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>
227 lines
7.5 KiB
GDScript
227 lines
7.5 KiB
GDScript
class_name Instance
|
|
extends RefCounted
|
|
## One live world on the server: the shared lobby hub, or one dungeon run for a
|
|
## party. Instances are cheap -- a [SimWorld] and a peer list -- so a single
|
|
## server process hosts the hub plus every concurrent dungeon.
|
|
|
|
enum State {
|
|
## Dungeon is open for players to join from the hub.
|
|
FORMING,
|
|
## Fight in progress.
|
|
ACTIVE,
|
|
## Boss dead; players are being returned to the hub.
|
|
CLEARED,
|
|
}
|
|
|
|
var id: int = 0
|
|
var kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY
|
|
var world: SimWorld
|
|
var peers: Array[int] = []
|
|
var state: State = State.ACTIVE
|
|
var age: int = 0
|
|
var seed_value: int = 0
|
|
## Drives dungeon size and difficulty. The hub is always depth 0.
|
|
var depth: int = 0
|
|
## Which kind of run this is -- see [Dungeons]. Empty for the hub.
|
|
var dungeon_id: StringName = &""
|
|
|
|
## Dungeon progression. -1 is the pre-fight breather.
|
|
var stage: int = -1
|
|
var stage_delay: int = 0
|
|
var boss_id: StringName = &""
|
|
## Generated layout, used to place enemies room by room.
|
|
var rooms: Array[Rect2i] = []
|
|
var boss_spawn := Vector2.ZERO
|
|
var boss_room := Rect2i()
|
|
|
|
|
|
static func make_lobby(instance_id: int) -> Instance:
|
|
var inst := Instance.new()
|
|
inst.id = instance_id
|
|
inst.kind = Protocol.InstanceKind.LOBBY
|
|
inst.seed_value = 1
|
|
inst.world = SimWorld.new(inst.seed_value)
|
|
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
|
|
inst.world.set_map(built["grid"])
|
|
inst.world.portals = built["portals"]
|
|
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
|
|
inst.state = State.ACTIVE
|
|
# A single inert practice target so players can feel out the gun before
|
|
# committing to a run.
|
|
inst.world.spawn_enemy(Content.dummy(), built["dummy"])
|
|
return inst
|
|
|
|
|
|
## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and
|
|
## the boss are the same whichever is chosen; only how much health they have and
|
|
## how freely they drop differs. See [Dungeons].
|
|
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1,
|
|
dungeon: StringName = &"") -> Instance:
|
|
var inst := Instance.new()
|
|
inst.id = instance_id
|
|
inst.kind = Protocol.InstanceKind.DUNGEON
|
|
inst.seed_value = dungeon_seed
|
|
inst.depth = maxi(dungeon_depth, 1)
|
|
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
|
|
inst.world = SimWorld.new(dungeon_seed)
|
|
var flavour := Dungeons.get_or_default(inst.dungeon_id)
|
|
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
|
|
inst.depth, flavour.arena)
|
|
inst.world.set_map(built["grid"])
|
|
inst.rooms = built["rooms"]
|
|
inst.boss_spawn = built["boss_pos"]
|
|
inst.boss_room = built["boss_room"]
|
|
inst.world.spawn_point = built["spawn"]
|
|
# Arriving into a fight already in progress needs a moment of protection;
|
|
# arriving in the hub does not.
|
|
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
|
|
inst.boss_id = built["boss_id"]
|
|
inst.state = State.FORMING
|
|
inst._populate()
|
|
return inst
|
|
|
|
|
|
## Enemies are placed once, per room, at generation -- not spawned in waves.
|
|
## A dungeon you explore has to be populated before you arrive in it: waves
|
|
## would make the map's contents depend on when you walk in rather than where,
|
|
## and aggro range would have nothing left to gate.
|
|
func _populate() -> void:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = seed_value ^ 0x5eed
|
|
var flavour := Dungeons.get_or_default(dungeon_id)
|
|
var boss := world.spawn_boss(flavour.apply_to_boss(Content.boss(boss_id)))
|
|
boss.pos = boss_spawn
|
|
boss.room = _room_rect_world(boss_room)
|
|
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
|
|
if GameOpts.boss_rush:
|
|
return # dev switch: an empty dungeon with just the fight in it
|
|
for i in rooms.size():
|
|
# Room 0 holds the entrance; arriving into a fight you cannot see yet
|
|
# is not a decision, it is an ambush.
|
|
if i == 0:
|
|
continue
|
|
var r: Rect2i = rooms[i]
|
|
var count := rng.randi_range(1, 3 + depth / 2)
|
|
for _n in count:
|
|
var def := flavour.apply_to_enemy(_pick_enemy(rng))
|
|
var tx := rng.randi_range(r.position.x + 1, r.end.x - 2)
|
|
var ty := rng.randi_range(r.position.y + 1, r.end.y - 2)
|
|
var at := world.map.tile_centre(tx, ty)
|
|
if world.map.circle_blocked(at, def.radius):
|
|
continue
|
|
world.spawn_enemy(def, at, rng.randi_range(0, 120))
|
|
|
|
|
|
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
|
|
var roll := rng.randf()
|
|
if roll < 0.4:
|
|
return Content.drifter()
|
|
if roll < 0.75:
|
|
return Content.turret()
|
|
return Content.stalker()
|
|
|
|
|
|
## Tile-space room rect to world space, for confining a boss to its arena.
|
|
func _room_rect_world(r: Rect2i) -> Rect2:
|
|
if r.size == Vector2i.ZERO:
|
|
return Rect2()
|
|
var lo := world.map.tile_centre(r.position.x + 1, r.position.y + 1)
|
|
var hi := world.map.tile_centre(r.end.x - 2, r.end.y - 2)
|
|
return Rect2(lo, hi - lo)
|
|
|
|
|
|
func add_peer(peer_id: int, display_name: String) -> void:
|
|
if not peers.has(peer_id):
|
|
peers.append(peer_id)
|
|
world.add_player(peer_id, display_name)
|
|
|
|
|
|
func remove_peer(peer_id: int) -> void:
|
|
peers.erase(peer_id)
|
|
world.remove_player(peer_id)
|
|
|
|
|
|
## Drop a peer from the send list but leave its player in the world. Used when a
|
|
## connection dies: the body stays behind, still killable, channelling its way
|
|
## out (see SimPlayer.linkdead), while we stop trying to send to a dead socket.
|
|
func detach_peer(peer_id: int) -> void:
|
|
peers.erase(peer_id)
|
|
var p: SimPlayer = world.players.get(peer_id)
|
|
if p != null:
|
|
p.linkdead = true
|
|
|
|
|
|
## Peers we still talk to. Linkdead bodies are not counted -- an instance whose
|
|
## only occupant has dropped should wind down, not wait on a ghost.
|
|
func is_empty() -> bool:
|
|
return peers.is_empty()
|
|
|
|
|
|
## True while a dropped player is still resolving its escape channel. Keeps the
|
|
## instance alive just long enough for that to finish.
|
|
func has_linkdead() -> bool:
|
|
for p in world.players.values():
|
|
if p.linkdead:
|
|
return true
|
|
return false
|
|
|
|
|
|
## Whole seconds until a cleared dungeon returns its party, or
|
|
## Protocol.COUNTDOWN_NONE when that does not apply.
|
|
func exit_countdown_seconds() -> int:
|
|
if state != State.CLEARED:
|
|
return Protocol.COUNTDOWN_NONE
|
|
return mini(int(ceil(float(stage_delay) / float(SimConfig.TICK_RATE))),
|
|
Protocol.COUNTDOWN_NONE - 1)
|
|
|
|
|
|
## [param dungeon] has to match: walking into the Proving Grounds portal must
|
|
## never drop you into a standard run that happens to still be forming, however
|
|
## conveniently timed.
|
|
func accepts_new_party_member(dungeon: StringName) -> bool:
|
|
return kind == Protocol.InstanceKind.DUNGEON \
|
|
and dungeon_id == dungeon \
|
|
and state == State.FORMING \
|
|
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
|
|
|
|
|
|
func step() -> void:
|
|
age += 1
|
|
world.step()
|
|
if kind == Protocol.InstanceKind.DUNGEON:
|
|
_step_dungeon()
|
|
|
|
|
|
## The dungeon is fully populated at generation, so all that is left to track
|
|
## is the party forming and the boss dying.
|
|
func _step_dungeon() -> void:
|
|
if stage_delay > 0:
|
|
stage_delay -= 1
|
|
return
|
|
if state == State.CLEARED:
|
|
return
|
|
|
|
if state == State.FORMING:
|
|
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
|
|
state = State.ACTIVE
|
|
return
|
|
|
|
if world.boss != null and not world.boss.alive:
|
|
state = State.CLEARED
|
|
stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
|
|
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
|
|
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
|
|
|
|
|
|
func _live_enemy_count() -> int:
|
|
var n := 0
|
|
for e in world.enemies.values():
|
|
if e.alive:
|
|
n += 1
|
|
return n
|