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,193 @@
|
||||
class_name ServerRuntime
|
||||
extends Node
|
||||
## The dedicated server. Owns every instance, ticks them all at the physics
|
||||
## rate, and is the only place in the codebase allowed to decide what happened.
|
||||
##
|
||||
## Clients send intent and nothing else (see [method SimWorld.queue_input]), so
|
||||
## there is no client message that can move a player, deal damage, cancel a hit
|
||||
## or shorten an escape channel. Refusing to accept those messages at all is a
|
||||
## stronger guarantee than validating them after the fact.
|
||||
|
||||
var instances: Dictionary[int, Instance] = {}
|
||||
var peer_instance: Dictionary[int, int] = {}
|
||||
var peer_names: Dictionary[int, String] = {}
|
||||
var lobby: Instance
|
||||
|
||||
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
|
||||
var _snapshot_phase: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
lobby = Instance.make_lobby(_take_instance_id())
|
||||
instances[lobby.id] = lobby
|
||||
GameLog.info("server", "lobby instance %d up" % lobby.id)
|
||||
|
||||
|
||||
func _take_instance_id() -> int:
|
||||
var id := _next_instance_id
|
||||
_next_instance_id += 1
|
||||
return id
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_snapshot_phase += 1
|
||||
var send_snapshot := _snapshot_phase % SimConfig.SNAPSHOT_INTERVAL == 0
|
||||
var closing: Array[int] = []
|
||||
|
||||
for inst in instances.values():
|
||||
inst.step()
|
||||
_dispatch_events(inst)
|
||||
if send_snapshot and not inst.peers.is_empty():
|
||||
var snap := NetCodec.encode_snapshot(inst.world)
|
||||
for peer in inst.peers:
|
||||
Net.send_snapshot(peer, snap)
|
||||
if inst.kind == Protocol.InstanceKind.DUNGEON \
|
||||
and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
|
||||
closing.append(inst.id)
|
||||
elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60:
|
||||
closing.append(inst.id)
|
||||
|
||||
for id in closing:
|
||||
_close_dungeon(id)
|
||||
|
||||
|
||||
## Split the tick's events into the ones clients need and the ones only the
|
||||
## server acts on (escape completion, portal use).
|
||||
func _dispatch_events(inst: Instance) -> void:
|
||||
var events := inst.world.drain_events()
|
||||
if events.is_empty():
|
||||
return
|
||||
var transfers: Array[Dictionary] = []
|
||||
for ev in events:
|
||||
match int(ev["t"]):
|
||||
SimEvent.Type.ESCAPE_COMPLETED:
|
||||
transfers.append({"peer": int(ev["peer"]), "to_lobby": true})
|
||||
SimEvent.Type.PORTAL_USED:
|
||||
transfers.append({"peer": int(ev["peer"]), "to_lobby": false})
|
||||
_:
|
||||
pass
|
||||
|
||||
var payload := NetCodec.encode_events(inst.world.tick, events)
|
||||
for peer in inst.peers:
|
||||
Net.send_events(peer, payload)
|
||||
|
||||
for t in transfers:
|
||||
if t["to_lobby"]:
|
||||
_send_to_lobby(int(t["peer"]))
|
||||
else:
|
||||
_send_to_dungeon(int(t["peer"]))
|
||||
|
||||
|
||||
# --- Peer lifecycle ---------------------------------------------------------
|
||||
|
||||
func on_peer_connected(peer_id: int) -> void:
|
||||
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
|
||||
|
||||
|
||||
func on_peer_disconnected(peer_id: int) -> void:
|
||||
var inst := instance_of(peer_id)
|
||||
if inst != null:
|
||||
inst.remove_peer(peer_id)
|
||||
peer_instance.erase(peer_id)
|
||||
peer_names.erase(peer_id)
|
||||
GameLog.info("server", "peer %d disconnected" % peer_id)
|
||||
|
||||
|
||||
func on_hello(peer_id: int, version: int, display_name: String) -> void:
|
||||
if peer_names.has(peer_id):
|
||||
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)
|
||||
return
|
||||
# Never trust a client-supplied string for anything but display.
|
||||
var clean := display_name.strip_edges().substr(0, 24)
|
||||
if clean.is_empty():
|
||||
clean = "player%d" % peer_id
|
||||
peer_names[peer_id] = clean
|
||||
Net.send_welcome(peer_id)
|
||||
_place(peer_id, lobby)
|
||||
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
|
||||
|
||||
|
||||
func on_input(peer_id: int, data: PackedByteArray) -> void:
|
||||
var inst := instance_of(peer_id)
|
||||
if inst == null:
|
||||
return
|
||||
inst.world.queue_input(peer_id, NetCodec.decode_inputs(data))
|
||||
|
||||
|
||||
func instance_of(peer_id: int) -> Instance:
|
||||
var id: int = peer_instance.get(peer_id, 0)
|
||||
return instances.get(id)
|
||||
|
||||
|
||||
# --- Transfers --------------------------------------------------------------
|
||||
|
||||
func _place(peer_id: int, inst: Instance) -> void:
|
||||
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
|
||||
peer_instance[peer_id] = inst.id
|
||||
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
|
||||
String(inst.boss_id), inst.world.spawn_point)
|
||||
# A player arriving mid-fight has no idea what is already in the air, so
|
||||
# replay the live bullets as spawn events before the next snapshot lands.
|
||||
var backlog := _live_bullet_events(inst.world)
|
||||
if not backlog.is_empty():
|
||||
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
|
||||
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world))
|
||||
|
||||
|
||||
func _transfer(peer_id: int, to: Instance) -> void:
|
||||
var from := instance_of(peer_id)
|
||||
if from != null:
|
||||
from.remove_peer(peer_id)
|
||||
_place(peer_id, to)
|
||||
|
||||
|
||||
func _send_to_lobby(peer_id: int) -> void:
|
||||
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
|
||||
_transfer(peer_id, lobby)
|
||||
|
||||
|
||||
func _send_to_dungeon(peer_id: int) -> void:
|
||||
var target: Instance = null
|
||||
for inst in instances.values():
|
||||
if inst.accepts_new_party_member():
|
||||
target = inst
|
||||
break
|
||||
if target == null:
|
||||
target = Instance.make_dungeon(_take_instance_id(), randi())
|
||||
instances[target.id] = target
|
||||
GameLog.info("server", "opened dungeon instance %d" % target.id)
|
||||
_transfer(peer_id, target)
|
||||
|
||||
|
||||
func _close_dungeon(id: int) -> void:
|
||||
var inst: Instance = instances.get(id)
|
||||
if inst == null:
|
||||
return
|
||||
for peer in inst.peers.duplicate():
|
||||
_transfer(peer, lobby)
|
||||
instances.erase(id)
|
||||
GameLog.info("server", "closed dungeon instance %d" % id)
|
||||
|
||||
|
||||
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for i in world.pool.high_water:
|
||||
if world.pool.alive[i] == 0:
|
||||
continue
|
||||
out.append({
|
||||
"t": SimEvent.Type.BULLET_SPAWN,
|
||||
"uid": world.pool.uid[i],
|
||||
"pos": world.pool.pos[i],
|
||||
"vel": world.pool.vel[i],
|
||||
"r": world.pool.radius[i],
|
||||
"life": world.pool.life[i],
|
||||
"kind": world.pool.kind[i],
|
||||
"team": world.pool.team[i],
|
||||
"accel": world.pool.accel[i],
|
||||
"turn": world.pool.turn[i],
|
||||
})
|
||||
return out
|
||||
Reference in New Issue
Block a user