Compare commits

...
7 Commits
Author SHA1 Message Date
claudeandClaude Opus 5 a6084c1a8d Tell the roster the truth about a wounded character
ci / verify (push) Successful in 52s
Health survives a run now, so a character sitting on the select screen
can be nearly dead -- and the screen was still printing only the ceiling.
"level 11, 2000 hp" for a character on 160 is a lie told at the exact
moment it matters most, because the next thing that happens is the player
walks it into a dungeon under permadeath.

The roster message carries current hit points alongside the maximum, and
the screen shows a fraction only when it means something: a healthy
character still reads "1300 hp" rather than "1300/1300". The field is
inserted mid-record, which shifts every field after it, so the protocol
version goes to 10 -- a mismatched client would read the active flag out
of the middle of a health value.

tools/screenshot.gd had no capture for this screen, which is how a UI
change to it could have shipped unlooked-at. It now shows a made-up
roster of three characters -- whole, badly hurt, nearly dead -- for the
same reason the upgrade screen is fed a made-up offer: the point is the
layout, not a dungeon run. Getting there needs the player walked off the
quartermaster first, or the game scene re-opens the upgrade screen over
the top of it every frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:54:40 +02:00
claudeandClaude Opus 5 4aeb6d229d Health survives a door, and experience needs you to be there
Arriving anywhere set health to maximum, so a run cost nothing: walk in,
take what you could, walk out, walk back in whole. Current health now
lives on the character record for the same reason the inventory does --
SimWorld builds a blank player on every instance change, so anything that
must survive a door has to be written down -- and it is banked at every
transfer, every disconnect, and on every item transaction, since a potion
moves an item and hit points in one action.

The hub is where that damage is paid off, at 10% of maximum per second:
about ten seconds from nothing, fast enough not to be a chore and slow
enough to be a decision before walking back in. The dungeon rate is
unchanged, so waiting one out is still not a plan. Regeneration is a
property of the world now rather than a constant.

adopt() no longer touches 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 so Glass Cannon takes the hit points above the new
ceiling with it. ServerRuntime._adopt_character stays the one place a
record becomes a live player.

Experience is still undivided, but now only among players within
XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so
anyone who watched it die is inside and someone parked in the entrance
room is not. The death events carry a position for this, and deliberately
do not put it on the wire: encode_events still writes the id alone.

diag_progression covers the whole chain end to end -- wounded in a
dungeon, escaped to the hub with the wound intact, healing there, and
walking back in still hurt -- because a unit test cannot transfer a peer.
Both halves were mutation-tested against it; the unit suite alone missed
the arrival heal entirely.

The Proving Grounds is recorded in the roadmap as a debugging tool that
does not ship: it pays full experience for a boss with 8% health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:48:20 +02:00
claudeandClaude Opus 5 54086b5604 Stop the rate limiter from being its own amplifier
The control-message pacing added two commits ago logged one WARN line
per refused packet, which is a thousand formatted strings and a thousand
stdout writes per second for a client sending a thousand -- the exact
cost the limiter was added to bound, moved from the disk write to the
log. One line per burst now, re-armed when the peer behaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:27:07 +02:00
claudeandClaude Opus 5 7b710471df Make the arenas' cover actually stop bullets
The player's muzzle was fixed last commit; every enemy and boss emitter
has one too, and theirs reach further. A ring sits 46px out, an arc
sweep's outer arms sit past 100px, and a wall-gap curtain spans the whole
arena -- most of a curtain is nowhere near the thing that fired it. The
pool samples geometry once per tick and never sees the jump, so all of
those were spawning bullets on the far side of whatever stood between.

That matters here more than it sounds, because both boss arenas are
authored out of cover. The hall has pillars; the vault has barricades,
and its own comment says they are there so that knowing where the Cantor
is does not hand you a firing lane to it. A barricade blocks bullets and
deliberately does NOT block sight -- so it is exactly the cover this bug
cancelled. Measured before fixing: the Warden's curtain put 13 shots
through a one-tile wall and its arc sweep 13 more; the Cantor's curtain,
16.

Two checks, not one. The path test skips its own starting tile so a
shooter in a doorway still resolves, and that skip let a telegraphed
burst whose centre and ring sit inside one wall tile through -- born in
the wall, announced to every client, dead on the next tick. The
destination is now asked separately, so a bullet never exists inside
geometry.

A telegraphed strike passes its own marker as the source rather than the
boss: that burst is born where the player was warned, and a pillar
between the boss and the spot has nothing to say about it. A strike
marked on cover is now simply a dud, which is cover working; pinned by a
test so it stays a known shape.

First attempt at this used a turret behind a barricade and proved
nothing -- a turret's 24px offset cannot clear its own 16px radius plus a
32px tile, so the test could not have failed. Replaced with the two
emitters that can reach, plus both real arenas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:24:34 +02:00
claudeandClaude Opus 5 20715e77cc Make the two hot decoders as careful as the six cold ones
Every colder message here checks get_available_bytes() before it reads,
and has a test named for it. Snapshots and events -- twenty and sixty
times a second, the two that actually matter -- did neither.

