Files
transcience/src/instances/instance.gd
T
claudeandClaude Opus 5 39d857c4d1 Bound what one packet can cost, and stop the hub eating its own loot
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>
2026-09-07 17:09:43 +02:00

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