Initial commit: Transcience MVP
ci / verify (push) Successful in 1m57s

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:
Adyrem
2026-09-03 16:03:57 +02:00
commit 651c4ad94a
385 changed files with 28725 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
extends GutTest
## Progression through a whole dungeon instance: forming, two trash waves, the
## boss, and the cleared state that sends the party home.
var inst: Instance
func before_each() -> void:
inst = Instance.make_dungeon(2, 12345)
inst.add_peer(1, "tester")
func _step(n: int) -> void:
for _i in n:
inst.step()
func _kill_all_enemies() -> void:
for e in inst.world.enemies.values():
e.alive = false
func test_a_forming_dungeon_locks_after_the_window() -> void:
assert_eq(inst.state, Instance.State.FORMING)
_step(SimConfig.DUNGEON_FORMING_TICKS + 2)
assert_eq(inst.state, Instance.State.ACTIVE)
func test_a_full_party_locks_the_dungeon_immediately() -> void:
for peer in range(2, 2 + SimConfig.DUNGEON_PARTY_MAX - 1):
inst.add_peer(peer, "p%d" % peer)
_step(2)
assert_eq(inst.state, Instance.State.ACTIVE)
assert_false(inst.accepts_new_party_member())
func test_waves_gate_on_clearing_the_previous_one() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
assert_eq(inst.stage, 0)
var wave_one := inst.world.enemies.size()
assert_gt(wave_one, 0)
_step(200)
assert_eq(inst.stage, 0, "a slow party must never be overrun by the next wave")
_kill_all_enemies()
_step(120)
assert_eq(inst.stage, 1)
assert_gt(inst.world.enemies.size(), wave_one)
func test_the_boss_arrives_after_the_last_wave() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
for _wave in 2:
_kill_all_enemies()
_step(120)
assert_eq(inst.stage, 2)
assert_not_null(inst.world.boss)
assert_eq(inst.world.boss.def.id, Content.BOSS_WARDEN)
func test_killing_the_boss_clears_the_instance() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
for _wave in 2:
_kill_all_enemies()
_step(120)
inst.world.boss.alive = false
_step(5)
assert_eq(inst.state, Instance.State.CLEARED)
# The exit timer has to run down, or the party would never be released.
_step(400)
assert_eq(inst.stage_delay, 0)
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
assert_true(lobby.world.portal_enabled)
_step(1)
for _i in 600:
lobby.step()
assert_eq(lobby.world.pool.live_count, 0, "nothing in the hub may shoot at you")
assert_eq(lobby.world.players[1].hp, SimConfig.PLAYER_MAX_HP)
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
lobby.world.players[1].pos = SimConfig.PORTAL_POS
var frames: Array[InputFrame] = [
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
lobby.world.queue_input(1, frames)
lobby.step()
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 1)
func test_interacting_away_from_the_portal_does_nothing() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
lobby.world.players[1].pos = SimConfig.PORTAL_POS + Vector2(0.0, SimConfig.PORTAL_RADIUS + 50.0)
var frames: Array[InputFrame] = [
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
lobby.world.queue_input(1, frames)
lobby.step()
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 0)
@@ -0,0 +1 @@
uid://b0sw7d0vhuxyi
+127
View File
@@ -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])
@@ -0,0 +1 @@
uid://dvqtvvt3chqcs