A snapshot's counts are a u8 and two u16s read straight off the wire and
then looped without a bounds check. A garbage enemy count is up to 65535
dictionaries built out of an empty buffer, and a garbage tick is fed
directly to the client's clock estimate and its input-lead resync. The
channel is unreliable and ENet's checksum is off, so a short snapshot is
a network event, not only an attack. It now answers valid:false and the
client drops it -- one dropped snapshot costs 50ms of interpolation.

Events were worse in kind. The decoder read a type byte and matched on
it, so a type it did not recognise consumed one byte and carried on,
reading every field after it at the wrong offset: one stray byte became a
stream of bullets spawned at invented positions, which the client then
applied. Lengths are now declared up front and an unknown type stops the
stream. The prefix is kept, because the channel is reliable and ordered
and throwing the packet away would strand every despawn in it.

Both size tables are pinned to their encoders by tests that encode one of
each record and compare, so they cannot drift -- and a new SimEvent.Type
with no declared length fails a test rather than shipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:14:14 +02:00
claudeandClaude Opus 5 39d857c4d1 Bound what one packet can cost, and stop the hub eating its own loot
Selecting a character rewrote the entire store to disk, re-placed the
player, re-streamed its map chunks and broadcast a roster to everyone --
off one small reliable packet, at whatever rate a client cared to send.
Repeating a selection you already hold now answers yes and stops, and
select/create are paced per peer. Item moves and experience grants, the
two paths a client can trigger every tick, mark the store dirty and the
server flushes once per tick instead of once per event.

That flush also fixes experience quietly not being saved: grant_xp only
wrote when a level was crossed, and level is DERIVED from lifetime xp, so
the number nobody was keeping was the only number there is. A restart
rolled a character back to its last level-up.

The emergency escape ran in the hub, where completing it means
transferring into the instance you are already in: 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. The hub now opts out.

Ground loot evicts the oldest item when the floor fills. That is right
for a dungeon, which closes and takes its litter with it, and wrong for
the hub, which never closes -- a player dropping into a full floor
destroyed whatever someone else had left there longest, and drop is
edge-triggered per tick. A player's drop is refused instead; the world's
own drops still make room.

Also: a linkdead body is not in inst.peers, so closing a CLEARED dungeon
walked past it and left that peer's account and name in the dictionaries
forever -- with the new one-account guard, enough to lock someone out of
their own session. And several encoders wrote mini(size, 255) as a count
and then looped the whole array, which puts records after the count on
the wire; MAX_CLIENTS makes it unreachable, which is why it would have
survived to the day it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:09:43 +02:00
claudeandClaude Opus 5 ef28970654 Close three holes an unmodified client could walk through
One account is now one session. Two peers signed in as the same account
each got their own player built from the SAME character record, so both
held a copy of one bag and whichever wrote last won: drop the single
potion from both copies and two potions hit the floor. Reconciling two
live mirrors of one record cannot be made to work, so the handshake
refuses the second connection -- the newcomer, not the incumbent, or
claiming an account would be a way to kick anyone off the server.

A muzzle offset is a teleport and the bullet pool never sees it: it
samples geometry once per tick. Forward that is 19px, but Doubleshot
adds up to 45px sideways, wider than a 32px wall plus the 6px hitbox
holding you off it. Six Doubleshots, stand flush against a wall, fire
along it, and live shots came out the far side. Shots now start from the
shooter when geometry stands in the way, which keeps the count honest.

Bullet-spawn events carry the firing actor's exact position, and they
were filtered by a flat 2900-unit radius against an 800-unit actor cull.
A depth-1 map's diagonal is about 2521, so that radius culled nothing:
every enemy that fired was pinpointed for every player, through fog, on
terrain the client was deliberately not streamed. The test is now
per bullet -- can THIS one reach ground you can see -- which keeps the
guarantee the wide radius existed for and drops the radar.

Found while fixing the above: a rejection never reached the client. The
RPC is queued and only leaves on the frame's flush, so kicking in the
same call threw the reason away, and _on_server_disconnected overwrote
last_error anyway. Both were true of protocol mismatch too, which is the
one message a player most needs. Smoke asserts the reason arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:04:05 +02:00
30 changed files with 1570 additions and 71 deletions

No files matched your search

+30 -10
View File
@@ -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
+9
View File
@@ -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.
+35 -1
View File
@@ -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,8 +102,31 @@ 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:
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)
@@ -130,6 +158,12 @@ func _on_connect_failed() -> void:
func _on_server_disconnected() -> void:
# 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()
+12
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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.
+17
View File
@@ -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.
+47 -3
View File
@@ -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,14 +111,37 @@ 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)
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.
##
## The offer is rolled HERE and stored, not generated on demand when the screen
@@ -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]
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+38 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+11 -2
View File
@@ -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
+91
View File
@@ -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")
+30
View File
@@ -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)
+127
View File
@@ -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")
+72
View File
@@ -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")
+32
View File
@@ -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)
+30
View File
@@ -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)))
+167
View File
@@ -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)
+47
View File
@@ -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")
+178
View File
@@ -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))
+69
View File
@@ -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
View File
@@ -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)
+37
View File
@@ -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"