Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated server with client-side prediction. Clients send input only; the server resolves every hit for both players and enemies (no PvP). - SimWorld: whole simulation as plain RefCounted objects (no nodes, no physics server), ~0.24ms/tick at peak load -- runs headless for free and drives 78 tests in under a second - BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn events rather than per-tick state - Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash enemies and bosses -- a new boss is data in src/content/content.gd, no simulation changes - The Warden of the Fold: stationary 4-phase boss built entirely on that format - Lobby hub with a portal into on-demand dungeon instances; one process hosts the hub plus every concurrent dungeon - Emergency escape: 3s server-owned channel, cancelled by damage - tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over ENet), bench.gd; git hooks wired to the same scripts - docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
extends GutTest
|
||||
## The security tests. Each one describes something a modified client would try
|
||||
## and asserts that the authoritative world does not let it happen.
|
||||
##
|
||||
## The design intent is that these are boring to write, because the client has
|
||||
## no message that expresses the cheat in the first place -- it can only send
|
||||
## intent. These tests pin that property down so a future "just let the client
|
||||
## send its position, it is simpler" change fails loudly.
|
||||
|
||||
var world: SimWorld
|
||||
const PEER := 7
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
world = SimWorld.new(1)
|
||||
world.add_player(PEER, "tester")
|
||||
|
||||
|
||||
func _send(frame_tick: int, move := Vector2.ZERO, buttons := 0, aim := 0.0) -> void:
|
||||
var frames: Array[InputFrame] = [InputFrame.make(frame_tick, move, aim, buttons)]
|
||||
world.queue_input(PEER, frames)
|
||||
|
||||
|
||||
## Drive the player for [param ticks] ticks with one fresh input per tick.
|
||||
func _drive(ticks: int, move := Vector2.ZERO, buttons := 0, aim := 0.0) -> void:
|
||||
for _i in ticks:
|
||||
_send(world.tick + 1, move, buttons, aim)
|
||||
world.step()
|
||||
|
||||
|
||||
func test_player_cannot_outrun_the_configured_speed() -> void:
|
||||
var start: Vector2 = world.players[PEER].pos
|
||||
_drive(60, Vector2(1.0, 0.0))
|
||||
var travelled: float = world.players[PEER].pos.distance_to(start)
|
||||
assert_almost_eq(travelled, SimConfig.PLAYER_SPEED, 1.0,
|
||||
"one second of held input must cover exactly one second of movement")
|
||||
|
||||
|
||||
func test_replayed_input_is_dropped() -> void:
|
||||
_drive(5, Vector2.RIGHT)
|
||||
var pos_after: Vector2 = world.players[PEER].pos
|
||||
var acked: int = world.players[PEER].last_input_tick
|
||||
# Re-send an already-consumed tick, the classic replay attack.
|
||||
_send(acked, Vector2.RIGHT)
|
||||
assert_eq(world.players[PEER].input_queue.size(), 0)
|
||||
world.step()
|
||||
# The held input coasts one more tick, which is expected; what matters is
|
||||
# that the stale frame did not stack a second move on top of it.
|
||||
assert_almost_eq(world.players[PEER].pos.distance_to(pos_after),
|
||||
SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA, 0.001)
|
||||
|
||||
|
||||
func test_input_from_the_far_future_is_dropped() -> void:
|
||||
_send(world.tick + SimConfig.INPUT_MAX_LEAD + 50, Vector2.RIGHT)
|
||||
assert_eq(world.players[PEER].input_queue.size(), 0,
|
||||
"a client cannot buy a head start by claiming a future tick")
|
||||
|
||||
|
||||
func test_ancient_input_is_dropped() -> void:
|
||||
world.tick = 10000
|
||||
_send(1, Vector2.RIGHT)
|
||||
assert_eq(world.players[PEER].input_queue.size(), 0)
|
||||
|
||||
|
||||
func test_input_flood_cannot_grow_the_queue_without_bound() -> void:
|
||||
for i in 500:
|
||||
_send(world.tick + 1 + i, Vector2.RIGHT)
|
||||
assert_lte(world.players[PEER].input_queue.size(), SimConfig.INPUT_MAX_AGE,
|
||||
"a flood of inputs must not become unbounded server memory")
|
||||
|
||||
|
||||
func test_fire_rate_is_enforced_by_the_server() -> void:
|
||||
# Hold fire every single tick; the server still applies its own cooldown.
|
||||
_drive(60, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||
var expected := 60 / SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
assert_almost_eq(float(world.pool.live_count), float(expected), 2.0,
|
||||
"holding fire must not fire faster than the cooldown allows")
|
||||
|
||||
|
||||
func test_a_starved_player_eventually_stops_moving() -> void:
|
||||
_drive(3, Vector2.RIGHT)
|
||||
var pos_at_starve: Vector2 = world.players[PEER].pos
|
||||
# Send nothing at all for a long time, as a disconnecting client would.
|
||||
for _i in SimConfig.INPUT_MAX_AGE + 120:
|
||||
world.step()
|
||||
var coasted: float = world.players[PEER].pos.distance_to(pos_at_starve)
|
||||
assert_lt(coasted, SimConfig.PLAYER_SPEED * 1.0,
|
||||
"a silent client must coast briefly, then stop, not drift forever")
|
||||
|
||||
|
||||
func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25)
|
||||
assert_eq(world.pool.live_count, 0, "a bullet that hits must be consumed")
|
||||
|
||||
|
||||
func test_invulnerability_frames_stop_a_second_hit() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
for _i in 2:
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25, "two bullets in one tick is still one hit")
|
||||
|
||||
|
||||
func test_a_replica_world_never_resolves_a_hit() -> void:
|
||||
world.authoritative = false
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP,
|
||||
"only the server decides damage; a client replica must never apply it")
|
||||
|
||||
|
||||
func test_player_death_and_respawn() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.hp = 5
|
||||
p.iframes = 0
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_false(p.alive)
|
||||
for _i in SimConfig.PLAYER_RESPAWN_DELAY + 1:
|
||||
world.step()
|
||||
assert_true(p.alive)
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP)
|
||||
assert_eq(p.pos, world.spawn_point)
|
||||
Reference in New Issue
Block a user