Initial commit: Transcience MVP
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,127 @@
|
||||
extends GutTest
|
||||
## The load-bearing assumption of the whole netcode: a client that only receives
|
||||
## bullet spawn and despawn events can reproduce the server's bullet field
|
||||
## exactly, by running the same integration.
|
||||
##
|
||||
## That is what lets the server send ~30 bytes once per bullet instead of a
|
||||
## position for every bullet every snapshot. If this test ever fails, bullets on
|
||||
## screen no longer match the bullets that can kill you, and the fix is not to
|
||||
## loosen the tolerance -- it is to stop deriving anything the server did not
|
||||
## send.
|
||||
|
||||
var server: SimWorld
|
||||
var replica: SimWorld
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
server = SimWorld.new(99)
|
||||
server.authoritative = true
|
||||
replica = SimWorld.new(99)
|
||||
replica.authoritative = false
|
||||
|
||||
|
||||
func _sync_one_tick() -> Array[Dictionary]:
|
||||
server.step()
|
||||
var events := server.drain_events()
|
||||
# Order matters: the replica first advances the bullets it already knows
|
||||
# about to the server's new tick, then adopts the spawns, which arrive at
|
||||
# their post-step positions.
|
||||
replica.step()
|
||||
for ev in events:
|
||||
replica.apply_event(ev)
|
||||
return events
|
||||
|
||||
|
||||
func _mismatches() -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var server_uids := {}
|
||||
for i in server.pool.high_water:
|
||||
if server.pool.alive[i] == 0:
|
||||
continue
|
||||
var id: int = server.pool.uid[i]
|
||||
server_uids[id] = true
|
||||
var j := replica.pool.find_by_uid(id)
|
||||
if j < 0:
|
||||
out.append("bullet %d missing from replica" % id)
|
||||
continue
|
||||
var d: float = (server.pool.pos[i] as Vector2).distance_to(replica.pool.pos[j])
|
||||
if d > 0.01:
|
||||
out.append("bullet %d off by %.4f px" % [id, d])
|
||||
for i in replica.pool.high_water:
|
||||
if replica.pool.alive[i] == 1 and not server_uids.has(replica.pool.uid[i]):
|
||||
out.append("bullet %d lingering on replica" % replica.pool.uid[i])
|
||||
return out
|
||||
|
||||
|
||||
func test_boss_pattern_reproduces_exactly_on_the_replica() -> void:
|
||||
server.add_player(1, "tester")
|
||||
server.players[1].pos = Vector2(0.0, 220.0)
|
||||
server.spawn_boss(Content.warden())
|
||||
for _i in 900:
|
||||
_sync_one_tick()
|
||||
assert_gt(server.pool.live_count, 30, "the fight should be producing bullets")
|
||||
assert_eq(_mismatches(), [] as Array[String])
|
||||
|
||||
|
||||
func test_enemy_patterns_reproduce_exactly_on_the_replica() -> void:
|
||||
server.add_player(1, "tester")
|
||||
server.players[1].pos = Vector2(80.0, 200.0)
|
||||
server.spawn_enemy(Content.turret(), Vector2(-200.0, -100.0))
|
||||
server.spawn_enemy(Content.turret(), Vector2(200.0, -100.0), 40)
|
||||
server.spawn_enemy(Content.drifter(), Vector2(0.0, -200.0))
|
||||
for _i in 900:
|
||||
_sync_one_tick()
|
||||
assert_gt(server.pool.live_count, 5)
|
||||
assert_eq(_mismatches(), [] as Array[String])
|
||||
|
||||
|
||||
func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void:
|
||||
# A stationary player soaking fire produces plenty of early despawns, which
|
||||
# are the one thing the replica cannot derive on its own.
|
||||
server.add_player(1, "tester")
|
||||
# Just below the boss, inside the ring pattern rather than at its centre.
|
||||
server.players[1].pos = Vector2(0.0, 20.0)
|
||||
server.players[1].iframes = 0
|
||||
server.spawn_boss(Content.warden())
|
||||
var hits := 0
|
||||
for _i in 900:
|
||||
for ev in _sync_one_tick():
|
||||
if int(ev["t"]) == SimEvent.Type.PLAYER_HIT:
|
||||
hits += 1
|
||||
assert_gt(hits, 0, "the test needs the player to actually get hit")
|
||||
assert_eq(_mismatches(), [] as Array[String])
|
||||
|
||||
|
||||
func test_the_replica_never_invents_a_bullet() -> void:
|
||||
# Same enemies, but the replica is told nothing at all. It must stay empty:
|
||||
# emitters and AI are server-side only.
|
||||
replica.spawn_enemy(Content.turret(), Vector2.ZERO)
|
||||
replica.spawn_boss(Content.warden())
|
||||
for _i in 600:
|
||||
replica.step()
|
||||
assert_eq(replica.pool.live_count, 0)
|
||||
|
||||
|
||||
func test_full_event_stream_survives_the_wire_format() -> void:
|
||||
# Same as the parity test, but every event is encoded and decoded on the way,
|
||||
# so a codec bug cannot hide behind in-process dictionaries.
|
||||
server.add_player(1, "tester")
|
||||
server.players[1].pos = Vector2(0.0, 220.0)
|
||||
server.spawn_boss(Content.warden())
|
||||
for _i in 600:
|
||||
server.step()
|
||||
var packet := NetCodec.decode_events(
|
||||
NetCodec.encode_events(server.tick, server.drain_events()))
|
||||
replica.step()
|
||||
for ev: Dictionary in packet["events"]:
|
||||
replica.apply_event(ev)
|
||||
# float32 on the wire, so allow a hair more slack than the in-process test.
|
||||
var bad := 0
|
||||
for i in server.pool.high_water:
|
||||
if server.pool.alive[i] == 0:
|
||||
continue
|
||||
var j := replica.pool.find_by_uid(server.pool.uid[i])
|
||||
if j < 0 or (server.pool.pos[i] as Vector2).distance_to(replica.pool.pos[j]) > 1.0:
|
||||
bad += 1
|
||||
assert_eq(bad, 0, "%d of %d bullets diverged through the codec"
|
||||
% [bad, server.pool.live_count])
|
||||
Reference in New Issue
Block a user