Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6084c1a8d | ||
|
|
4aeb6d229d | ||
|
|
54086b5604 | ||
|
|
7b710471df | ||
|
|
20715e77cc | ||
|
|
39d857c4d1 | ||
|
|
ef28970654 |
No files matched your search
+30
-10
@@ -147,10 +147,19 @@ 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.
|
||||
**Experience is shared across the party, undivided — among everyone who was
|
||||
there.** Everyone alive 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.
|
||||
|
||||
Presence is a separate question from the split, and it has a radius:
|
||||
`SimConfig.XP_AWARD_RADIUS`, ten percent past `FOG_VIEW_RADIUS`. Anyone who
|
||||
watched something die is inside it; a player parked in the entrance room while
|
||||
the party clears the dungeon is not, and used to collect the whole run. Ten
|
||||
percent past sight rather than exactly on it, so the edge of the fog is
|
||||
comfortably inside and nobody loses a kill they actually saw. One consequence
|
||||
worth knowing: an upgraded shot outranges this several times over, so a kill
|
||||
made somewhere you cannot see pays nothing.
|
||||
|
||||
**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.
|
||||
@@ -168,12 +177,23 @@ than the one-second escape channel, and it would make that channel pointless.
|
||||
The menu greys the button out so the rule is visible, but the server refuses
|
||||
regardless of what any client's UI allows.
|
||||
|
||||
**Health regenerates at 0.5% of MAXIMUM per second, with no out-of-combat
|
||||
gate.** A percentage rather than a flat rate, so it does not become irrelevant
|
||||
at level 15 — a capped character regains 1.2 hp/s against a level 1's 0.5, and
|
||||
both take about 200 seconds to heal from nothing. No combat gate because at this
|
||||
rate it cannot out-heal anything actually shooting at you, and a trickle that
|
||||
never stops is easier to reason about than a timer players have to learn.
|
||||
**Health regenerates at 0.5% of MAXIMUM per second in a dungeon, with no
|
||||
out-of-combat gate.** A percentage rather than a flat rate, so it does not become
|
||||
irrelevant at level 15 — a capped character regains 1.2 hp/s against a level 1's
|
||||
0.5. No combat gate because at this rate it cannot out-heal anything actually
|
||||
shooting at you, and a trickle that never stops is easier to reason about than a
|
||||
timer players have to learn.
|
||||
|
||||
**The hub regenerates at 10% per second, and health is carried through a door.**
|
||||
Arriving anywhere used to set health to maximum, which meant a run cost nothing:
|
||||
walk in, take what you can, walk out, walk back in whole. Current health now
|
||||
lives on the character record — the same reason the inventory does, since
|
||||
`SimWorld` builds a blank player on every instance change — and the hub is where
|
||||
it is paid off, over about ten seconds. Fast enough that recovering is not a
|
||||
chore, slow enough that it is a decision to make before walking back in. The
|
||||
dungeon rate is unchanged, so waiting inside one is still not a plan.
|
||||
|
||||
More friction is expected here later; this is the floor, not the design.
|
||||
|
||||
**A dead character is gone, as far as the player is concerned.** Retirement is
|
||||
the server's own bookkeeping for archival and troubleshooting; the roster the
|
||||
|
||||
@@ -142,6 +142,15 @@ couple of minutes instead of a quarter of an hour, and because it is a portal
|
||||
rather than a launch flag you can compare it to a real run without restarting
|
||||
the server.
|
||||
|
||||
**It is a debugging tool and does not ship.** It pays exactly the same
|
||||
experience a real run does — `Progression.xp_for_boss` ignores its argument and
|
||||
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
|
||||
touch — while its boss has 8% of its health and its trash drops ten times as
|
||||
often. That makes it strictly the best place to level and to farm, which is
|
||||
correct for a harness and would be ruinous in a release. Remove it from
|
||||
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
|
||||
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
|
||||
|
||||
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
|
||||
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
|
||||
two have to stay in the same order.
|
||||
|
||||
+38
-4
@@ -13,6 +13,11 @@ signal state_changed(new_state: State)
|
||||
|
||||
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
|
||||
|
||||
## How long a refused peer is left connected so its rejection can actually go
|
||||
## out. Long enough to survive a frame's flush and the round trip, short enough
|
||||
## that nobody notices; see [method reject_and_kick].
|
||||
const REJECT_LINGER := 0.5
|
||||
|
||||
## Peer id the engine gives the host. On a listen server this is also a real
|
||||
## player, which is why every send goes through the helpers below.
|
||||
const LOCAL_PEER := 1
|
||||
@@ -97,9 +102,32 @@ func shutdown() -> void:
|
||||
_set_state(State.OFFLINE)
|
||||
|
||||
|
||||
## Tell a peer why it is not welcome, then close the socket.
|
||||
##
|
||||
## The kick is DEFERRED, which is the whole point of this existing. An RPC is
|
||||
## queued and only leaves on the frame's flush, so disconnecting in the same
|
||||
## call throws the reason away with the packet -- a client turned away for a
|
||||
## protocol mismatch was told only that the server closed the connection. The
|
||||
## peer can do nothing during the wait: it has no account bound, and every
|
||||
## message that needs one already refuses it.
|
||||
func reject_and_kick(peer_id: int, reason: String) -> void:
|
||||
send_reject(peer_id, reason)
|
||||
if _is_local(peer_id):
|
||||
return
|
||||
get_tree().create_timer(REJECT_LINGER).timeout.connect(
|
||||
func() -> void: kick(peer_id))
|
||||
|
||||
|
||||
func kick(peer_id: int) -> void:
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
|
||||
if multiplayer.multiplayer_peer is not ENetMultiplayerPeer:
|
||||
return
|
||||
# The peer may have hung up first. A rejected client closes its own socket
|
||||
# as soon as it reads the reason, and reject_and_kick's timer lands after
|
||||
# that -- asking ENet to disconnect someone already gone is an engine error,
|
||||
# not a quiet no-op.
|
||||
if not multiplayer.get_peers().has(peer_id):
|
||||
return
|
||||
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
|
||||
|
||||
|
||||
func is_server() -> bool:
|
||||
@@ -130,8 +158,14 @@ func _on_connect_failed() -> void:
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
last_error = "server closed the connection"
|
||||
GameLog.warn("net", last_error)
|
||||
# A rejection lands immediately before the socket closes, and it is the
|
||||
# useful half of the pair: "protocol mismatch: server 9, client 8" is
|
||||
# actionable and "server closed the connection" is not. Overwriting it here
|
||||
# made every reject reason in the game unreachable -- the reason was sent,
|
||||
# decoded, stored, and then clobbered a frame later by this line.
|
||||
if state != State.FAILED:
|
||||
last_error = "server closed the connection"
|
||||
GameLog.warn("net", last_error)
|
||||
shutdown()
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,18 @@ static func outside_map(p: Vector2, map: MapGrid) -> bool:
|
||||
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
|
||||
|
||||
|
||||
## The furthest a bullet with these parameters could travel before it expires.
|
||||
##
|
||||
## Used to decide whether a spawn is worth telling a client about. Deliberately
|
||||
## an OVER-estimate: acceleration is integrated at face value while a negative
|
||||
## one is ignored, and a turning bullet curves, so the straight-line answer is
|
||||
## always at least the real path. Over-estimating costs a packet; under-
|
||||
## estimating is a bullet that arrives invisible.
|
||||
static func bullet_reach(speed: float, accel: float, life_ticks: int) -> float:
|
||||
var t := float(maxi(life_ticks, 0)) * SimConfig.TICK_DELTA
|
||||
return maxf(speed, 0.0) * t + 0.5 * maxf(accel, 0.0) * t * t
|
||||
|
||||
|
||||
## Outside the map, or stopped by geometry.
|
||||
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
|
||||
if map == null:
|
||||
|
||||
+27
-4
@@ -64,11 +64,24 @@ const SPAWN_GRACE_TICKS := 120 # 2 seconds
|
||||
## simpler to reason about than a timer players have to learn, and at this rate
|
||||
## it cannot outpace anything that is actually shooting at you.
|
||||
const HP_REGEN_PERCENT_PER_SEC := 0.5
|
||||
## What the hub heals at instead. Arriving anywhere used to snap you to full,
|
||||
## which made health free: enter a dungeon, take what you could, leave, walk
|
||||
## back in whole. Recovering it takes ten seconds of standing in the hub now,
|
||||
## so the run you just left cost you something -- and the cost is time in the
|
||||
## one place nothing is shooting at you, not a resource to manage.
|
||||
const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0
|
||||
|
||||
## A downed player cannot leave for the hub until this has elapsed. Enforced
|
||||
## here rather than only by grey-ing out the button, because a disabled button
|
||||
## is a suggestion -- the server is the only thing a modified client cannot
|
||||
## argue with.
|
||||
## A downed player cannot leave for the hub until this has elapsed. Enforced in
|
||||
## the simulation rather than only by grey-ing out the button, because a
|
||||
## disabled button is a suggestion.
|
||||
##
|
||||
## Worth knowing what actually reaches it: a player whose peer is still
|
||||
## connected never waits, because ServerRuntime unbinds it on the same tick it
|
||||
## dies and leaves it at the roster screen -- that is a settled decision, not an
|
||||
## oversight (docs/DECISIONS.md, "Death unbinds the character entirely"). What
|
||||
## the lockout governs is the LINKDEAD body, which has nobody to show a roster
|
||||
## to and asks for itself; the countdown is what stops it vanishing out of a
|
||||
## fight the instant it is killed.
|
||||
const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds
|
||||
|
||||
# --- Input timing -----------------------------------------------------------
|
||||
@@ -186,6 +199,16 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
|
||||
const FOG_VIEW_RADIUS := 460.0
|
||||
|
||||
# --- Interest management ----------------------------------------------------
|
||||
## How near a kill you have to be to be paid for it. Ten percent beyond what a
|
||||
## player can see, so someone who watched it die is always inside and someone
|
||||
## parked at the entrance never is. Experience stays undivided among everyone
|
||||
## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much
|
||||
## each of them gets.
|
||||
##
|
||||
## Worth knowing: an upgraded shot outranges this several times over, so a kill
|
||||
## made far enough away that you cannot see it pays nothing.
|
||||
const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1
|
||||
|
||||
## Actors further than this from a player are not sent to that player at all.
|
||||
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
|
||||
## sight, but far short of a dungeon, so a modified client cannot read enemy
|
||||
|
||||
@@ -47,6 +47,13 @@ static func make_lobby(instance_id: int) -> Instance:
|
||||
inst.world.upgrade_npc = built["npc"]
|
||||
inst.world.has_upgrade_npc = true
|
||||
inst.world.spawn_point = built["spawn"]
|
||||
# Nothing here to escape from, and completing the channel in the hub means
|
||||
# transferring to the hub -- see SimWorld.escape_enabled.
|
||||
inst.world.escape_enabled = false
|
||||
# The hub is where a run's damage is paid off. Arriving used to heal you to
|
||||
# full, which made health free between runs; ten seconds of standing here
|
||||
# does the same job at a price.
|
||||
inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC
|
||||
inst.state = State.ACTIVE
|
||||
# A single inert practice target so players can feel out the gun before
|
||||
# committing to a run.
|
||||
|
||||
@@ -28,6 +28,17 @@ var died_unix: int = 0
|
||||
## bags, and so a server restart does not quietly confiscate everyone's potions.
|
||||
var inventory: Array[StringName] = []
|
||||
|
||||
## Current health, or [constant HP_FULL] for "however much this character can
|
||||
## have". Stored for the same reason the inventory is: SimWorld builds a blank
|
||||
## player on every instance change, so anything that must survive walking
|
||||
## through a door has to live on the record. Without it, entering and leaving
|
||||
## was a free heal, and health cost nothing between runs.
|
||||
##
|
||||
## HP_FULL rather than a number so a save written before this existed, and a
|
||||
## character that has never been hurt, both load as whole rather than as dead.
|
||||
const HP_FULL := -1
|
||||
var hp: int = HP_FULL
|
||||
|
||||
## Upgrades taken, in the order they were taken. Duplicates are meaningful --
|
||||
## two Split Shots really is two splits -- so this is a list, not a set.
|
||||
##
|
||||
@@ -140,6 +151,7 @@ func to_dict() -> Dictionary:
|
||||
# Items.ORDER being appended to, and a human editing it should be able
|
||||
# to tell what a character is carrying.
|
||||
"inventory": _inventory_ids(),
|
||||
"hp": hp,
|
||||
"upgrades": _name_list(upgrades),
|
||||
"pending_choices": pending_choices,
|
||||
"offer": _name_list(offer),
|
||||
@@ -180,6 +192,11 @@ static func from_dict(d: Dictionary) -> Character:
|
||||
var carried_id := StringName(String(raw))
|
||||
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
|
||||
c.set_inventory(carried)
|
||||
# Anything unreadable, negative or absent means full. The failure that
|
||||
# matters is a save loading as a character with no health left, which under
|
||||
# permadeath is not something a player can recover from.
|
||||
var saved_hp := int(d.get("hp", HP_FULL))
|
||||
c.hp = saved_hp if saved_hp > 0 else HP_FULL
|
||||
# Unknown upgrade ids are dropped rather than kept as dead entries, so a
|
||||
# save from a build with an upgrade this one lacks still produces coherent
|
||||
# stats instead of a phantom that counts toward the +5% and does nothing.
|
||||
|
||||
@@ -20,6 +20,9 @@ const MAX_ACTIVE := 5
|
||||
var _accounts: Dictionary[int, Dictionary] = {}
|
||||
var _path: String = SAVE_PATH
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
## Set by [method save_deferred]; cleared by the next write. See that method for
|
||||
## why one path defers and every other one does not.
|
||||
var _dirty: bool = false
|
||||
|
||||
|
||||
func _init(path: String = SAVE_PATH) -> void:
|
||||
@@ -108,12 +111,35 @@ func retire_character(account_id: int, character_id: String) -> void:
|
||||
## Write a character's carried items back to disk. Separate from grant_xp
|
||||
## rather than folded into a general "save this character", so the one caller
|
||||
## reads as what it is.
|
||||
func set_inventory(account_id: int, character_id: String, items: Array[StringName]) -> void:
|
||||
##
|
||||
## [param defer] coalesces the write to the end of the server tick. Item events
|
||||
## are the one persistence path a client can trigger at the tick rate -- the
|
||||
## slot byte counts toward the edge, so alternating it with drop held produces
|
||||
## an action every tick -- and each one otherwise re-serialised every account on
|
||||
## the server to disk. Deferring bounds that at one write per tick however many
|
||||
## players are doing it. The risk the immediate write guards against is a crash
|
||||
## between moving an item and recording it; a tick is 16ms of that, and the
|
||||
## direction of the loss is an item that stays in your bag, never a duplicate.
|
||||
func set_inventory(account_id: int, character_id: String, items: Array[StringName],
|
||||
defer: bool = false) -> void:
|
||||
var c := get_character(account_id, character_id)
|
||||
if c == null:
|
||||
return
|
||||
c.set_inventory(items)
|
||||
save()
|
||||
if defer:
|
||||
save_deferred()
|
||||
else:
|
||||
save()
|
||||
|
||||
|
||||
## Bank a character's current health. Deferred like the inventory: it changes
|
||||
## every tick a player is regenerating, and the server flushes once a tick.
|
||||
func set_health(account_id: int, character_id: String, hp: int) -> void:
|
||||
var c := get_character(account_id, character_id)
|
||||
if c == null:
|
||||
return
|
||||
c.hp = maxi(hp, 1)
|
||||
save_deferred()
|
||||
|
||||
|
||||
## Award level-up choices and, if nothing is on the table yet, roll one.
|
||||
@@ -162,14 +188,32 @@ func grant_xp(account_id: int, character_id: String, amount: int) -> int:
|
||||
if c == null:
|
||||
return 0
|
||||
var gained := c.grant_xp(amount)
|
||||
if gained > 0:
|
||||
save()
|
||||
# Every grant is written, not only the ones that cross a level boundary.
|
||||
# Saving on gained > 0 alone meant a character could kill its way to one
|
||||
# point short of a level and lose all of it to a restart, because the level
|
||||
# is DERIVED from lifetime experience -- the number that was not being kept
|
||||
# is the only number there is. Deferred so a busy fight still costs one
|
||||
# write per tick rather than one per kill.
|
||||
save_deferred()
|
||||
return gained
|
||||
|
||||
|
||||
# --- Persistence ------------------------------------------------------------
|
||||
|
||||
## Mark the store as needing a write, without doing it now. The owner calls
|
||||
## [method flush] once per tick; see [method set_inventory].
|
||||
func save_deferred() -> void:
|
||||
_dirty = true
|
||||
|
||||
|
||||
## Write only if something asked for one since the last write.
|
||||
func flush() -> void:
|
||||
if _dirty:
|
||||
save()
|
||||
|
||||
|
||||
func save() -> void:
|
||||
_dirty = false
|
||||
var accounts := {}
|
||||
for account_id in _accounts:
|
||||
var entry: Dictionary = _accounts[account_id]
|
||||
|
||||
@@ -450,6 +450,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
|
||||
func on_snapshot(data: PackedByteArray) -> void:
|
||||
var snap := NetCodec.decode_snapshot(data)
|
||||
if not snap["valid"]:
|
||||
# Unreliable channel, no checksum: a truncated snapshot is a network
|
||||
# event, not only an attack. Dropping one costs 50ms of interpolation;
|
||||
# applying half of one moves the clock estimate and the input lead on
|
||||
# numbers read out of the end of a buffer.
|
||||
return
|
||||
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
|
||||
return # stale or duplicate; unreliable channel, newest wins
|
||||
cleared_countdown = int(snap["cleared_countdown"])
|
||||
|
||||
+101
-11
@@ -38,7 +38,13 @@ static func encode_snapshot(world: SimWorld,
|
||||
or eye.distance_squared_to(p.pos) <= cull_sq:
|
||||
visible_players.append(p)
|
||||
|
||||
b.put_u8(mini(visible_players.size(), 255))
|
||||
# Sliced, not just clamped. Writing mini(size, 255) as the header and then
|
||||
# looping the full array puts records after the count on the wire, and the
|
||||
# decoder reads the enemy count out of the middle of a player. MAX_CLIENTS
|
||||
# keeps it unreachable today, which is exactly why it would survive to the
|
||||
# day it is not.
|
||||
visible_players = visible_players.slice(0, 255)
|
||||
b.put_u8(visible_players.size())
|
||||
for p in visible_players:
|
||||
b.put_u32(p.peer_id)
|
||||
b.put_float(p.pos.x)
|
||||
@@ -81,7 +87,8 @@ static func encode_snapshot(world: SimWorld,
|
||||
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))
|
||||
live_enemies = live_enemies.slice(0, 65535)
|
||||
b.put_u16(live_enemies.size())
|
||||
for e in live_enemies:
|
||||
b.put_u32(e.id)
|
||||
b.put_float(e.pos.x)
|
||||
@@ -123,7 +130,8 @@ static func encode_snapshot(world: SimWorld,
|
||||
if eye.distance_squared_to(l.pos) > cull_sq:
|
||||
continue
|
||||
visible_loot.append(l)
|
||||
b.put_u16(mini(visible_loot.size(), 65535))
|
||||
visible_loot = visible_loot.slice(0, 65535)
|
||||
b.put_u16(visible_loot.size())
|
||||
for l in visible_loot:
|
||||
b.put_u32(l.id)
|
||||
b.put_float(l.pos.x)
|
||||
@@ -132,18 +140,41 @@ static func encode_snapshot(world: SimWorld,
|
||||
return b.data_array
|
||||
|
||||
|
||||
# Fixed record sizes, mirroring what encode_snapshot writes. Pinned against the
|
||||
# encoder by test_net_codec so the two cannot drift.
|
||||
const SNAPSHOT_HEADER := 5 ## u32 tick + u8 countdown
|
||||
const SNAPSHOT_PLAYER := 33
|
||||
const SNAPSHOT_ENEMY := 16
|
||||
const SNAPSHOT_BOSS := 17
|
||||
const SNAPSHOT_LOOT := 13
|
||||
|
||||
|
||||
## Decode one snapshot. The returned dictionary carries "valid": a short or
|
||||
## corrupt packet answers false and the caller drops it.
|
||||
##
|
||||
## Snapshots ride an unreliable channel with no checksum, so a truncated one is
|
||||
## a network event rather than only an attack -- and this decoder used to read
|
||||
## straight through the end of the buffer. A garbage enemy count is up to 65535
|
||||
## dictionaries built out of nothing, twenty times a second, and a garbage tick
|
||||
## poisons the client's clock estimate and its input lead. Every colder message
|
||||
## here already checks; the two hottest did not.
|
||||
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(),
|
||||
"tick": 0, "cleared_countdown": Protocol.COUNTDOWN_NONE,
|
||||
"players": [], "enemies": [], "boss": null,
|
||||
"loot": [], "inventory": [],
|
||||
"loot": [], "inventory": [], "valid": false,
|
||||
}
|
||||
if b.get_available_bytes() < SNAPSHOT_HEADER + 1:
|
||||
return snap
|
||||
snap["tick"] = b.get_u32()
|
||||
snap["cleared_countdown"] = b.get_u8()
|
||||
|
||||
var pcount := b.get_u8()
|
||||
if b.get_available_bytes() < pcount * SNAPSHOT_PLAYER:
|
||||
return snap
|
||||
for _i in pcount:
|
||||
snap["players"].append({
|
||||
"peer": b.get_u32(),
|
||||
@@ -159,7 +190,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"total_xp": b.get_u32(),
|
||||
})
|
||||
|
||||
if b.get_available_bytes() < 2:
|
||||
return snap
|
||||
var ecount := b.get_u16()
|
||||
if b.get_available_bytes() < ecount * SNAPSHOT_ENEMY:
|
||||
return snap
|
||||
for _i in ecount:
|
||||
snap["enemies"].append({
|
||||
"id": b.get_u32(),
|
||||
@@ -169,7 +204,12 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"visual": b.get_u8(),
|
||||
})
|
||||
|
||||
if b.get_u8() == 1:
|
||||
if b.get_available_bytes() < 1:
|
||||
return snap
|
||||
var has_boss := b.get_u8() == 1
|
||||
if has_boss and b.get_available_bytes() < SNAPSHOT_BOSS:
|
||||
return snap
|
||||
if has_boss:
|
||||
snap["boss"] = {
|
||||
"id": b.get_u32(),
|
||||
"pos": Vector2(b.get_float(), b.get_float()),
|
||||
@@ -177,18 +217,23 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"phase": b.get_u8(),
|
||||
}
|
||||
|
||||
if b.get_available_bytes() < SimConfig.INVENTORY_SLOTS + 2:
|
||||
return snap
|
||||
var inventory: Array[int] = []
|
||||
for _i in SimConfig.INVENTORY_SLOTS:
|
||||
inventory.append(b.get_u8())
|
||||
snap["inventory"] = inventory
|
||||
|
||||
var lcount := b.get_u16()
|
||||
if b.get_available_bytes() < lcount * SNAPSHOT_LOOT:
|
||||
return snap
|
||||
for _i in lcount:
|
||||
snap["loot"].append({
|
||||
"id": b.get_u32(),
|
||||
"pos": Vector2(b.get_float(), b.get_float()),
|
||||
"item": b.get_u8(),
|
||||
})
|
||||
snap["valid"] = true
|
||||
return snap
|
||||
|
||||
|
||||
@@ -271,16 +316,53 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
return b.data_array
|
||||
|
||||
|
||||
## Bytes an event of [param t] writes after its type byte, or -1 for a type this
|
||||
## build cannot parse.
|
||||
##
|
||||
## The decoder needs this BEFORE the match, because an event whose length it
|
||||
## does not know is not a single bad record: every field after it is read at the
|
||||
## wrong offset, so one stray byte becomes a stream of invented bullets at
|
||||
## invented positions. Pinned against encode_events by test_net_codec.
|
||||
static func event_payload_size(t: int) -> int:
|
||||
match t:
|
||||
SimEvent.Type.BULLET_SPAWN: return 36
|
||||
SimEvent.Type.BULLET_DESPAWN: return 4
|
||||
SimEvent.Type.PLAYER_HIT: return 8
|
||||
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
||||
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: return 4
|
||||
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
||||
SimEvent.Type.ITEM_DROPPED: return 5
|
||||
SimEvent.Type.PLAYER_RESPAWNED: return 12
|
||||
SimEvent.Type.ENEMY_HIT: return 10
|
||||
SimEvent.Type.ENEMY_DIED: return 4
|
||||
SimEvent.Type.BOSS_PHASE: return 1
|
||||
SimEvent.Type.TELEGRAPH: return 14
|
||||
SimEvent.Type.BOSS_DIED: return 0
|
||||
return -1
|
||||
|
||||
|
||||
## Returns { "tick": int, "events": Array[Dictionary] }.
|
||||
##
|
||||
## Decoding stops at the first record that does not fit or that names a type
|
||||
## this build has no length for. The stream is reliable and ordered, so the
|
||||
## prefix decoded before that point is sound and is kept -- unlike a snapshot,
|
||||
## dropping the whole packet would strand every bullet it was about to despawn.
|
||||
static func decode_events(data: PackedByteArray) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.data_array = data
|
||||
if b.get_available_bytes() < 6:
|
||||
return {"tick": 0, "events": out}
|
||||
var server_tick := b.get_u32()
|
||||
var count := b.get_u16()
|
||||
for _i in count:
|
||||
if b.get_available_bytes() < 1:
|
||||
break
|
||||
var t := b.get_u8()
|
||||
var payload := event_payload_size(t)
|
||||
if payload < 0 or b.get_available_bytes() < payload:
|
||||
break
|
||||
var ev := {"t": t}
|
||||
match t:
|
||||
SimEvent.Type.BULLET_SPAWN:
|
||||
@@ -361,7 +443,8 @@ static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
|
||||
static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_u8(mini(entries.size(), 255))
|
||||
entries = entries.slice(0, 255)
|
||||
b.put_u8(entries.size())
|
||||
for e in entries:
|
||||
b.put_u32(int(e["peer"]))
|
||||
b.put_utf8_string(String(e["name"]))
|
||||
@@ -535,7 +618,13 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack
|
||||
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))
|
||||
var ceiling := c.max_hp()
|
||||
b.put_u16(clampi(ceiling, 1, 65535))
|
||||
# Current health as well as the ceiling. Health survives a run now, so a
|
||||
# character on the roster can be badly hurt, and a screen that showed
|
||||
# only the maximum would advertise a wounded character as whole -- which
|
||||
# under permadeath is the worst possible thing to be wrong about.
|
||||
b.put_u16(clampi(ceiling if c.hp == Character.HP_FULL else c.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:
|
||||
@@ -574,8 +663,8 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
|
||||
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
|
||||
# max_hp, hp, active, colour, then one byte per inventory slot.
|
||||
var fixed := 1 + 4 + 1 + 2 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
|
||||
for _i in count:
|
||||
var id := _safe_utf8(b)
|
||||
var display := _safe_utf8(b)
|
||||
@@ -588,6 +677,7 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
|
||||
"xp": b.get_u32(),
|
||||
"progress": float(b.get_u8()) / 255.0,
|
||||
"max_hp": b.get_u16(),
|
||||
"hp": b.get_u16(),
|
||||
"active": b.get_u8() == 1,
|
||||
"colour": Color.hex(b.get_u32()),
|
||||
"inventory": _read_inventory(b),
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@ extends RefCounted
|
||||
## 8: upgrades. A new server -> client upgrade-state message, a new
|
||||
## client -> server choice message, and two more SelectResult values.
|
||||
## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum.
|
||||
const VERSION := 9
|
||||
## 10: health survives a run, so the character roster carries current hit
|
||||
## points as well as the ceiling. Inserted mid-record, which shifts every
|
||||
## field after it -- a mismatched client would read the active flag out of
|
||||
## the middle of a health value.
|
||||
const VERSION := 10
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
|
||||
+193
-23
@@ -24,8 +24,25 @@ var store: CharacterStore = null
|
||||
var peer_chunks: Dictionary[int, Dictionary] = {}
|
||||
var lobby: Instance
|
||||
|
||||
## Ticks a peer must wait between control messages. Selecting or creating a
|
||||
## character rewrites the whole store to disk, re-places the player (which wipes
|
||||
## its map-chunk record and re-streams the neighbourhood), and broadcasts a
|
||||
## roster to every peer on the server -- all off one small reliable packet.
|
||||
## Unrated, one client could hold the server in a synchronous JSON write. A
|
||||
## quarter of a second is far under anything a person clicks deliberately.
|
||||
const CONTROL_COOLDOWN_TICKS := 15
|
||||
|
||||
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
|
||||
var _snapshot_phase: int = 0
|
||||
## Server clock, in physics ticks. Only used to pace the control messages above.
|
||||
var _tick: int = 0
|
||||
## Tick each peer was last allowed a control message on.
|
||||
var _peer_control_tick: Dictionary[int, int] = {}
|
||||
## Peers already told off for going too fast. Without this the refusal logs one
|
||||
## line per packet, which turns the limiter into the amplification it exists to
|
||||
## prevent -- a client sending a thousand a second would get a thousand lines
|
||||
## formatted and written. One line per burst, cleared when the peer behaves.
|
||||
var _peer_control_warned: Dictionary[int, bool] = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -49,6 +66,7 @@ func _take_instance_id() -> int:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_tick += 1
|
||||
_snapshot_phase += 1
|
||||
var send_snapshot := _snapshot_phase % SimConfig.SNAPSHOT_INTERVAL == 0
|
||||
var closing: Array[int] = []
|
||||
@@ -79,6 +97,12 @@ func _physics_process(_delta: float) -> void:
|
||||
for id in closing:
|
||||
_close_dungeon(id)
|
||||
|
||||
# One write per tick at most, however many item pickups and experience
|
||||
# grants the tick produced. Everything that defers is bounded here; the
|
||||
# paths that matter more (a character created, retired, or levelled) still
|
||||
# write the moment they happen.
|
||||
store.flush()
|
||||
|
||||
|
||||
## Split the tick's events into the ones clients need and the ones only the
|
||||
## server acts on (escape completion, portal use, respawn requests).
|
||||
@@ -95,9 +119,11 @@ func _dispatch_events(inst: Instance) -> void:
|
||||
for ev in events:
|
||||
match int(ev["t"]):
|
||||
SimEvent.Type.ENEMY_DIED:
|
||||
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
|
||||
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))),
|
||||
ev.get("pos", Vector2.ZERO))
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
|
||||
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))),
|
||||
ev.get("pos", Vector2.ZERO))
|
||||
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
||||
SimEvent.Type.ITEM_DROPPED:
|
||||
# The simulation moved items between the ground and a bag; the
|
||||
@@ -174,6 +200,7 @@ func on_peer_disconnected(peer_id: int) -> void:
|
||||
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
|
||||
_broadcast_roster()
|
||||
return
|
||||
_bank_health(inst, peer_id)
|
||||
inst.remove_peer(peer_id)
|
||||
_forget_peer(peer_id)
|
||||
GameLog.info("server", "peer %d disconnected" % peer_id)
|
||||
@@ -185,6 +212,8 @@ func _forget_peer(peer_id: int) -> void:
|
||||
peer_chunks.erase(peer_id)
|
||||
peer_accounts.erase(peer_id)
|
||||
peer_characters.erase(peer_id)
|
||||
_peer_control_tick.erase(peer_id)
|
||||
_peer_control_warned.erase(peer_id)
|
||||
_broadcast_roster()
|
||||
|
||||
|
||||
@@ -192,6 +221,9 @@ func _forget_peer(peer_id: int) -> void:
|
||||
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
|
||||
## the same outcome, so it is simply removed.
|
||||
func _release_linkdead(peer_id: int, inst: Instance) -> void:
|
||||
# Whatever the channel left it on. Pulling the plug at one hit point must
|
||||
# not be a way to come back whole.
|
||||
_bank_health(inst, peer_id)
|
||||
inst.remove_peer(peer_id)
|
||||
GameLog.info("server", "peer %d released from instance %d after drop"
|
||||
% [peer_id, inst.id])
|
||||
@@ -206,14 +238,29 @@ func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void:
|
||||
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)
|
||||
Net.reject_and_kick(peer_id,
|
||||
"protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
|
||||
return
|
||||
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)
|
||||
Net.reject_and_kick(peer_id, "authentication failed")
|
||||
return
|
||||
# One account, one session. Two peers on one account each get their own
|
||||
# SimPlayer built from the SAME character record, so both hold a copy of the
|
||||
# same bag and whichever writes last wins -- drop the one potion from both
|
||||
# copies and two potions hit the floor. Refusing the second connection is the
|
||||
# only fix that closes it; reconciling two live mirrors of one record after
|
||||
# the fact cannot be made to work.
|
||||
#
|
||||
# The NEW peer is refused rather than the old one kicked, deliberately: with
|
||||
# an auth provider anyone can spoof, "kick whoever holds this account" would
|
||||
# be a way to disconnect any player on the server at will.
|
||||
var held_by := peer_for_account(account)
|
||||
if held_by != 0:
|
||||
GameLog.warn("server", "peer %d claimed account %d, already held by peer %d"
|
||||
% [peer_id, account, held_by])
|
||||
Net.reject_and_kick(peer_id, "that account is already playing")
|
||||
return
|
||||
peer_accounts[peer_id] = account
|
||||
Net.send_welcome(peer_id)
|
||||
@@ -255,6 +302,16 @@ func on_select_character(peer_id: int, character_id: String) -> void:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
|
||||
"you can only change character in the hub")
|
||||
return
|
||||
# Already playing this one, and already where a swap would put you. Answering
|
||||
# yes and stopping is not just an optimisation: the full path rewrites the
|
||||
# store, re-places the player and re-streams its map, so a client that simply
|
||||
# repeats its own selection was free work for the asking.
|
||||
if peer_characters.get(peer_id, "") == character_id and here != null:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
|
||||
_send_characters(peer_id)
|
||||
return
|
||||
if not _accept_control(peer_id):
|
||||
return
|
||||
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")
|
||||
@@ -278,6 +335,8 @@ func on_create_character(peer_id: int, character_name: String) -> void:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
|
||||
"you can only change character in the hub")
|
||||
return
|
||||
if not _accept_control(peer_id):
|
||||
return
|
||||
var c := store.create_character(account, character_name)
|
||||
if c == null:
|
||||
Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED,
|
||||
@@ -295,6 +354,7 @@ 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:
|
||||
_bank_health(previous, peer_id)
|
||||
previous.remove_peer(peer_id)
|
||||
peer_characters[peer_id] = c.id
|
||||
peer_names[peer_id] = c.display_name
|
||||
@@ -374,6 +434,39 @@ func instance_of(peer_id: int) -> Instance:
|
||||
return instances.get(id)
|
||||
|
||||
|
||||
## Whether [param peer_id] may spend a control message this tick, recording it
|
||||
## if so. Silent when it refuses: there is no SelectResult for "too fast", and a
|
||||
## dropped message at this rate can only come from a client that is not a menu.
|
||||
func _accept_control(peer_id: int) -> bool:
|
||||
var last: int = _peer_control_tick.get(peer_id, -CONTROL_COOLDOWN_TICKS)
|
||||
if _tick - last < CONTROL_COOLDOWN_TICKS:
|
||||
if not _peer_control_warned.get(peer_id, false):
|
||||
_peer_control_warned[peer_id] = true
|
||||
GameLog.warn("server",
|
||||
"peer %d is sending control messages faster than %d ticks apart"
|
||||
% [peer_id, CONTROL_COOLDOWN_TICKS])
|
||||
return false
|
||||
_peer_control_tick[peer_id] = _tick
|
||||
_peer_control_warned.erase(peer_id)
|
||||
return true
|
||||
|
||||
|
||||
## The peer currently signed in as [param account], or 0 for none.
|
||||
##
|
||||
## A peer that dropped inside a dungeon still counts while its linkdead body is
|
||||
## channelling out, which is the point: reconnecting during that second would
|
||||
## put the same character in the world twice -- once as the body still being
|
||||
## shot at, once fresh in the hub -- and a death on the abandoned body would
|
||||
## then retire the character being played.
|
||||
func peer_for_account(account: int) -> int:
|
||||
if account == AuthProvider.NO_ACCOUNT:
|
||||
return 0
|
||||
for peer in peer_accounts:
|
||||
if peer_accounts[peer] == account:
|
||||
return peer
|
||||
return 0
|
||||
|
||||
|
||||
# --- Transfers --------------------------------------------------------------
|
||||
|
||||
func _place(peer_id: int, inst: Instance) -> void:
|
||||
@@ -419,15 +512,19 @@ func _adopt_character(peer_id: int, inst: Instance) -> void:
|
||||
if c == null or p == null:
|
||||
return
|
||||
p.adopt(c)
|
||||
# Arriving anywhere is a fresh start. adopt() only clamps health down to the
|
||||
# new ceiling, which would leave a character whose upgrades RAISED it
|
||||
# arriving wounded for no reason.
|
||||
p.hp = p.max_hp
|
||||
# Health comes off the record, not from a full bar. Arriving used to heal
|
||||
# you outright, which meant a run cost nothing: walk in, take what you can,
|
||||
# walk out, walk back in whole. It is paid off in the hub instead, which
|
||||
# regenerates fast (SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) but not
|
||||
# instantly. HP_FULL covers a new character and a save written before health
|
||||
# was kept; the clamp covers upgrades that moved the ceiling either way.
|
||||
p.hp = p.max_hp if c.hp == Character.HP_FULL else clampi(c.hp, 1, p.max_hp)
|
||||
|
||||
|
||||
func _transfer(peer_id: int, to: Instance) -> void:
|
||||
var from := instance_of(peer_id)
|
||||
if from != null:
|
||||
_bank_health(from, peer_id)
|
||||
from.remove_peer(peer_id)
|
||||
_place(peer_id, to)
|
||||
_broadcast_roster()
|
||||
@@ -490,6 +587,17 @@ func _close_dungeon(id: int) -> void:
|
||||
return
|
||||
for peer in inst.peers.duplicate():
|
||||
_transfer(peer, lobby)
|
||||
# A linkdead body is not in inst.peers, so the loop above walks straight past
|
||||
# it. The empty-instance path checks has_linkdead() and waits; the CLEARED
|
||||
# path does not, so a party that killed the boss with one member disconnected
|
||||
# left that peer's account, name and instance id in the dictionaries with no
|
||||
# instance to resolve -- invisible, permanent, and enough to keep the account
|
||||
# locked out of its own session by the one-account guard.
|
||||
for p in inst.world.players.values():
|
||||
if p.linkdead:
|
||||
GameLog.info("server", "peer %d released with instance %d as it closed"
|
||||
% [p.peer_id, id])
|
||||
_forget_peer(p.peer_id)
|
||||
instances.erase(id)
|
||||
GameLog.info("server", "closed dungeon instance %d" % id)
|
||||
|
||||
@@ -521,16 +629,26 @@ 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:
|
||||
## Experience is shared, undivided, by everyone alive and near enough to
|
||||
## [param at] to have been part of it.
|
||||
##
|
||||
## Undivided is the settled decision: splitting it would make bringing a friend
|
||||
## cost you progress, which is the opposite of what a co-op game wants. The
|
||||
## radius is a separate question -- who was there -- and it exists because
|
||||
## without one a player parked at the entrance collected full boss experience
|
||||
## for the whole run. SimConfig.XP_AWARD_RADIUS is ten percent past what a
|
||||
## player can see, so anyone who watched it die is inside it and anyone who
|
||||
## could not possibly have known is not.
|
||||
func _award_kill(inst: Instance, amount: int, at: Vector2) -> void:
|
||||
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||
return
|
||||
var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS
|
||||
for peer in inst.peers:
|
||||
var p: SimPlayer = inst.world.players.get(peer)
|
||||
if p == null or not p.alive:
|
||||
continue
|
||||
if p.pos.distance_squared_to(at) > reach_sq:
|
||||
continue
|
||||
_grant_xp(peer, amount)
|
||||
|
||||
|
||||
@@ -540,6 +658,23 @@ func _award_kill(inst: Instance, amount: int) -> void:
|
||||
## point of persisting an inventory is that a crash between "picked it up" and
|
||||
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
|
||||
## duplicate one.
|
||||
## Copy a player's current health back onto its character record.
|
||||
##
|
||||
## Called wherever the [SimPlayer] is about to stop existing -- every transfer,
|
||||
## every disconnect -- because SimWorld builds a blank one on the other side and
|
||||
## whatever was not written down is gone. Also called on item transactions, so
|
||||
## drinking a potion is banked with the potion that was spent.
|
||||
func _bank_health(inst: Instance, peer_id: 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 p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null or not p.alive:
|
||||
return # a dead character is retired; its health is not a thing anymore
|
||||
store.set_health(account, character_id, p.hp)
|
||||
|
||||
|
||||
func _persist_inventory(inst: Instance, peer_id: int) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
var character_id: String = peer_characters.get(peer_id, "")
|
||||
@@ -548,7 +683,14 @@ func _persist_inventory(inst: Instance, peer_id: int) -> void:
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null:
|
||||
return
|
||||
store.set_inventory(account, character_id, p.inventory)
|
||||
# Deferred: a client can produce an item action every tick, and each one used
|
||||
# to re-serialise every account on the server. ServerRuntime flushes once at
|
||||
# the end of the tick.
|
||||
store.set_inventory(account, character_id, p.inventory, true)
|
||||
# A potion moves an item and hit points in the same action; banking one
|
||||
# without the other would let a restart hand the potion back.
|
||||
if p.alive:
|
||||
store.set_health(account, character_id, p.hp)
|
||||
|
||||
|
||||
func _grant_xp(peer_id: int, amount: int) -> void:
|
||||
@@ -655,30 +797,58 @@ func _broadcast_roster() -> void:
|
||||
Net.send_roster(peer, payload)
|
||||
|
||||
|
||||
## Bullet spawns within this peer's interest radius. See
|
||||
## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the
|
||||
## one used for actors.
|
||||
## Bullet spawns this peer could actually be reached by.
|
||||
##
|
||||
## Tested per bullet rather than against a flat radius, and that distinction is
|
||||
## the whole point. A spawn event carries the firing actor's exact position, so
|
||||
## a radius wide enough for the fastest shot in the game hands a modified client
|
||||
## the location of every enemy inside it -- 2900 units, against an 800-unit
|
||||
## actor cull and a 900-unit map stream. At the depth the game actually runs at
|
||||
## the map's own diagonal is about 2521, so the flat radius culled NOTHING: an
|
||||
## enemy firing anywhere on the floor was pinpointed for everyone, through the
|
||||
## fog, on terrain the client had deliberately not been streamed.
|
||||
##
|
||||
## Asking instead whether THIS bullet can reach ground this player can see keeps
|
||||
## the guarantee the wide radius existed for -- nothing ever arrives invisible --
|
||||
## while a slow, short-lived shot across the map stops being a free radar ping.
|
||||
func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null:
|
||||
return spawns
|
||||
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
|
||||
var out: Array[Dictionary] = []
|
||||
for ev in spawns:
|
||||
if p.pos.distance_squared_to(ev["pos"]) <= cull_sq:
|
||||
if _bullet_concerns(p.pos, ev["pos"], ev["vel"], ev["accel"], ev["life"]):
|
||||
out.append(ev)
|
||||
return out
|
||||
|
||||
|
||||
## Whether a bullet born at [param at] could reach ground visible from
|
||||
## [param eye] before it expires. BULLET_INTEREST_RADIUS stays on as a hard
|
||||
## ceiling: test_interest.gd proves no bullet in the game needs more than it, so
|
||||
## it can only ever agree with the per-bullet answer -- but if some future
|
||||
## content outruns the computation, the failure is a culled bullet rather than
|
||||
## an unbounded broadcast.
|
||||
func _bullet_concerns(eye: Vector2, at: Vector2, vel: Vector2, accel: float,
|
||||
life: int) -> bool:
|
||||
var horizon := minf(
|
||||
Movement.bullet_reach(vel.length(), accel, life) + SimConfig.FOG_VIEW_RADIUS,
|
||||
SimConfig.BULLET_INTEREST_RADIUS)
|
||||
return eye.distance_squared_to(at) <= horizon * horizon
|
||||
|
||||
|
||||
func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
|
||||
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
|
||||
for i in world.pool.high_water:
|
||||
if world.pool.alive[i] == 0:
|
||||
continue
|
||||
if observer != null \
|
||||
and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq:
|
||||
# Same per-bullet question as _spawns_near. The backlog a joiner is sent
|
||||
# is a snapshot of every live bullet, so a flat radius here leaked the
|
||||
# position of every firing enemy in the instance the moment you walked
|
||||
# in -- the one moment you have seen none of the map yet.
|
||||
if observer != null and not _bullet_concerns(observer.pos,
|
||||
world.pool.pos[i], world.pool.vel[i], world.pool.accel[i],
|
||||
world.pool.life[i]):
|
||||
continue
|
||||
out.append({
|
||||
"t": SimEvent.Type.BULLET_SPAWN,
|
||||
|
||||
+29
-4
@@ -177,7 +177,31 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
|
||||
|
||||
## Bresenham-style sight test between two world points. Used for fog on the
|
||||
## client and for aggro on the server, so it has to agree on both.
|
||||
##
|
||||
## [param include_end] is false here: the endpoints themselves never block, so
|
||||
## standing in a doorway, or shooting at something embedded in a wall, still
|
||||
## resolves.
|
||||
func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
|
||||
return _walk_clear(from, to, BLOCKS_SIGHT, false)
|
||||
|
||||
|
||||
## Whether a bullet could travel from [param from] to [param to] without
|
||||
## crossing geometry.
|
||||
##
|
||||
## Needed because a muzzle offset is a teleport: [BulletPool] samples position
|
||||
## once per tick and never sees the jump from a player's centre out to where the
|
||||
## shot is actually born, so anything that places a bullet away from its shooter
|
||||
## has to ask this first. Unlike sight, the DESTINATION counts -- a bullet born
|
||||
## inside a wall is exactly the case being ruled out.
|
||||
func bullet_path_clear(from: Vector2, to: Vector2) -> bool:
|
||||
return _walk_clear(from, to, BLOCKS_BULLET, true)
|
||||
|
||||
|
||||
## Shared Bresenham walk behind both tests above, so the two can never disagree
|
||||
## about which tiles a straight line crosses. [param flags] is one of the
|
||||
## BLOCKS_* tables; [param include_end] decides whether the destination tile is
|
||||
## allowed to stop the walk.
|
||||
func _walk_clear(from: Vector2, to: Vector2, flags: Array, include_end: bool) -> bool:
|
||||
var a := to_tile(from)
|
||||
var b := to_tile(to)
|
||||
var dx := absi(b.x - a.x)
|
||||
@@ -192,12 +216,13 @@ func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
|
||||
var limit := width + height + 4
|
||||
while steps < limit:
|
||||
steps += 1
|
||||
if x == b.x and y == b.y:
|
||||
var at_end := x == b.x and y == b.y
|
||||
if at_end and not include_end:
|
||||
return true
|
||||
# The endpoints themselves never block: standing in a doorway, or
|
||||
# shooting at something embedded in a wall, must still resolve.
|
||||
if not (x == a.x and y == a.y) and blocks_sight(x, y):
|
||||
if not (x == a.x and y == a.y) and bool(flags[at(x, y)]):
|
||||
return false
|
||||
if at_end:
|
||||
return true
|
||||
var e2 := 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
|
||||
@@ -54,7 +54,44 @@ func shot_index_at(local_tick: int) -> int:
|
||||
|
||||
|
||||
## Shared spawn path so every emitter produces bullets with identical physics.
|
||||
func emit_shot(ctx: EmitContext, angle: float, from: Vector2, speed_scale: float = 1.0) -> void:
|
||||
##
|
||||
## [param from] is where the shot appears, which for nearly every emitter is a
|
||||
## muzzle offset away from the shooter -- up to 46px here, and an arc sweep's
|
||||
## outer arms further still. That offset is a teleport: [BulletPool] samples
|
||||
## geometry once per tick and never sees the jump, so a shot could be born on
|
||||
## the far side of a 32px wall and fly away down the other side of it. A
|
||||
## barricade makes it worse than a curiosity, because a barricade stops bullets
|
||||
## while deliberately NOT stopping sight -- so an enemy behind one sees you,
|
||||
## aggros, fires, and hits you through the cover that exists to prevent exactly
|
||||
## that. Shots that cannot reach their own muzzle are dropped, which is what
|
||||
## would have happened a tick later anyway if they had had to travel there.
|
||||
func emit_shot(ctx: EmitContext, angle: float, from: Vector2,
|
||||
speed_scale: float = 1.0) -> void:
|
||||
emit_shot_from(ctx, angle, from, ctx.origin, speed_scale)
|
||||
|
||||
|
||||
## As [method emit_shot], for a burst that is not travelling out of the shooter.
|
||||
## [param travels_from] is the point the shot is treated as leaving -- a
|
||||
## telegraphed strike passes its own marker, because that burst is born at the
|
||||
## spot the player was warned about and has nothing to do with where the boss is
|
||||
## standing.
|
||||
func emit_shot_from(ctx: EmitContext, angle: float, from: Vector2,
|
||||
travels_from: Vector2, speed_scale: float = 1.0) -> void:
|
||||
# ctx.pool.map is null when a test fires a pattern into a bare pool, which
|
||||
# keeps EmitContext as narrow as its own comment promises.
|
||||
var map: MapGrid = ctx.pool.map
|
||||
if map != null:
|
||||
# Two questions, not one. The path check skips its own starting tile, so
|
||||
# a shooter standing in a doorway still resolves -- but that skip also
|
||||
# lets a burst whose centre and whose ring both sit inside one wall tile
|
||||
# through. Those bullets are born in the wall, announced to every client,
|
||||
# and die on the next tick: a flicker of shots inside a barricade. A
|
||||
# bullet must never exist inside geometry, so the destination is asked
|
||||
# separately.
|
||||
if map.bullet_blocked(from):
|
||||
return
|
||||
if not map.bullet_path_clear(travels_from, from):
|
||||
return
|
||||
var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale
|
||||
ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind,
|
||||
accel, deg_to_rad(turn_deg))
|
||||
@@ -59,7 +59,10 @@ func _burst(ctx: EmitContext, at: Vector2) -> void:
|
||||
return
|
||||
for i in burst_count:
|
||||
var a := TAU * (float(i) / float(burst_count))
|
||||
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
|
||||
# From the marker, not from the boss: this burst is born where the
|
||||
# player was warned it would be, and a pillar between the boss and the
|
||||
# spot has nothing to say about it.
|
||||
emit_shot_from(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25), at)
|
||||
|
||||
|
||||
## Where volley [param volley]'s strike [param index] lands, inside
|
||||
|
||||
+20
-5
@@ -118,6 +118,16 @@ func can_request_respawn() -> bool:
|
||||
## Adopt a character's stats. Called when a player picks or switches character.
|
||||
## Inventory comes along: it is stored on the character, so swapping in the hub
|
||||
## swaps bags too rather than handing one character another's potions.
|
||||
## Adopt a character's stats. Called when a player picks or switches character,
|
||||
## and again whenever an upgrade changes the numbers.
|
||||
##
|
||||
## Deliberately does NOT set current health. It is called mid-session by
|
||||
## on_choose_upgrade, and reading health from the record there would snap a
|
||||
## player back to whatever was last written to disk. recompute_max_hp() still
|
||||
## clamps downwards, because Glass Cannon halving your ceiling has to take the
|
||||
## hit points above it with them. Where current health comes from on arrival is
|
||||
## ServerRuntime._adopt_character, which is the one place a record becomes a
|
||||
## live player.
|
||||
func adopt(c: Character) -> void:
|
||||
character_id = c.id
|
||||
display_name = c.display_name
|
||||
@@ -126,7 +136,6 @@ func adopt(c: Character) -> void:
|
||||
colour = c.colour
|
||||
stats = PlayerStats.build(c.upgrades)
|
||||
recompute_max_hp()
|
||||
hp = mini(hp, max_hp)
|
||||
set_inventory(c.inventory)
|
||||
|
||||
|
||||
@@ -187,13 +196,19 @@ func heal_percent(percent: float) -> int:
|
||||
return hp - before
|
||||
|
||||
|
||||
## One tick of passive healing. Returns true if the visible hit points changed,
|
||||
## so the caller can decide whether anything needs announcing.
|
||||
func regenerate() -> bool:
|
||||
## One tick of passive healing at [param percent_per_sec] of maximum health.
|
||||
## Returns true if the visible hit points changed, so the caller can decide
|
||||
## whether anything needs announcing.
|
||||
##
|
||||
## The rate is a property of where you are standing rather than a constant: the
|
||||
## hub heals far faster than a dungeon does, because recovering between runs
|
||||
## should take a moment rather than a resource, and recovering DURING one
|
||||
## should not be possible at all by waiting.
|
||||
func regenerate(percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC) -> bool:
|
||||
if not alive or hp >= max_hp:
|
||||
regen_carry = 0.0
|
||||
return false
|
||||
regen_carry += float(max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \
|
||||
regen_carry += float(max_hp) * (percent_per_sec / 100.0) \
|
||||
* SimConfig.TICK_DELTA
|
||||
if regen_carry < 1.0:
|
||||
return false
|
||||
|
||||
+42
-4
@@ -46,6 +46,17 @@ var spawn_point := Vector2(0.0, 240.0)
|
||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||
var spawn_grace_ticks: int = 0
|
||||
## Whether the emergency escape does anything here. False only in the hub: the
|
||||
## escape's completion is routed to "send this peer to the lobby", and in the
|
||||
## lobby that is a transfer to the instance you are already in -- which re-places
|
||||
## the player, teleporting it to the spawn point, healing it to full, dropping
|
||||
## its instanced loot and re-streaming every map chunk around it, once a second
|
||||
## for as long as the key is held. A world you can leave is the default; the hub
|
||||
## is the one that opts out.
|
||||
var escape_enabled: bool = true
|
||||
## Passive healing here, as a percentage of maximum health per second. The hub
|
||||
## overrides it -- see SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC.
|
||||
var regen_percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC
|
||||
|
||||
var _next_actor_id: int = 1
|
||||
var _ctx := EmitContext.new()
|
||||
@@ -209,7 +220,7 @@ func step() -> void:
|
||||
|
||||
func _step_players() -> void:
|
||||
for p in players.values():
|
||||
p.regenerate()
|
||||
p.regenerate(regen_percent_per_sec)
|
||||
if p.spawn_grace > 0:
|
||||
p.spawn_grace -= 1
|
||||
if p.fire_cooldown > 0:
|
||||
@@ -317,9 +328,21 @@ func _fire_player_shot(p: SimPlayer) -> void:
|
||||
forward)
|
||||
|
||||
|
||||
## Place one shot. [param at] is where the muzzle wants it; if geometry stands
|
||||
## between the shooter and that point the bullet is born on the shooter instead.
|
||||
##
|
||||
## A muzzle offset is a teleport, and [BulletPool] never sees it: it samples
|
||||
## geometry once per tick, so a bullet that starts on the far side of a wall
|
||||
## simply flies away down the other side. Forward that is only 19px, but
|
||||
## Doubleshot adds up to 45px SIDEWAYS -- more than a 32px tile is thick, plus
|
||||
## the 6px hitbox that keeps you off the wall. Six Doubleshots and standing
|
||||
## flush against a wall was a wallhack reachable with a stock client. Falling
|
||||
## back to p.pos rather than dropping the shot keeps the shot count honest:
|
||||
## you fired what the upgrade says you fired, it just left from your centre.
|
||||
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
|
||||
var from := at if map.bullet_path_clear(p.pos, at) else p.pos
|
||||
var slot := pool.spawn(
|
||||
at,
|
||||
from,
|
||||
dir * p.stats.bullet_speed,
|
||||
SimConfig.PLAYER_BULLET_RADIUS,
|
||||
SimConfig.PLAYER_BULLET_LIFETIME,
|
||||
@@ -459,6 +482,13 @@ func _drop_slot(p: SimPlayer, slot_index: int) -> void:
|
||||
return
|
||||
if p.inventory[slot_index] == Items.NONE:
|
||||
return
|
||||
# Refused rather than made room for. spawn_loot evicts the oldest item when
|
||||
# the floor is full, which is right for a dungeon's own drops and wrong for a
|
||||
# player's: the hub never closes, so a player dropping into a full floor was
|
||||
# destroying whatever someone else had left there longest -- and the drop key
|
||||
# can be pressed every tick. Your item stays in your bag instead.
|
||||
if loot.size() >= SimConfig.MAX_LOOT_PER_INSTANCE:
|
||||
return
|
||||
var item := p.take_slot(slot_index)
|
||||
# Anything dropped becomes world-shared, even if it arrived as an instanced
|
||||
# drop. That is what makes dropping worth having: an item you do not want
|
||||
@@ -468,6 +498,8 @@ func _drop_slot(p: SimPlayer, slot_index: int) -> void:
|
||||
|
||||
|
||||
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
|
||||
if not escape_enabled:
|
||||
return
|
||||
# A dropped connection is treated as holding the button down. Pulling the
|
||||
# plug then costs exactly what pressing escape costs -- one second of
|
||||
# standing there, still killable -- instead of an instant, safe exit.
|
||||
@@ -833,7 +865,12 @@ func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
|
||||
_drop_loot(e.def.loot, e.pos)
|
||||
# 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)})
|
||||
# "pos" is read by ServerRuntime to decide who was close enough to be
|
||||
# paid, and is deliberately not in the wire format -- encode_events
|
||||
# writes the id and nothing else. A client has no use for it, and every
|
||||
# field that does not go out is one that cannot be mined.
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id,
|
||||
"def": String(e.def.id), "pos": e.pos})
|
||||
|
||||
|
||||
## Returns the damage actually applied, after the phase's armour multiplier, so
|
||||
@@ -851,7 +888,8 @@ func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
|
||||
if boss.hp <= 0:
|
||||
boss.alive = false
|
||||
_drop_loot(boss.def.loot, boss.pos)
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id),
|
||||
"pos": boss.pos})
|
||||
return applied
|
||||
|
||||
|
||||
|
||||
@@ -140,8 +140,17 @@ func _row_for(c: Dictionary) -> Control:
|
||||
|
||||
var playing := String(c["id"]) == _selected
|
||||
var label := Label.new()
|
||||
label.text = " %s level %d %d hp%s" % [
|
||||
c["name"], int(c["level"]), int(c["max_hp"]),
|
||||
# Current health is shown only when it is not full. Health carries between
|
||||
# runs now, so "1300 hp" on a character sitting on 160 would be a lie told
|
||||
# at the exact moment it matters most -- but printing "1300/1300" on every
|
||||
# healthy character is noise, so the fraction appears only when it means
|
||||
# something.
|
||||
var ceiling := int(c["max_hp"])
|
||||
var current := int(c.get("hp", ceiling))
|
||||
var health := "%d hp" % ceiling if current >= ceiling \
|
||||
else "%d/%d hp" % [current, ceiling]
|
||||
label.text = " %s level %d %s%s" % [
|
||||
c["name"], int(c["level"]), health,
|
||||
" <- playing" if playing else ""]
|
||||
label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
@@ -312,3 +312,94 @@ func test_an_unknown_upgrade_in_a_save_is_dropped() -> void:
|
||||
assert_eq(restored.upgrades, [Upgrades.SNIPER] as Array[StringName])
|
||||
assert_eq(restored.offer, [Upgrades.POISON] as Array[StringName])
|
||||
assert_eq(restored.pending_choices, 2)
|
||||
|
||||
|
||||
# --- Deferred writes --------------------------------------------------------
|
||||
# Two paths a client can trigger at the tick rate -- item moves and experience
|
||||
# grants -- mark the store dirty instead of writing, and the server flushes once
|
||||
# per tick. Everything else still writes the moment it happens.
|
||||
|
||||
func test_experience_below_a_level_boundary_is_still_written() -> void:
|
||||
var c := store.create_character(ACC, "Grinder")
|
||||
assert_eq(store.grant_xp(ACC, c.id, 5), 0, "nowhere near a level")
|
||||
store.flush()
|
||||
|
||||
var reloaded := CharacterStore.new(store._path)
|
||||
assert_true(reloaded.load_from_disk())
|
||||
assert_eq(reloaded.get_character(ACC, c.id).total_xp, 5,
|
||||
"experience that has not crossed a level is the only record of it: " +
|
||||
"level is derived from lifetime xp, so losing the xp loses the level")
|
||||
|
||||
|
||||
func test_a_deferred_inventory_write_lands_on_the_flush() -> void:
|
||||
var c := store.create_character(ACC, "Carrier") # writes immediately
|
||||
var bag: Array[StringName] = [Items.HEALTH_POTION, Items.NONE, Items.NONE, Items.NONE]
|
||||
store.set_inventory(ACC, c.id, bag, true)
|
||||
|
||||
var before := CharacterStore.new(store._path)
|
||||
before.load_from_disk()
|
||||
assert_eq(before.get_character(ACC, c.id).inventory[0], Items.NONE,
|
||||
"deferred means deferred; the file still holds the older bag")
|
||||
|
||||
store.flush()
|
||||
var after := CharacterStore.new(store._path)
|
||||
after.load_from_disk()
|
||||
assert_eq(after.get_character(ACC, c.id).inventory[0], Items.HEALTH_POTION)
|
||||
|
||||
|
||||
func test_flushing_a_clean_store_writes_nothing() -> void:
|
||||
store.create_character(ACC, "Idle")
|
||||
var stamp := FileAccess.get_modified_time(store._path)
|
||||
store.flush()
|
||||
assert_eq(FileAccess.get_modified_time(store._path), stamp,
|
||||
"a tick in which nothing happened must not cost a disk write")
|
||||
|
||||
|
||||
func test_an_immediate_write_clears_the_pending_one() -> void:
|
||||
var c := store.create_character(ACC, "Mixed")
|
||||
store.grant_xp(ACC, c.id, 7) # dirty, not written
|
||||
store.set_last_played(ACC, c.id) # writes now, and takes the xp with it
|
||||
var reloaded := CharacterStore.new(store._path)
|
||||
reloaded.load_from_disk()
|
||||
assert_eq(reloaded.get_character(ACC, c.id).total_xp, 7)
|
||||
|
||||
|
||||
# --- Health on the record ---------------------------------------------------
|
||||
# SimWorld builds a blank player on every instance change, so anything that must
|
||||
# survive walking through a door lives here, exactly like the inventory.
|
||||
|
||||
func test_health_survives_a_save_and_reload() -> void:
|
||||
var c := store.create_character(ACC, "Wounded")
|
||||
store.set_health(ACC, c.id, 137)
|
||||
store.flush()
|
||||
var reloaded := CharacterStore.new(store._path)
|
||||
assert_true(reloaded.load_from_disk())
|
||||
assert_eq(reloaded.get_character(ACC, c.id).hp, 137)
|
||||
|
||||
|
||||
func test_a_fresh_character_is_recorded_as_whole_rather_than_as_a_number() -> void:
|
||||
var c := store.create_character(ACC, "New")
|
||||
assert_eq(c.hp, Character.HP_FULL,
|
||||
"a character who has never been hurt has no health number to keep")
|
||||
|
||||
|
||||
func test_a_save_from_before_health_was_kept_loads_as_whole() -> void:
|
||||
var d := Character.create("Old", RandomNumberGenerator.new()).to_dict()
|
||||
d.erase("hp")
|
||||
assert_eq(Character.from_dict(d).hp, Character.HP_FULL)
|
||||
|
||||
|
||||
func test_a_nonsense_health_in_a_save_loads_as_whole_not_as_dead() -> void:
|
||||
# Under permadeath, a character that loads at zero health is unrecoverable,
|
||||
# so every unreadable value has to fail in the generous direction.
|
||||
for bad in [0, -5, "banana"]:
|
||||
var d := Character.create("Odd", RandomNumberGenerator.new()).to_dict()
|
||||
d["hp"] = bad
|
||||
assert_eq(Character.from_dict(d).hp, Character.HP_FULL,
|
||||
"hp %s must load as whole" % bad)
|
||||
|
||||
|
||||
func test_health_is_never_banked_at_zero() -> void:
|
||||
var c := store.create_character(ACC, "Edge")
|
||||
store.set_health(ACC, c.id, 0)
|
||||
assert_eq(c.hp, 1, "the store floors it; a live character is never on zero")
|
||||
@@ -112,3 +112,33 @@ func test_escape_completion_never_reaches_the_client_directly() -> void:
|
||||
for ev: Dictionary in packet["events"]:
|
||||
assert_ne(int(ev["t"]), SimEvent.Type.ESCAPE_COMPLETED,
|
||||
"the transfer is the server's decision; clients only see the outcome")
|
||||
|
||||
|
||||
## The hub has nothing to escape from, and completing the channel there routes
|
||||
## to "send this peer to the lobby" -- a transfer into the instance you are
|
||||
## already in, which re-places the player: teleported to spawn, healed to full,
|
||||
## instanced loot dropped, every map chunk re-streamed. Once a second, for as
|
||||
## long as the key is held.
|
||||
func test_the_hub_has_no_escape_to_channel() -> void:
|
||||
var hub := Instance.make_lobby(1)
|
||||
assert_false(hub.world.escape_enabled,
|
||||
"the lobby is the one world that opts out")
|
||||
hub.add_peer(99, "tester")
|
||||
for i in SimConfig.ESCAPE_CHANNEL_TICKS * 2:
|
||||
var frames: Array[InputFrame] = [InputFrame.make(hub.world.tick + 1,
|
||||
Vector2.ZERO, 0.0, InputFrame.BTN_ESCAPE)]
|
||||
hub.world.queue_input(99, frames)
|
||||
hub.step()
|
||||
var p: SimPlayer = hub.world.players[99]
|
||||
assert_eq(p.escape_ticks, 0, "nothing should have been channelling")
|
||||
for ev in hub.world.events:
|
||||
assert_ne(int(ev["t"]), SimEvent.Type.ESCAPE_STARTED,
|
||||
"holding escape in the hub must not start a channel")
|
||||
assert_ne(int(ev["t"]), SimEvent.Type.ESCAPE_COMPLETED,
|
||||
"and must certainly never complete one")
|
||||
|
||||
|
||||
func test_a_dungeon_still_lets_you_out() -> void:
|
||||
# The guard above must not be a way to disable the escape everywhere.
|
||||
var run := Instance.make_dungeon(2, 1234)
|
||||
assert_true(run.world.escape_enabled)
|
||||
@@ -145,3 +145,130 @@ func test_arc_sweep_angle_changes_over_the_phase() -> void:
|
||||
ctx.local_tick = 30
|
||||
e.fire(ctx)
|
||||
assert_ne(snappedf(a0, 0.001), snappedf(_angles()[0], 0.001))
|
||||
|
||||
|
||||
# --- Muzzles against geometry -----------------------------------------------
|
||||
# An emitter places bullets away from its shooter, and BulletPool samples
|
||||
# geometry once per tick, so it never sees that jump. A curtain spans the whole
|
||||
# arena and an arc sweep's outer arms sit 100px out -- both were spawning shots
|
||||
# on the far side of cover. Both boss arenas are built out of cover: the vault's
|
||||
# barricades exist precisely so there is no clean firing lane, and the hall has
|
||||
# pillars.
|
||||
|
||||
func _walled_world(kind: MapGrid.Kind) -> SimWorld:
|
||||
var g := MapGrid.new(30, 20, MapGrid.Kind.FLOOR)
|
||||
for ty in 20:
|
||||
g.set_tile(15, ty, kind)
|
||||
var w := SimWorld.new(1)
|
||||
w.set_map(g)
|
||||
return w
|
||||
|
||||
|
||||
func _ctx_for(w: SimWorld, at: Vector2) -> EmitContext:
|
||||
var ctx := EmitContext.new()
|
||||
ctx.pool = w.pool
|
||||
ctx.origin = at
|
||||
ctx.target = at + Vector2(0.0, 200.0)
|
||||
ctx.has_target = true
|
||||
ctx.rng = w.rng
|
||||
ctx.bounds = Rect2(at - Vector2(320.0, 320.0), Vector2(640.0, 640.0))
|
||||
return ctx
|
||||
|
||||
|
||||
## Bullets born strictly beyond the barrier column, which spans one tile.
|
||||
func _past_the_barrier(w: SimWorld) -> int:
|
||||
var beyond: float = w.map.origin.x + 16.0 * MapGrid.TILE
|
||||
var n := 0
|
||||
for i in w.pool.high_water:
|
||||
if w.pool.alive[i] != 0 and w.pool.pos[i].x >= beyond:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _fire_once(w: SimWorld, em: BulletEmitter, at: Vector2) -> void:
|
||||
var ctx := _ctx_for(w, at)
|
||||
ctx.local_tick = 0
|
||||
ctx.shot_index = 0
|
||||
em.fire(ctx)
|
||||
|
||||
|
||||
func test_a_curtain_does_not_spawn_through_a_barricade() -> void:
|
||||
# A curtain spans its bounds, so most of it is nowhere near the shooter --
|
||||
# which is exactly how it was reaching the far side of the room.
|
||||
var em := WallGapEmitter.new()
|
||||
em.count = 16
|
||||
em.speed = 100.0
|
||||
var open_world := _walled_world(MapGrid.Kind.FLOOR)
|
||||
var at := Vector2(open_world.map.origin.x + 14.0 * MapGrid.TILE,
|
||||
open_world.map.tile_centre(14, 10).y)
|
||||
_fire_once(open_world, em, at)
|
||||
assert_gt(_past_the_barrier(open_world), 0,
|
||||
"with nothing in the way the curtain reaches across the room")
|
||||
|
||||
var blocked := _walled_world(MapGrid.Kind.BARRICADE)
|
||||
_fire_once(blocked, em, at)
|
||||
assert_eq(_past_the_barrier(blocked), 0,
|
||||
"a barricade stops bullets, so it has to stop them being born past it")
|
||||
assert_gt(blocked.pool.live_count, 0,
|
||||
"and the near half of the curtain still fires")
|
||||
|
||||
|
||||
func test_an_arc_sweeps_outer_arms_do_not_reach_through_a_wall() -> void:
|
||||
var em := ArcSweepEmitter.new()
|
||||
em.arms = 4
|
||||
em.arm_spacing = 26.0
|
||||
em.muzzle_offset = 26.0
|
||||
em.speed = 100.0
|
||||
var at_open := _walled_world(MapGrid.Kind.FLOOR)
|
||||
# Flush against the barrier, sweeping into it.
|
||||
var at := Vector2(at_open.map.origin.x + 15.0 * MapGrid.TILE - 20.0,
|
||||
at_open.map.tile_centre(10, 10).y)
|
||||
_fire_once(at_open, em, at)
|
||||
var reach_open := _past_the_barrier(at_open)
|
||||
|
||||
var blocked := _walled_world(MapGrid.Kind.WALL)
|
||||
_fire_once(blocked, em, at)
|
||||
assert_gt(reach_open, 0, "the arms do reach past a tile when nothing stops them")
|
||||
assert_eq(_past_the_barrier(blocked), 0, "and must not when something does")
|
||||
|
||||
|
||||
func test_no_boss_ever_spawns_a_bullet_inside_its_own_arena_s_cover() -> void:
|
||||
# The gameplay statement, against the real arenas. Both are authored around
|
||||
# cover -- the vault's barricades exist so that knowing where the Cantor is
|
||||
# does not hand you a firing lane, and the hall has pillars -- and a spawner
|
||||
# that ignores geometry quietly cancels all of it.
|
||||
for dungeon in [Dungeons.STANDARD, Dungeons.VAULT]:
|
||||
var inst := Instance.make_dungeon(2, 4242, 1, dungeon)
|
||||
var w := inst.world
|
||||
var bait := w.add_player(1, "bait")
|
||||
bait.pos = inst.boss_spawn + Vector2(90.0, 0.0)
|
||||
var offenders := 0
|
||||
for _t in 900:
|
||||
w.step()
|
||||
for slot in w.pool.spawn_log:
|
||||
if w.map.bullet_blocked(w.pool.pos[slot]):
|
||||
offenders += 1
|
||||
assert_eq(offenders, 0,
|
||||
"%s spawned %d bullet(s) inside its own arena's cover"
|
||||
% [dungeon, offenders])
|
||||
|
||||
|
||||
## The Cantor's strike_point avoids the room's EDGE and knows nothing about the
|
||||
## barricades and pillars inside it, so a volley can be marked on top of cover.
|
||||
## What lands there is nothing at all -- which is cover doing its job, and is
|
||||
## pinned here so it stays a known shape rather than a surprise.
|
||||
func test_a_strike_marked_on_cover_is_a_dud_rather_than_a_shot_through_it() -> void:
|
||||
var g := MapGrid.new(20, 20, MapGrid.Kind.FLOOR)
|
||||
g.set_tile(10, 10, MapGrid.Kind.BARRICADE)
|
||||
var w := SimWorld.new(1)
|
||||
w.set_map(g)
|
||||
var em := TelegraphedStrikeEmitter.new()
|
||||
em.burst_count = 8
|
||||
em.blast_radius = 40.0
|
||||
em.speed = 100.0
|
||||
var ctx := _ctx_for(w, g.tile_centre(2, 2))
|
||||
# Burst centred on the barricade: nothing may come out of it, and nothing
|
||||
# may appear on the far side of it either.
|
||||
em._burst(ctx, g.tile_centre(10, 10))
|
||||
assert_eq(w.pool.live_count, 0,
|
||||
"a burst inside cover stays inside it, and dies there")
|
||||
@@ -112,3 +112,75 @@ func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
|
||||
var travel := PlayerStats.build(ids).bullet_speed \
|
||||
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
|
||||
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)
|
||||
|
||||
|
||||
# --- Per-bullet interest ----------------------------------------------------
|
||||
# The radius tests above pin the CEILING: no bullet in the game may need more
|
||||
# than BULLET_INTEREST_RADIUS. These pin the rule that actually runs, which asks
|
||||
# whether a particular bullet can reach ground this player can see. The two are
|
||||
# different questions, and only the second one closes the leak: a spawn event
|
||||
# carries the firing actor's exact position, so anything sent on the strength of
|
||||
# a flat radius is an enemy located for a modified client.
|
||||
|
||||
func _server() -> ServerRuntime:
|
||||
# Not added to the tree on purpose: _ready() would build a lobby and load a
|
||||
# character store, and none of that is involved in an interest decision.
|
||||
return autofree(ServerRuntime.new()) as ServerRuntime
|
||||
|
||||
|
||||
func test_a_slow_bullet_fired_across_the_map_is_not_announced() -> void:
|
||||
var srv := _server()
|
||||
var slow := Vector2(120.0, 0.0) # 120 u/s for 60 ticks reaches 120 units
|
||||
assert_false(srv._bullet_concerns(Vector2.ZERO, Vector2(2000.0, 0.0),
|
||||
slow, 0.0, 60),
|
||||
"it expires 1400 units short of anything this player can see")
|
||||
|
||||
|
||||
func test_a_bullet_that_can_reach_visible_ground_is_announced() -> void:
|
||||
var srv := _server()
|
||||
var fast := Vector2(900.0, 0.0) # 900 u/s for 90 ticks reaches 1350
|
||||
assert_true(srv._bullet_concerns(Vector2.ZERO, Vector2(1500.0, 0.0),
|
||||
fast, 0.0, 90),
|
||||
"1350 of travel plus the fog radius covers 1500, so it must be sent")
|
||||
|
||||
|
||||
func test_the_horizon_is_travel_plus_what_the_player_can_see() -> void:
|
||||
var srv := _server()
|
||||
# A bullet whose entire travel lands exactly on the edge of sight.
|
||||
var speed := 600.0
|
||||
var life := 60
|
||||
var reach := Movement.bullet_reach(speed, 0.0, life)
|
||||
var vel := Vector2(speed, 0.0)
|
||||
assert_true(srv._bullet_concerns(Vector2.ZERO,
|
||||
Vector2(reach + SimConfig.FOG_VIEW_RADIUS - 1.0, 0.0), vel, 0.0, life))
|
||||
assert_false(srv._bullet_concerns(Vector2.ZERO,
|
||||
Vector2(reach + SimConfig.FOG_VIEW_RADIUS + 1.0, 0.0), vel, 0.0, life))
|
||||
|
||||
|
||||
func test_acceleration_counts_toward_the_reach() -> void:
|
||||
var srv := _server()
|
||||
var at := Vector2(1200.0, 0.0)
|
||||
var vel := Vector2(200.0, 0.0)
|
||||
assert_false(srv._bullet_concerns(Vector2.ZERO, at, vel, 0.0, 90),
|
||||
"200 u/s for 1.5s is 300 units; nowhere near")
|
||||
assert_true(srv._bullet_concerns(Vector2.ZERO, at, vel, 900.0, 90),
|
||||
"the same shot accelerating gets there, so it has to be announced")
|
||||
|
||||
|
||||
func test_no_enemy_in_the_content_leaks_its_position_across_a_dungeon() -> void:
|
||||
# The regression this all exists for. A depth-1 map is 2112x1376 units, so
|
||||
# its diagonal is about 2521 -- less than BULLET_INTEREST_RADIUS. Under the
|
||||
# flat radius every firing enemy anywhere on the floor was announced to
|
||||
# everyone, whatever the fog or the actor cull said.
|
||||
var srv := _server()
|
||||
var diagonal := Vector2(66.0, 43.0).length() * MapGrid.TILE
|
||||
assert_lt(diagonal, SimConfig.BULLET_INTEREST_RADIUS,
|
||||
"if this stops being true the flat radius was not the bug described here")
|
||||
var leaked: Array[String] = []
|
||||
for id in Content.ALL_ENEMIES:
|
||||
for e in Content.enemy(id).emitters:
|
||||
if srv._bullet_concerns(Vector2.ZERO, Vector2(diagonal, 0.0),
|
||||
Vector2(e.speed, 0.0), 0.0, e.lifetime):
|
||||
leaked.append("%s (%.0f u/s x %d ticks)" % [id, e.speed, e.lifetime])
|
||||
assert_eq(leaked, [] as Array[String],
|
||||
"an enemy firing at the far corner of the map must not be announced")
|
||||
@@ -418,3 +418,35 @@ func test_the_same_tick_tie_break_is_join_order_not_distance() -> void:
|
||||
assert_eq(world.players[ME].inventory[0], Items.HEALTH_POTION,
|
||||
"the player who joined first wins, despite standing further away")
|
||||
assert_eq(them.inventory[0], Items.NONE)
|
||||
|
||||
|
||||
## Ground loot evicts the oldest item when the floor fills up, which is the
|
||||
## right answer for a dungeon's own drops and the wrong one for a player's: the
|
||||
## hub never closes, so dropping into a full floor destroyed whatever someone
|
||||
## else had left there longest -- and drop is edge-triggered per tick, so a
|
||||
## client could clear the hub floor in seconds.
|
||||
func test_a_player_drop_never_destroys_someone_elses_item() -> void:
|
||||
var w := SimWorld.new(1)
|
||||
var mine := w.add_player(1, "me")
|
||||
mine.pos = Vector2.ZERO
|
||||
mine.inventory[0] = Items.HEALTH_POTION
|
||||
|
||||
var theirs: Array[int] = []
|
||||
for i in SimConfig.MAX_LOOT_PER_INSTANCE:
|
||||
theirs.append(w.spawn_loot(Items.WARDENS_RATION, Vector2(500.0, float(i))).id)
|
||||
assert_eq(w.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE, "the floor is full")
|
||||
|
||||
w._drop_slot(mine, 0)
|
||||
assert_eq(mine.inventory[0], Items.HEALTH_POTION,
|
||||
"the drop is refused, so the item stays in the bag")
|
||||
for id in theirs:
|
||||
assert_true(w.loot.has(id), "nothing already on the floor may be evicted")
|
||||
|
||||
|
||||
func test_the_cap_still_bites_for_drops_the_world_generates_itself() -> void:
|
||||
# A dungeon's own drops keep the old behaviour: they must never fail to
|
||||
# appear, and the instance closes and takes the litter with it anyway.
|
||||
var w := SimWorld.new(1)
|
||||
for i in SimConfig.MAX_LOOT_PER_INSTANCE + 5:
|
||||
w.spawn_loot(Items.WARDENS_RATION, Vector2(float(i), 0.0))
|
||||
assert_eq(w.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE)
|
||||
@@ -102,3 +102,33 @@ func test_standing_inside_a_wall_can_still_see_out() -> void:
|
||||
var inside := grid.tile_centre(0, 0)
|
||||
var outside := grid.tile_centre(1, 1)
|
||||
assert_true(grid.has_line_of_sight(inside, outside))
|
||||
|
||||
|
||||
## bullet_path_clear differs from has_line_of_sight in two ways that matter, and
|
||||
## both are easy to get wrong: it consults BLOCKS_BULLET (so a barricade stops
|
||||
## it and a pit does not), and the destination tile counts.
|
||||
func test_a_bullet_path_is_blocked_by_what_stops_bullets_not_by_what_stops_sight() -> void:
|
||||
var a := grid.tile_centre(1, 4)
|
||||
var b := grid.tile_centre(8, 4)
|
||||
assert_true(grid.bullet_path_clear(a, b), "clear floor between them")
|
||||
|
||||
grid.set_tile(4, 4, MapGrid.Kind.PIT)
|
||||
assert_true(grid.bullet_path_clear(a, b), "bullets fly over a pit")
|
||||
|
||||
grid.set_tile(4, 4, MapGrid.Kind.BARRICADE)
|
||||
assert_false(grid.bullet_path_clear(a, b),
|
||||
"you can SEE over a barricade, which is exactly why sight is the wrong test")
|
||||
assert_true(grid.has_line_of_sight(a, b), "and sight still passes it")
|
||||
|
||||
grid.set_tile(4, 4, MapGrid.Kind.WALL)
|
||||
assert_false(grid.bullet_path_clear(a, b), "a wall stops both")
|
||||
|
||||
|
||||
func test_a_bullet_path_ending_inside_a_wall_is_refused() -> void:
|
||||
var from := grid.tile_centre(1, 4)
|
||||
grid.set_tile(2, 4, MapGrid.Kind.WALL)
|
||||
assert_false(grid.bullet_path_clear(from, grid.tile_centre(2, 4)),
|
||||
"the destination counts: a muzzle inside a wall is the case this rules out")
|
||||
# Sight deliberately does the opposite -- shooting at something embedded in
|
||||
# a wall must still resolve -- so the two must not be collapsed into one.
|
||||
assert_true(grid.has_line_of_sight(from, grid.tile_centre(2, 4)))
|
||||
@@ -511,3 +511,170 @@ func test_events_after_a_telegraph_still_decode() -> void:
|
||||
assert_eq(int(out[1]["id"]), 77)
|
||||
assert_eq(int(out[1]["hp"]), 900)
|
||||
assert_eq(int(out[2]["phase"]), 2)
|
||||
|
||||
|
||||
# --- Truncation on the two hot streams --------------------------------------
|
||||
# Every cold message here already refuses to read past the end of its buffer.
|
||||
# Snapshots and events -- the two that arrive twenty and sixty times a second --
|
||||
# did not, and the snapshot channel is unreliable and unchecksummed, so a short
|
||||
# packet is a network event rather than only an attack.
|
||||
|
||||
func _populated_world() -> SimWorld:
|
||||
var w := SimWorld.new(1)
|
||||
w.add_player(1, "a")
|
||||
w.add_player(2, "b")
|
||||
w.spawn_enemy(Content.turret(), Vector2(40.0, 0.0))
|
||||
w.spawn_boss(Content.boss(&"warden"))
|
||||
w.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 10.0))
|
||||
return w
|
||||
|
||||
|
||||
func test_the_snapshot_record_sizes_match_what_the_encoder_writes() -> void:
|
||||
# The guards are arithmetic on these constants, so if they drift from the
|
||||
# encoder the decoder starts refusing good packets or accepting short ones.
|
||||
var empty := SimWorld.new(1)
|
||||
var base := NetCodec.encode_snapshot(empty).size()
|
||||
assert_eq(base, NetCodec.SNAPSHOT_HEADER + 1 + 2 + 1 + SimConfig.INVENTORY_SLOTS + 2,
|
||||
"an empty world is header + three counts + the boss flag + the bag")
|
||||
|
||||
var one_player := SimWorld.new(1)
|
||||
one_player.add_player(1, "a")
|
||||
assert_eq(NetCodec.encode_snapshot(one_player).size() - base,
|
||||
NetCodec.SNAPSHOT_PLAYER)
|
||||
|
||||
var one_enemy := SimWorld.new(1)
|
||||
one_enemy.spawn_enemy(Content.turret(), Vector2.ZERO)
|
||||
assert_eq(NetCodec.encode_snapshot(one_enemy).size() - base,
|
||||
NetCodec.SNAPSHOT_ENEMY)
|
||||
|
||||
var one_boss := SimWorld.new(1)
|
||||
one_boss.spawn_boss(Content.boss(&"warden"))
|
||||
assert_eq(NetCodec.encode_snapshot(one_boss).size() - base,
|
||||
NetCodec.SNAPSHOT_BOSS)
|
||||
|
||||
var one_item := SimWorld.new(1)
|
||||
one_item.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
|
||||
assert_eq(NetCodec.encode_snapshot(one_item).size() - base,
|
||||
NetCodec.SNAPSHOT_LOOT)
|
||||
|
||||
|
||||
func test_a_truncated_snapshot_is_refused_rather_than_read_past_the_end() -> void:
|
||||
var full := NetCodec.encode_snapshot(_populated_world())
|
||||
assert_true(NetCodec.decode_snapshot(full)["valid"], "the whole packet is fine")
|
||||
# Every prefix short of the whole thing must be refused, not half-applied.
|
||||
for cut in range(0, full.size()):
|
||||
var snap := NetCodec.decode_snapshot(full.slice(0, cut))
|
||||
assert_false(snap["valid"], "a %d-byte prefix must not decode" % cut)
|
||||
|
||||
|
||||
func test_a_snapshot_claiming_more_actors_than_it_carries_is_refused() -> void:
|
||||
var data := NetCodec.encode_snapshot(_populated_world())
|
||||
# Byte 5 is the player count; claim 255 of them.
|
||||
data[NetCodec.SNAPSHOT_HEADER] = 255
|
||||
var snap := NetCodec.decode_snapshot(data)
|
||||
assert_false(snap["valid"],
|
||||
"a hostile count is 255 records read out of a buffer that has none")
|
||||
assert_eq((snap["players"] as Array).size(), 0)
|
||||
|
||||
|
||||
func test_an_invalid_snapshot_carries_a_usable_empty_shape() -> void:
|
||||
# The caller drops it, but it must not be a shape that errors on the way out.
|
||||
var snap := NetCodec.decode_snapshot(PackedByteArray())
|
||||
assert_false(snap["valid"])
|
||||
assert_eq(snap["tick"], 0)
|
||||
assert_eq((snap["players"] as Array).size(), 0)
|
||||
assert_null(snap["boss"])
|
||||
|
||||
|
||||
func test_every_event_type_declares_the_length_the_encoder_writes() -> void:
|
||||
# The decoder needs a length BEFORE it parses, because an event of unknown
|
||||
# size does not spoil one record -- it unaligns every byte after it.
|
||||
var samples := {
|
||||
SimEvent.Type.BULLET_SPAWN: {"uid": 1, "pos": Vector2.ONE, "vel": Vector2.ONE,
|
||||
"r": 4.0, "life": 60, "kind": 0, "team": 0, "accel": 0.0, "turn": 0.0},
|
||||
SimEvent.Type.BULLET_DESPAWN: {"uid": 1},
|
||||
SimEvent.Type.PLAYER_HIT: {"peer": 1, "dmg": 5, "hp": 10},
|
||||
SimEvent.Type.PLAYER_DIED: {"peer": 1},
|
||||
SimEvent.Type.ESCAPE_STARTED: {"peer": 1},
|
||||
SimEvent.Type.ESCAPE_CANCELLED: {"peer": 1},
|
||||
SimEvent.Type.PLAYER_FIRED: {"peer": 1},
|
||||
SimEvent.Type.PLAYER_RESPAWNED: {"peer": 1, "pos": Vector2.ONE},
|
||||
SimEvent.Type.ITEM_PICKED_UP: {"peer": 1, "item": Items.HEALTH_POTION},
|
||||
SimEvent.Type.ITEM_USED: {"peer": 1, "item": Items.HEALTH_POTION},
|
||||
SimEvent.Type.ITEM_DROPPED: {"peer": 1, "item": Items.HEALTH_POTION},
|
||||
SimEvent.Type.ENEMY_HIT: {"id": 1, "dmg": 5, "hp": 10},
|
||||
SimEvent.Type.ENEMY_DIED: {"id": 1},
|
||||
SimEvent.Type.BOSS_PHASE: {"phase": 1},
|
||||
SimEvent.Type.BOSS_DIED: {},
|
||||
SimEvent.Type.TELEGRAPH: {"pos": Vector2.ONE, "r": 8.0, "ticks": 30},
|
||||
}
|
||||
var header := 6 # u32 tick + u16 count
|
||||
for t: int in SimEvent.Type.values():
|
||||
if NetCodec.SERVER_ONLY.has(t):
|
||||
assert_eq(NetCodec.event_payload_size(t), -1,
|
||||
"a server-only event is never on the wire, so it has no length")
|
||||
continue
|
||||
assert_true(samples.has(t),
|
||||
"SimEvent.Type %d is new: give it a sample and a payload size" % t)
|
||||
var ev: Dictionary = (samples[t] as Dictionary).duplicate()
|
||||
ev["t"] = t
|
||||
var wrote := NetCodec.encode_events(0, [ev] as Array[Dictionary]).size()
|
||||
assert_eq(wrote - header - 1, NetCodec.event_payload_size(t),
|
||||
"declared length for SimEvent.Type %d does not match the encoder" % t)
|
||||
|
||||
|
||||
func test_a_truncated_event_packet_stops_instead_of_inventing_bullets() -> void:
|
||||
var evs: Array[Dictionary] = [
|
||||
{"t": SimEvent.Type.BULLET_SPAWN, "uid": 7, "pos": Vector2(1.0, 2.0),
|
||||
"vel": Vector2(3.0, 4.0), "r": 4.0, "life": 60, "kind": 0, "team": 1,
|
||||
"accel": 0.0, "turn": 0.0},
|
||||
{"t": SimEvent.Type.ENEMY_DIED, "id": 9},
|
||||
]
|
||||
var full := NetCodec.encode_events(11, evs)
|
||||
assert_eq((NetCodec.decode_events(full)["events"] as Array).size(), 2)
|
||||
# Lop off the last event's payload: the first must still arrive, the second
|
||||
# must not be conjured from whatever is left.
|
||||
var short := full.slice(0, full.size() - 2)
|
||||
var got: Array = NetCodec.decode_events(short)["events"]
|
||||
assert_eq(got.size(), 1, "the sound prefix is kept; the torn record is not")
|
||||
assert_eq(int(got[0]["t"]), SimEvent.Type.BULLET_SPAWN)
|
||||
|
||||
|
||||
func test_an_unknown_event_type_stops_the_stream_rather_than_desyncing_it() -> void:
|
||||
var evs: Array[Dictionary] = [{"t": SimEvent.Type.ENEMY_DIED, "id": 9}]
|
||||
var data := NetCodec.encode_events(3, evs)
|
||||
# Claim there are two events and give the second an unparseable type.
|
||||
data[4] = 2
|
||||
data.append(200)
|
||||
data.append_array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
var got: Array = NetCodec.decode_events(data)["events"]
|
||||
assert_eq(got.size(), 1,
|
||||
"an event whose length is unknown unaligns everything after it, " +
|
||||
"so the only safe answer is to stop")
|
||||
|
||||
|
||||
func test_the_roster_carries_current_health_as_well_as_the_ceiling() -> void:
|
||||
# Health survives a run now, so a character on the select screen can be
|
||||
# badly hurt. Sending only the maximum would advertise it as whole, which
|
||||
# under permadeath is the worst thing on that screen to be wrong about.
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var whole := Character.create("Whole", rng)
|
||||
var hurt := Character.create("Hurt", rng)
|
||||
hurt.hp = 42
|
||||
var got: Array = NetCodec.decode_characters(
|
||||
NetCodec.encode_characters([whole, hurt] as Array[Character], ""))["characters"]
|
||||
assert_eq(got.size(), 2)
|
||||
assert_eq(int(got[0]["hp"]), int(got[0]["max_hp"]),
|
||||
"a character that has never been hurt reads as full")
|
||||
assert_eq(int(got[1]["hp"]), 42)
|
||||
assert_gt(int(got[1]["max_hp"]), 42, "and the ceiling is still its own number")
|
||||
|
||||
|
||||
func test_roster_health_is_never_sent_as_zero() -> void:
|
||||
# A zero on this screen reads as a dead character the player is being
|
||||
# offered, which cannot happen: the roster is living characters only.
|
||||
var c := Character.create("Edge", RandomNumberGenerator.new())
|
||||
c.hp = 0
|
||||
var got: Array = NetCodec.decode_characters(
|
||||
NetCodec.encode_characters([c] as Array[Character], ""))["characters"]
|
||||
assert_gte(int(got[0]["hp"]), 1)
|
||||
@@ -96,3 +96,50 @@ func test_it_cannot_outpace_incoming_fire() -> void:
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_lt(p.hp, start, "standing in fire must still lose health")
|
||||
|
||||
|
||||
# --- Where you are decides how fast you heal ---------------------------------
|
||||
# Arriving anywhere used to set health to maximum, which made a run free: walk
|
||||
# in, take what you can, walk out, walk back in whole. The hub pays that off
|
||||
# over about ten seconds instead.
|
||||
|
||||
func test_the_hub_heals_far_faster_than_a_dungeon() -> void:
|
||||
var hub := Instance.make_lobby(1)
|
||||
assert_eq(hub.world.regen_percent_per_sec,
|
||||
SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC)
|
||||
var run := Instance.make_dungeon(2, 99)
|
||||
assert_eq(run.world.regen_percent_per_sec, SimConfig.HP_REGEN_PERCENT_PER_SEC)
|
||||
assert_gt(hub.world.regen_percent_per_sec, run.world.regen_percent_per_sec,
|
||||
"recovering is what the hub is for; a dungeon must not be a rest stop")
|
||||
|
||||
|
||||
func test_a_full_bar_takes_about_ten_seconds_in_the_hub() -> void:
|
||||
var hub := Instance.make_lobby(1)
|
||||
var p := hub.world.add_player(1, "hurt")
|
||||
p.hp = 1
|
||||
for _t in SimConfig.TICK_RATE * 10:
|
||||
hub.step()
|
||||
assert_eq(p.hp, p.max_hp, "ten seconds of standing in the hub is a full bar")
|
||||
|
||||
|
||||
func test_the_hub_still_takes_a_noticeable_moment() -> void:
|
||||
# Fast, but not the instant heal this replaced: half a second must not do it.
|
||||
var hub := Instance.make_lobby(1)
|
||||
var p := hub.world.add_player(1, "hurt")
|
||||
p.hp = 1
|
||||
for _t in SimConfig.TICK_RATE / 2:
|
||||
hub.step()
|
||||
assert_lt(p.hp, p.max_hp, "arriving in the hub is not a heal button")
|
||||
assert_gt(p.hp, 1, "but it is visibly happening")
|
||||
|
||||
|
||||
func test_a_dungeon_cannot_be_waited_out() -> void:
|
||||
var run := Instance.make_dungeon(2, 99)
|
||||
var p := run.world.add_player(1, "hurt")
|
||||
p.hp = 1
|
||||
p.spawn_grace = 0
|
||||
var before := p.hp
|
||||
for _t in SimConfig.TICK_RATE * 10:
|
||||
p.regenerate(run.world.regen_percent_per_sec)
|
||||
assert_lt(p.hp - before, p.max_hp / 10,
|
||||
"ten seconds in a dungeon must not undo a fight")
|
||||
@@ -251,3 +251,181 @@ func test_spawn_grace_expires() -> void:
|
||||
assert_false(p.invulnerable())
|
||||
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||
assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends")
|
||||
|
||||
|
||||
## A muzzle offset moves a bullet's birthplace away from its shooter, and the
|
||||
## bullet pool samples geometry once per tick so it never sees that jump. With
|
||||
## six Doubleshots the sideways offset reaches 45px -- wider than a 32px wall
|
||||
## plus the 6px hitbox that holds you off it -- so standing flush against a wall
|
||||
## and firing along it put live shots down the far side. No modified client
|
||||
## needed, which is what made it worth a test rather than a comment.
|
||||
func _wall_at_column(column: int) -> SimWorld:
|
||||
var g := MapGrid.new(10, 6, MapGrid.Kind.FLOOR)
|
||||
for ty in 6:
|
||||
g.set_tile(column, ty, MapGrid.Kind.WALL)
|
||||
var w := SimWorld.new(1)
|
||||
w.set_map(g)
|
||||
return w
|
||||
|
||||
|
||||
func _fire_flush_against_the_wall(doubleshots: int) -> Dictionary:
|
||||
var w := _wall_at_column(4)
|
||||
var p := w.add_player(PEER, "tester")
|
||||
var face: float = w.map.origin.x + 4.0 * MapGrid.TILE
|
||||
p.pos = Vector2(face - SimConfig.PLAYER_RADIUS, w.map.tile_centre(3, 3).y)
|
||||
p.aim = -PI / 2.0 # along the wall, not into it
|
||||
p.spawn_grace = 0
|
||||
p.fire_cooldown = 0
|
||||
var ids: Array[StringName] = []
|
||||
for _i in doubleshots:
|
||||
ids.append(Upgrades.DOUBLESHOT)
|
||||
p.stats = PlayerStats.build(ids)
|
||||
|
||||
w._fire_player_shot(p)
|
||||
var fired := 0
|
||||
var past := 0
|
||||
for i in w.pool.high_water:
|
||||
if w.pool.alive[i] == 0:
|
||||
continue
|
||||
fired += 1
|
||||
if w.pool.pos[i].x > face:
|
||||
past += 1
|
||||
return {"fired": fired, "past_the_face": past}
|
||||
|
||||
|
||||
func test_no_stack_of_upgrades_can_put_a_shot_through_a_wall() -> void:
|
||||
for doubleshots in [0, 5, 6, 10]:
|
||||
var r := _fire_flush_against_the_wall(doubleshots)
|
||||
assert_eq(int(r["past_the_face"]), 0,
|
||||
"%d Doubleshot(s) put %d shot(s) on the far side of the wall"
|
||||
% [doubleshots, r["past_the_face"]])
|
||||
|
||||
|
||||
func test_clamping_the_muzzle_does_not_swallow_the_shot() -> void:
|
||||
# The fix must not silently reduce your firepower for standing near cover:
|
||||
# a blocked muzzle falls back to the shooter's centre, it does not cancel.
|
||||
assert_eq(int(_fire_flush_against_the_wall(6)["fired"]), 7,
|
||||
"six Doubleshots is seven projectiles, wall or no wall")
|
||||
|
||||
|
||||
# --- Control-message pacing -------------------------------------------------
|
||||
# Selecting or creating a character rewrites the whole store to disk, re-places
|
||||
# the player and broadcasts a roster to everyone, all off one small reliable
|
||||
# packet. The pacing below is what stops a client turning that into a synchronous
|
||||
# disk write it controls the rate of.
|
||||
|
||||
func _runtime() -> ServerRuntime:
|
||||
# Not parented: _ready() would open a lobby and load a store, and neither is
|
||||
# involved in deciding whether a message arrived too soon.
|
||||
return autofree(ServerRuntime.new()) as ServerRuntime
|
||||
|
||||
|
||||
func test_control_messages_are_paced_per_peer() -> void:
|
||||
var srv := _runtime()
|
||||
assert_true(srv._accept_control(PEER), "the first one is always allowed")
|
||||
assert_false(srv._accept_control(PEER), "the second on the same tick is not")
|
||||
srv._tick += ServerRuntime.CONTROL_COOLDOWN_TICKS - 1
|
||||
assert_false(srv._accept_control(PEER), "one tick short is still too soon")
|
||||
srv._tick += 1
|
||||
assert_true(srv._accept_control(PEER), "and the cooldown does expire")
|
||||
|
||||
|
||||
func test_one_peer_flooding_does_not_gag_another() -> void:
|
||||
var srv := _runtime()
|
||||
assert_true(srv._accept_control(1))
|
||||
assert_false(srv._accept_control(1))
|
||||
assert_true(srv._accept_control(2), "pacing is per peer, not global")
|
||||
|
||||
|
||||
func test_a_refused_flood_is_logged_once_and_not_once_per_packet() -> void:
|
||||
# The limiter must not become the amplification it exists to prevent: a
|
||||
# client sending a thousand a second would otherwise have the server format
|
||||
# and write a thousand lines.
|
||||
var srv := _runtime()
|
||||
assert_true(srv._accept_control(PEER))
|
||||
for _i in 500:
|
||||
assert_false(srv._accept_control(PEER))
|
||||
assert_true(srv._peer_control_warned.get(PEER, false),
|
||||
"the first refusal of a burst is worth saying")
|
||||
srv._tick += ServerRuntime.CONTROL_COOLDOWN_TICKS
|
||||
assert_true(srv._accept_control(PEER))
|
||||
assert_false(srv._peer_control_warned.has(PEER),
|
||||
"behaving again re-arms the warning for the next burst")
|
||||
|
||||
|
||||
# --- Who gets paid for a kill -----------------------------------------------
|
||||
# Experience is undivided among everyone who was there, which is settled. Who
|
||||
# counts as "there" was not: without a radius, a player parked in the entrance
|
||||
# room collected the full experience for a boss they never saw.
|
||||
|
||||
func _paying_server(where: Instance) -> ServerRuntime:
|
||||
var srv := _runtime()
|
||||
srv.store = CharacterStore.new("user://test_award_%d.json" % randi())
|
||||
srv.store.load_from_disk()
|
||||
srv.instances[where.id] = where
|
||||
return srv
|
||||
|
||||
|
||||
func _seat(srv: ServerRuntime, where: Instance, peer: int, at: Vector2) -> SimPlayer:
|
||||
var c := srv.store.create_character(peer, "p%d" % peer)
|
||||
srv.peer_accounts[peer] = peer
|
||||
srv.peer_characters[peer] = c.id
|
||||
srv.peer_instance[peer] = where.id
|
||||
where.add_peer(peer, c.display_name)
|
||||
var p: SimPlayer = where.world.players[peer]
|
||||
p.pos = at
|
||||
return p
|
||||
|
||||
|
||||
func _xp_of(srv: ServerRuntime, peer: int) -> int:
|
||||
return srv.store.get_character(peer, srv.peer_characters[peer]).total_xp
|
||||
|
||||
|
||||
func test_only_players_near_the_kill_are_paid() -> void:
|
||||
var run := Instance.make_dungeon(9, 4242)
|
||||
var srv := _paying_server(run)
|
||||
var kill := Vector2(500.0, 500.0)
|
||||
_seat(srv, run, 11, kill + Vector2(50.0, 0.0))
|
||||
_seat(srv, run, 22, kill + Vector2(SimConfig.XP_AWARD_RADIUS + 200.0, 0.0))
|
||||
|
||||
srv._award_kill(run, 50, kill)
|
||||
assert_eq(_xp_of(srv, 11), 50, "standing over it counts")
|
||||
assert_eq(_xp_of(srv, 22), 0, "parked across the map does not")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||
|
||||
|
||||
func test_the_radius_reaches_past_what_a_player_can_see() -> void:
|
||||
# Anyone who watched it die must be paid, so the boundary sits outside the
|
||||
# fog rather than on it.
|
||||
var run := Instance.make_dungeon(9, 4242)
|
||||
var srv := _paying_server(run)
|
||||
var kill := Vector2.ZERO
|
||||
_seat(srv, run, 11, Vector2(SimConfig.FOG_VIEW_RADIUS, 0.0))
|
||||
srv._award_kill(run, 50, kill)
|
||||
assert_eq(_xp_of(srv, 11), 50,
|
||||
"at the very edge of sight you still saw it happen")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||
|
||||
|
||||
func test_a_kill_still_pays_the_whole_party_that_was_there() -> void:
|
||||
# The radius decides who was present; it must not turn into a split.
|
||||
var run := Instance.make_dungeon(9, 4242)
|
||||
var srv := _paying_server(run)
|
||||
var kill := Vector2(100.0, 100.0)
|
||||
for peer in [11, 22, 33]:
|
||||
_seat(srv, run, peer, kill + Vector2(40.0, 0.0))
|
||||
srv._award_kill(run, 50, kill)
|
||||
for peer in [11, 22, 33]:
|
||||
assert_eq(_xp_of(srv, peer), 50, "undivided, for everyone present")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||
|
||||
|
||||
func test_the_dead_are_not_paid_however_close_they_are_lying() -> void:
|
||||
var run := Instance.make_dungeon(9, 4242)
|
||||
var srv := _paying_server(run)
|
||||
var kill := Vector2.ZERO
|
||||
var corpse := _seat(srv, run, 11, kill)
|
||||
corpse.alive = false
|
||||
srv._award_kill(run, 50, kill)
|
||||
assert_eq(_xp_of(srv, 11), 0)
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||
@@ -18,6 +18,8 @@ var _step: int = 0
|
||||
var _srv: ServerRuntime
|
||||
var _account: int = 424242
|
||||
var _character: Character
|
||||
var _wounded: int = 0
|
||||
var _hub_arrival: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -77,6 +79,11 @@ func _physics_process(_delta: float) -> void:
|
||||
_check(false, "the dungeon had a living enemy to kill")
|
||||
_finish()
|
||||
return
|
||||
# Stand over it. Experience now has a radius, so a kill the player is
|
||||
# nowhere near pays nothing -- which would make this check depend on
|
||||
# where the generator happened to put the enemy.
|
||||
var mine_now: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||
mine_now.pos = victim.pos
|
||||
victim.hp = 1
|
||||
inst.world._damage_enemy(victim, 100)
|
||||
_srv._dispatch_events(inst)
|
||||
@@ -116,6 +123,68 @@ func _physics_process(_delta: float) -> void:
|
||||
_check(_srv.store.characters_for(_account).size() == before_count,
|
||||
"creating a character inside a dungeon is refused too")
|
||||
|
||||
elif _step == 65:
|
||||
# A kill nobody was near pays nobody. The radius exists because without
|
||||
# it a player parked in the entrance room collected the full experience
|
||||
# for a boss they never saw.
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
var far: SimEnemy = null
|
||||
for e in inst.world.enemies.values():
|
||||
if e.alive:
|
||||
far = e
|
||||
break
|
||||
if far == null:
|
||||
_check(false, "a second enemy to kill out of sight")
|
||||
else:
|
||||
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||
p.pos = far.pos + Vector2(SimConfig.XP_AWARD_RADIUS + 400.0, 0.0)
|
||||
var before: int = _srv.store.get_character(_account, _character.id).total_xp
|
||||
far.hp = 1
|
||||
inst.world._damage_enemy(far, 100)
|
||||
_srv._dispatch_events(inst)
|
||||
var after: int = _srv.store.get_character(_account, _character.id).total_xp
|
||||
_check(after == before,
|
||||
"a kill out of range awards nothing (%d -> %d)" % [before, after])
|
||||
|
||||
elif _step == 82:
|
||||
# Health is carried, not reset. Arriving anywhere used to set it to
|
||||
# maximum, which made a run free: walk in, take what you can, walk out,
|
||||
# walk back in whole.
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||
p.spawn_grace = 0
|
||||
p.hp = int(float(p.max_hp) * 0.4)
|
||||
_wounded = p.hp
|
||||
_check(_wounded > 0 and _wounded < p.max_hp, "the player is wounded")
|
||||
|
||||
elif _step == 84:
|
||||
_srv._send_to_lobby(Net.LOCAL_PEER)
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
_check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY,
|
||||
"escaped to the hub")
|
||||
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||
_check(p.hp < p.max_hp, "the wound came with them (%d/%d)" % [p.hp, p.max_hp])
|
||||
_check(absi(p.hp - _wounded) <= 2,
|
||||
"and it is the same wound, not a new number (%d vs %d)" % [p.hp, _wounded])
|
||||
_hub_arrival = p.hp
|
||||
|
||||
elif _step == 88:
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||
_check(p.hp > _hub_arrival,
|
||||
"the hub is healing it back (%d -> %d)" % [_hub_arrival, p.hp])
|
||||
_check(p.hp < p.max_hp, "but has not finished in four ticks")
|
||||
_srv._send_to_dungeon(Net.LOCAL_PEER)
|
||||
|
||||
elif _step == 89:
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
|
||||
"back into a dungeon")
|
||||
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||
_check(p.hp < p.max_hp,
|
||||
"and walked in still hurt (%d/%d) -- entering is not a heal"
|
||||
% [p.hp, p.max_hp])
|
||||
|
||||
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,
|
||||
|
||||
+57
-1
@@ -27,7 +27,9 @@ const OPEN_CREDITS := 290
|
||||
const AT_CREDITS := 320
|
||||
const OPEN_UPGRADES := 330
|
||||
const AT_UPGRADES := 360
|
||||
const DONE := 370
|
||||
const OPEN_ROSTER := 370
|
||||
const AT_ROSTER := 400
|
||||
const DONE := 410
|
||||
|
||||
var _frame: int = 0
|
||||
var _game: Node = null
|
||||
@@ -67,6 +69,10 @@ func _process(_delta: float) -> void:
|
||||
# Held until the shot is taken; one frame of it is not enough.
|
||||
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
|
||||
_stand_at_the_npc()
|
||||
# Same reason: the game scene owns this screen's visibility too and decides
|
||||
# it every frame, so one assignment lasts exactly one frame.
|
||||
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
|
||||
_show_the_roster()
|
||||
match _frame:
|
||||
AT_MENU:
|
||||
_grab("menu")
|
||||
@@ -108,11 +114,61 @@ func _process(_delta: float) -> void:
|
||||
_game.get_node("UpgradeScreen").refresh(2, offer, taken)
|
||||
AT_UPGRADES:
|
||||
_grab("upgrades")
|
||||
OPEN_ROSTER:
|
||||
_show_the_roster()
|
||||
AT_ROSTER:
|
||||
_grab("roster")
|
||||
DONE:
|
||||
Net.shutdown()
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
## Put the roster on screen with a made-up set of characters.
|
||||
##
|
||||
## Made up for the same reason the upgrade offer is: the point is the layout,
|
||||
## and earning three characters at three different health levels first would
|
||||
## make the shot depend on a dungeon run. One whole, one badly hurt, one barely
|
||||
## alive -- health survives a run now, so the wounded rows are the reason this
|
||||
## screen is worth a picture at all.
|
||||
##
|
||||
## Walking off the quartermaster first is not optional: the game scene re-opens
|
||||
## the upgrade screen every frame the player is standing on the NPC, and it
|
||||
## draws over this one.
|
||||
func _show_the_roster() -> void:
|
||||
var away: Vector2 = Net.client.upgrade_npc + Vector2(600.0, 0.0)
|
||||
Net.client.predicted_pos = away
|
||||
var inst := Net.server.instance_of(Net.LOCAL_PEER)
|
||||
if inst != null:
|
||||
var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER)
|
||||
if p != null:
|
||||
p.pos = away
|
||||
_game.set("_upgrades_open", false)
|
||||
_game.set("_roster_open", true)
|
||||
var roster: Array[Dictionary] = [
|
||||
_roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)),
|
||||
_roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)),
|
||||
_roster_entry("Nearly", 11, 2000, 160, Color(1.0, 0.5, 0.5)),
|
||||
]
|
||||
_game.get_node("CharacterSelect").refresh(roster, "c4")
|
||||
|
||||
|
||||
## One roster row in the shape decode_characters produces.
|
||||
func _roster_entry(who: String, level: int, ceiling: int, current: int,
|
||||
tint: Color) -> Dictionary:
|
||||
return {
|
||||
"id": "c%d" % level,
|
||||
"name": who,
|
||||
"level": level,
|
||||
"xp": 0,
|
||||
"progress": 0.4,
|
||||
"max_hp": ceiling,
|
||||
"hp": current,
|
||||
"active": true,
|
||||
"colour": tint,
|
||||
"inventory": [0, 0, 0, 0] as Array[int],
|
||||
}
|
||||
|
||||
|
||||
func _grab(shot_name: String) -> void:
|
||||
var path := "%s/shot_%s.png" % [_dir, shot_name]
|
||||
var err := get_viewport().get_texture().get_image().save_png(path)
|
||||
|
||||
@@ -55,6 +55,22 @@ for n in 1 2; do
|
||||
sleep 0.4
|
||||
done
|
||||
|
||||
# A bot that claims an account another bot is already playing. One account is
|
||||
# one session: two peers on one account each get their own player built from the
|
||||
# SAME character record, so both hold a copy of one bag and whichever writes last
|
||||
# wins -- drop the single potion from both copies and two potions hit the floor.
|
||||
# The handshake has to refuse it, and it has to refuse the NEWCOMER, or claiming
|
||||
# someone's account becomes a way to kick them off the server.
|
||||
for _ in $(seq 1 60); do
|
||||
grep -q "authenticated as account 7001" "$OUT/server.log" 2>/dev/null && break
|
||||
sleep 0.25
|
||||
done
|
||||
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||
--name "twinbot" --account 7001 --autoquit 300 \
|
||||
> "$OUT/twinbot.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
sleep 0.4
|
||||
|
||||
# A fourth bot that leaves *politely* -- the same Net.shutdown() the in-game
|
||||
# menu's "Disconnect" button calls. The escape channel is keyed on the socket
|
||||
# closing, not on how it closed, so a clean exit must be caught exactly like the
|
||||
@@ -118,6 +134,17 @@ no_engine_errors() { # no_engine_errors <label> <file>
|
||||
fi
|
||||
}
|
||||
|
||||
count() { # count <label> <file> <pattern> <expected>
|
||||
local hits
|
||||
hits=$(grep -cE "$3" "$2" || true)
|
||||
if [[ "$hits" == "$4" ]]; then
|
||||
echo " ok $1"
|
||||
else
|
||||
echo " FAIL $1 (/$3/ matched $hits time(s), expected $4, in $(basename "$2"))"
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
refute() { # refute <label> <file> <pattern>
|
||||
local hits
|
||||
hits=$(grep -cE "$3" "$2" || true)
|
||||
@@ -166,6 +193,16 @@ check "a polite disconnect is also channelled" \
|
||||
# assert nothing at all. The upgrade-state message DOES cross the wire on every
|
||||
# login, so a malformed one shows up in the script-error refutations below --
|
||||
# and tools/diag_upgrades.tscn covers the loop itself.
|
||||
check "a duplicate account is refused" "$OUT/server.log" \
|
||||
"claimed account 7001, already held by peer"
|
||||
check "and the newcomer is the one told no" "$OUT/twinbot.log" \
|
||||
"that account is already playing"
|
||||
count "the account was only ever seated once" "$OUT/server.log" \
|
||||
"authenticated as account 7001" 1
|
||||
# The victim keeps playing: refusing the second connection must not disturb the
|
||||
# session that already held the account.
|
||||
check "the original session survives it" "$OUT/bot1.log" "entered instance .*LOBBY"
|
||||
|
||||
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||
no_engine_errors "no engine errors on the server" "$OUT/server.log"
|
||||
|
||||
Reference in new issue
Block a user