diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2968f05..5ea291c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -147,10 +147,19 @@ removed from the instance and left at the roster screen. The one exception is a linkdead player, which has nobody to show a roster to, so its body is left for the escape channel to resolve as before. -**Experience is shared across the party, undivided.** Everyone alive in the -instance receives the full amount for a kill. Splitting it would make bringing a -friend cost you progress, which is the opposite of what the hub roster exists to -encourage. +**Experience is shared across the party, undivided — among everyone who was +there.** Everyone alive receives the full amount for a kill. Splitting it would +make bringing a friend cost you progress, which is the opposite of what the hub +roster exists to encourage. + +Presence is a separate question from the split, and it has a radius: +`SimConfig.XP_AWARD_RADIUS`, ten percent past `FOG_VIEW_RADIUS`. Anyone who +watched something die is inside it; a player parked in the entrance room while +the party clears the dungeon is not, and used to collect the whole run. Ten +percent past sight rather than exactly on it, so the edge of the fog is +comfortably inside and nobody loses a kill they actually saw. One consequence +worth knowing: an upgraded shot outranges this several times over, so a kill +made somewhere you cannot see pays nothing. **A level-up heals by the amount it added.** Gaining a level mid-fight should feel like relief, not like the bar you were watching got further from full. @@ -168,12 +177,23 @@ than the one-second escape channel, and it would make that channel pointless. The menu greys the button out so the rule is visible, but the server refuses regardless of what any client's UI allows. -**Health regenerates at 0.5% of MAXIMUM per second, with no out-of-combat -gate.** A percentage rather than a flat rate, so it does not become irrelevant -at level 15 — a capped character regains 1.2 hp/s against a level 1's 0.5, and -both take about 200 seconds to heal from nothing. No combat gate because at this -rate it cannot out-heal anything actually shooting at you, and a trickle that -never stops is easier to reason about than a timer players have to learn. +**Health regenerates at 0.5% of MAXIMUM per second in a dungeon, with no +out-of-combat gate.** A percentage rather than a flat rate, so it does not become +irrelevant at level 15 — a capped character regains 1.2 hp/s against a level 1's +0.5. No combat gate because at this rate it cannot out-heal anything actually +shooting at you, and a trickle that never stops is easier to reason about than a +timer players have to learn. + +**The hub regenerates at 10% per second, and health is carried through a door.** +Arriving anywhere used to set health to maximum, which meant a run cost nothing: +walk in, take what you can, walk out, walk back in whole. Current health now +lives on the character record — the same reason the inventory does, since +`SimWorld` builds a blank player on every instance change — and the hub is where +it is paid off, over about ten seconds. Fast enough that recovering is not a +chore, slow enough that it is a decision to make before walking back in. The +dungeon rate is unchanged, so waiting inside one is still not a plan. + +More friction is expected here later; this is the floor, not the design. **A dead character is gone, as far as the player is concerned.** Retirement is the server's own bookkeeping for archival and troubleshooting; the roster the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f516461..7f84927 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -142,6 +142,15 @@ couple of minutes instead of a quarter of an hour, and because it is a portal rather than a launch flag you can compare it to a real run without restarting the server. +**It is a debugging tool and does not ship.** It pays exactly the same +experience a real run does — `Progression.xp_for_boss` ignores its argument and +`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not +touch — while its boss has 8% of its health and its trash drops ten times as +often. That makes it strictly the best place to level and to farm, which is +correct for a harness and would be ruinous in a release. Remove it from +`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or +give `DungeonDef` an `xp_mult` alongside the three multipliers it already has. + Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the two have to stay in the same order. diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 4e1f15d..ec224fd 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -64,6 +64,12 @@ const SPAWN_GRACE_TICKS := 120 # 2 seconds ## simpler to reason about than a timer players have to learn, and at this rate ## it cannot outpace anything that is actually shooting at you. const HP_REGEN_PERCENT_PER_SEC := 0.5 +## What the hub heals at instead. Arriving anywhere used to snap you to full, +## which made health free: enter a dungeon, take what you could, leave, walk +## back in whole. Recovering it takes ten seconds of standing in the hub now, +## so the run you just left cost you something -- and the cost is time in the +## one place nothing is shooting at you, not a resource to manage. +const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0 ## A downed player cannot leave for the hub until this has elapsed. Enforced in ## the simulation rather than only by grey-ing out the button, because a @@ -193,6 +199,16 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds const FOG_VIEW_RADIUS := 460.0 # --- Interest management ---------------------------------------------------- +## How near a kill you have to be to be paid for it. Ten percent beyond what a +## player can see, so someone who watched it die is always inside and someone +## parked at the entrance never is. Experience stays undivided among everyone +## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much +## each of them gets. +## +## Worth knowing: an upgraded shot outranges this several times over, so a kill +## made far enough away that you cannot see it pays nothing. +const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1 + ## Actors further than this from a player are not sent to that player at all. ## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of ## sight, but far short of a dungeon, so a modified client cannot read enemy diff --git a/src/instances/instance.gd b/src/instances/instance.gd index 3da08eb..4d3f9a8 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -50,6 +50,10 @@ static func make_lobby(instance_id: int) -> Instance: # 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 + # The hub is where a run's damage is paid off. Arriving used to heal you to + # full, which made health free between runs; ten seconds of standing here + # does the same job at a price. + inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC inst.state = State.ACTIVE # A single inert practice target so players can feel out the gun before # committing to a run. diff --git a/src/meta/character.gd b/src/meta/character.gd index 647abb8..1b44e57 100644 --- a/src/meta/character.gd +++ b/src/meta/character.gd @@ -28,6 +28,17 @@ var died_unix: int = 0 ## bags, and so a server restart does not quietly confiscate everyone's potions. var inventory: Array[StringName] = [] +## Current health, or [constant HP_FULL] for "however much this character can +## have". Stored for the same reason the inventory is: SimWorld builds a blank +## player on every instance change, so anything that must survive walking +## through a door has to live on the record. Without it, entering and leaving +## was a free heal, and health cost nothing between runs. +## +## HP_FULL rather than a number so a save written before this existed, and a +## character that has never been hurt, both load as whole rather than as dead. +const HP_FULL := -1 +var hp: int = HP_FULL + ## Upgrades taken, in the order they were taken. Duplicates are meaningful -- ## two Split Shots really is two splits -- so this is a list, not a set. ## @@ -140,6 +151,7 @@ func to_dict() -> Dictionary: # Items.ORDER being appended to, and a human editing it should be able # to tell what a character is carrying. "inventory": _inventory_ids(), + "hp": hp, "upgrades": _name_list(upgrades), "pending_choices": pending_choices, "offer": _name_list(offer), @@ -180,6 +192,11 @@ static func from_dict(d: Dictionary) -> Character: var carried_id := StringName(String(raw)) carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE) c.set_inventory(carried) + # Anything unreadable, negative or absent means full. The failure that + # matters is a save loading as a character with no health left, which under + # permadeath is not something a player can recover from. + var saved_hp := int(d.get("hp", HP_FULL)) + c.hp = saved_hp if saved_hp > 0 else HP_FULL # Unknown upgrade ids are dropped rather than kept as dead entries, so a # save from a build with an upgrade this one lacks still produces coherent # stats instead of a phantom that counts toward the +5% and does nothing. diff --git a/src/meta/character_store.gd b/src/meta/character_store.gd index e8857de..3574f0a 100644 --- a/src/meta/character_store.gd +++ b/src/meta/character_store.gd @@ -132,6 +132,16 @@ func set_inventory(account_id: int, character_id: String, items: Array[StringNam save() +## Bank a character's current health. Deferred like the inventory: it changes +## every tick a player is regenerating, and the server flushes once a tick. +func set_health(account_id: int, character_id: String, hp: int) -> void: + var c := get_character(account_id, character_id) + if c == null: + return + c.hp = maxi(hp, 1) + save_deferred() + + ## Award level-up choices and, if nothing is on the table yet, roll one. ## ## The offer is rolled HERE and stored, not generated on demand when the screen diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index bbe0edd..2d0d011 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -119,9 +119,11 @@ func _dispatch_events(inst: Instance) -> void: for ev in events: match int(ev["t"]): SimEvent.Type.ENEMY_DIED: - _award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", "")))) + _award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))), + ev.get("pos", Vector2.ZERO)) SimEvent.Type.BOSS_DIED: - _award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", "")))) + _award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))), + ev.get("pos", Vector2.ZERO)) SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ SimEvent.Type.ITEM_DROPPED: # The simulation moved items between the ground and a bag; the @@ -198,6 +200,7 @@ func on_peer_disconnected(peer_id: int) -> void: % [peer_id, peer_names.get(peer_id, "?"), inst.id]) _broadcast_roster() return + _bank_health(inst, peer_id) inst.remove_peer(peer_id) _forget_peer(peer_id) GameLog.info("server", "peer %d disconnected" % peer_id) @@ -218,6 +221,9 @@ func _forget_peer(peer_id: int) -> void: ## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are ## the same outcome, so it is simply removed. func _release_linkdead(peer_id: int, inst: Instance) -> void: + # Whatever the channel left it on. Pulling the plug at one hit point must + # not be a way to come back whole. + _bank_health(inst, peer_id) inst.remove_peer(peer_id) GameLog.info("server", "peer %d released from instance %d after drop" % [peer_id, inst.id]) @@ -348,6 +354,7 @@ func _enter_world_as(peer_id: int, c: Character) -> void: var account: int = peer_accounts[peer_id] var previous := instance_of(peer_id) if previous != null: + _bank_health(previous, peer_id) previous.remove_peer(peer_id) peer_characters[peer_id] = c.id peer_names[peer_id] = c.display_name @@ -505,15 +512,19 @@ func _adopt_character(peer_id: int, inst: Instance) -> void: if c == null or p == null: return p.adopt(c) - # Arriving anywhere is a fresh start. adopt() only clamps health down to the - # new ceiling, which would leave a character whose upgrades RAISED it - # arriving wounded for no reason. - p.hp = p.max_hp + # Health comes off the record, not from a full bar. Arriving used to heal + # you outright, which meant a run cost nothing: walk in, take what you can, + # walk out, walk back in whole. It is paid off in the hub instead, which + # regenerates fast (SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) but not + # instantly. HP_FULL covers a new character and a save written before health + # was kept; the clamp covers upgrades that moved the ceiling either way. + p.hp = p.max_hp if c.hp == Character.HP_FULL else clampi(c.hp, 1, p.max_hp) func _transfer(peer_id: int, to: Instance) -> void: var from := instance_of(peer_id) if from != null: + _bank_health(from, peer_id) from.remove_peer(peer_id) _place(peer_id, to) _broadcast_roster() @@ -618,16 +629,26 @@ func _stream_map(peer_id: int, inst: Instance) -> void: NetCodec.encode_map_chunks(inst.world.map, batch)) -## Experience is shared by everyone alive in the instance, undivided. Splitting -## it would make bringing a friend cost you progress, which is the opposite of -## what a co-op game wants; the hub roster exists to help people group up. -func _award_kill(inst: Instance, amount: int) -> void: +## Experience is shared, undivided, by everyone alive and near enough to +## [param at] to have been part of it. +## +## Undivided is the settled decision: splitting it would make bringing a friend +## cost you progress, which is the opposite of what a co-op game wants. The +## radius is a separate question -- who was there -- and it exists because +## without one a player parked at the entrance collected full boss experience +## for the whole run. SimConfig.XP_AWARD_RADIUS is ten percent past what a +## player can see, so anyone who watched it die is inside it and anyone who +## could not possibly have known is not. +func _award_kill(inst: Instance, amount: int, at: Vector2) -> void: if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON: return + var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS for peer in inst.peers: var p: SimPlayer = inst.world.players.get(peer) if p == null or not p.alive: continue + if p.pos.distance_squared_to(at) > reach_sq: + continue _grant_xp(peer, amount) @@ -637,6 +658,23 @@ func _award_kill(inst: Instance, amount: int) -> void: ## point of persisting an inventory is that a crash between "picked it up" and ## "wrote it down" must not be a way to lose an item -- or, far worse, a way to ## duplicate one. +## Copy a player's current health back onto its character record. +## +## Called wherever the [SimPlayer] is about to stop existing -- every transfer, +## every disconnect -- because SimWorld builds a blank one on the other side and +## whatever was not written down is gone. Also called on item transactions, so +## drinking a potion is banked with the potion that was spent. +func _bank_health(inst: Instance, peer_id: int) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + var character_id: String = peer_characters.get(peer_id, "") + if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): + return + var p: SimPlayer = inst.world.players.get(peer_id) + if p == null or not p.alive: + return # a dead character is retired; its health is not a thing anymore + store.set_health(account, character_id, p.hp) + + func _persist_inventory(inst: Instance, peer_id: int) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) var character_id: String = peer_characters.get(peer_id, "") @@ -649,6 +687,10 @@ func _persist_inventory(inst: Instance, peer_id: int) -> void: # to re-serialise every account on the server. ServerRuntime flushes once at # the end of the tick. store.set_inventory(account, character_id, p.inventory, true) + # A potion moves an item and hit points in the same action; banking one + # without the other would let a restart hand the potion back. + if p.alive: + store.set_health(account, character_id, p.hp) func _grant_xp(peer_id: int, amount: int) -> void: diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index 9108cea..3dc68cd 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -118,6 +118,16 @@ func can_request_respawn() -> bool: ## Adopt a character's stats. Called when a player picks or switches character. ## Inventory comes along: it is stored on the character, so swapping in the hub ## swaps bags too rather than handing one character another's potions. +## Adopt a character's stats. Called when a player picks or switches character, +## and again whenever an upgrade changes the numbers. +## +## Deliberately does NOT set current health. It is called mid-session by +## on_choose_upgrade, and reading health from the record there would snap a +## player back to whatever was last written to disk. recompute_max_hp() still +## clamps downwards, because Glass Cannon halving your ceiling has to take the +## hit points above it with them. Where current health comes from on arrival is +## ServerRuntime._adopt_character, which is the one place a record becomes a +## live player. func adopt(c: Character) -> void: character_id = c.id display_name = c.display_name @@ -126,7 +136,6 @@ func adopt(c: Character) -> void: colour = c.colour stats = PlayerStats.build(c.upgrades) recompute_max_hp() - hp = mini(hp, max_hp) set_inventory(c.inventory) @@ -187,13 +196,19 @@ func heal_percent(percent: float) -> int: return hp - before -## One tick of passive healing. Returns true if the visible hit points changed, -## so the caller can decide whether anything needs announcing. -func regenerate() -> bool: +## One tick of passive healing at [param percent_per_sec] of maximum health. +## Returns true if the visible hit points changed, so the caller can decide +## whether anything needs announcing. +## +## The rate is a property of where you are standing rather than a constant: the +## hub heals far faster than a dungeon does, because recovering between runs +## should take a moment rather than a resource, and recovering DURING one +## should not be possible at all by waiting. +func regenerate(percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC) -> bool: if not alive or hp >= max_hp: regen_carry = 0.0 return false - regen_carry += float(max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \ + regen_carry += float(max_hp) * (percent_per_sec / 100.0) \ * SimConfig.TICK_DELTA if regen_carry < 1.0: return false diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 3007177..8b5e0e4 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -54,6 +54,9 @@ var spawn_grace_ticks: int = 0 ## for as long as the key is held. A world you can leave is the default; the hub ## is the one that opts out. var escape_enabled: bool = true +## Passive healing here, as a percentage of maximum health per second. The hub +## overrides it -- see SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC. +var regen_percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC var _next_actor_id: int = 1 var _ctx := EmitContext.new() @@ -217,7 +220,7 @@ func step() -> void: func _step_players() -> void: for p in players.values(): - p.regenerate() + p.regenerate(regen_percent_per_sec) if p.spawn_grace > 0: p.spawn_grace -= 1 if p.fire_cooldown > 0: @@ -862,7 +865,12 @@ func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void: _drop_loot(e.def.loot, e.pos) # The def id rides along so the instance layer can score it without # looking up an actor that is about to stop existing. - events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)}) + # "pos" is read by ServerRuntime to decide who was close enough to be + # paid, and is deliberately not in the wire format -- encode_events + # writes the id and nothing else. A client has no use for it, and every + # field that does not go out is one that cannot be mined. + events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, + "def": String(e.def.id), "pos": e.pos}) ## Returns the damage actually applied, after the phase's armour multiplier, so @@ -880,7 +888,8 @@ func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int: if boss.hp <= 0: boss.alive = false _drop_loot(boss.def.loot, boss.pos) - events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)}) + events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id), + "pos": boss.pos}) return applied diff --git a/tests/unit/test_character_store.gd b/tests/unit/test_character_store.gd index 8f91278..5929be9 100644 --- a/tests/unit/test_character_store.gd +++ b/tests/unit/test_character_store.gd @@ -362,3 +362,44 @@ func test_an_immediate_write_clears_the_pending_one() -> void: var reloaded := CharacterStore.new(store._path) reloaded.load_from_disk() assert_eq(reloaded.get_character(ACC, c.id).total_xp, 7) + + +# --- Health on the record --------------------------------------------------- +# SimWorld builds a blank player on every instance change, so anything that must +# survive walking through a door lives here, exactly like the inventory. + +func test_health_survives_a_save_and_reload() -> void: + var c := store.create_character(ACC, "Wounded") + store.set_health(ACC, c.id, 137) + store.flush() + var reloaded := CharacterStore.new(store._path) + assert_true(reloaded.load_from_disk()) + assert_eq(reloaded.get_character(ACC, c.id).hp, 137) + + +func test_a_fresh_character_is_recorded_as_whole_rather_than_as_a_number() -> void: + var c := store.create_character(ACC, "New") + assert_eq(c.hp, Character.HP_FULL, + "a character who has never been hurt has no health number to keep") + + +func test_a_save_from_before_health_was_kept_loads_as_whole() -> void: + var d := Character.create("Old", RandomNumberGenerator.new()).to_dict() + d.erase("hp") + assert_eq(Character.from_dict(d).hp, Character.HP_FULL) + + +func test_a_nonsense_health_in_a_save_loads_as_whole_not_as_dead() -> void: + # Under permadeath, a character that loads at zero health is unrecoverable, + # so every unreadable value has to fail in the generous direction. + for bad in [0, -5, "banana"]: + var d := Character.create("Odd", RandomNumberGenerator.new()).to_dict() + d["hp"] = bad + assert_eq(Character.from_dict(d).hp, Character.HP_FULL, + "hp %s must load as whole" % bad) + + +func test_health_is_never_banked_at_zero() -> void: + var c := store.create_character(ACC, "Edge") + store.set_health(ACC, c.id, 0) + assert_eq(c.hp, 1, "the store floors it; a live character is never on zero") diff --git a/tests/unit/test_regen.gd b/tests/unit/test_regen.gd index 5b8c01a..33b33b4 100644 --- a/tests/unit/test_regen.gd +++ b/tests/unit/test_regen.gd @@ -96,3 +96,50 @@ func test_it_cannot_outpace_incoming_fire() -> void: SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_lt(p.hp, start, "standing in fire must still lose health") + + +# --- Where you are decides how fast you heal --------------------------------- +# Arriving anywhere used to set health to maximum, which made a run free: walk +# in, take what you can, walk out, walk back in whole. The hub pays that off +# over about ten seconds instead. + +func test_the_hub_heals_far_faster_than_a_dungeon() -> void: + var hub := Instance.make_lobby(1) + assert_eq(hub.world.regen_percent_per_sec, + SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) + var run := Instance.make_dungeon(2, 99) + assert_eq(run.world.regen_percent_per_sec, SimConfig.HP_REGEN_PERCENT_PER_SEC) + assert_gt(hub.world.regen_percent_per_sec, run.world.regen_percent_per_sec, + "recovering is what the hub is for; a dungeon must not be a rest stop") + + +func test_a_full_bar_takes_about_ten_seconds_in_the_hub() -> void: + var hub := Instance.make_lobby(1) + var p := hub.world.add_player(1, "hurt") + p.hp = 1 + for _t in SimConfig.TICK_RATE * 10: + hub.step() + assert_eq(p.hp, p.max_hp, "ten seconds of standing in the hub is a full bar") + + +func test_the_hub_still_takes_a_noticeable_moment() -> void: + # Fast, but not the instant heal this replaced: half a second must not do it. + var hub := Instance.make_lobby(1) + var p := hub.world.add_player(1, "hurt") + p.hp = 1 + for _t in SimConfig.TICK_RATE / 2: + hub.step() + assert_lt(p.hp, p.max_hp, "arriving in the hub is not a heal button") + assert_gt(p.hp, 1, "but it is visibly happening") + + +func test_a_dungeon_cannot_be_waited_out() -> void: + var run := Instance.make_dungeon(2, 99) + var p := run.world.add_player(1, "hurt") + p.hp = 1 + p.spawn_grace = 0 + var before := p.hp + for _t in SimConfig.TICK_RATE * 10: + p.regenerate(run.world.regen_percent_per_sec) + assert_lt(p.hp - before, p.max_hp / 10, + "ten seconds in a dungeon must not undo a fight") diff --git a/tests/unit/test_server_authority.gd b/tests/unit/test_server_authority.gd index 5f7a382..ace1d1f 100644 --- a/tests/unit/test_server_authority.gd +++ b/tests/unit/test_server_authority.gd @@ -351,3 +351,81 @@ func test_a_refused_flood_is_logged_once_and_not_once_per_packet() -> void: assert_true(srv._accept_control(PEER)) assert_false(srv._peer_control_warned.has(PEER), "behaving again re-arms the warning for the next burst") + + +# --- Who gets paid for a kill ----------------------------------------------- +# Experience is undivided among everyone who was there, which is settled. Who +# counts as "there" was not: without a radius, a player parked in the entrance +# room collected the full experience for a boss they never saw. + +func _paying_server(where: Instance) -> ServerRuntime: + var srv := _runtime() + srv.store = CharacterStore.new("user://test_award_%d.json" % randi()) + srv.store.load_from_disk() + srv.instances[where.id] = where + return srv + + +func _seat(srv: ServerRuntime, where: Instance, peer: int, at: Vector2) -> SimPlayer: + var c := srv.store.create_character(peer, "p%d" % peer) + srv.peer_accounts[peer] = peer + srv.peer_characters[peer] = c.id + srv.peer_instance[peer] = where.id + where.add_peer(peer, c.display_name) + var p: SimPlayer = where.world.players[peer] + p.pos = at + return p + + +func _xp_of(srv: ServerRuntime, peer: int) -> int: + return srv.store.get_character(peer, srv.peer_characters[peer]).total_xp + + +func test_only_players_near_the_kill_are_paid() -> void: + var run := Instance.make_dungeon(9, 4242) + var srv := _paying_server(run) + var kill := Vector2(500.0, 500.0) + _seat(srv, run, 11, kill + Vector2(50.0, 0.0)) + _seat(srv, run, 22, kill + Vector2(SimConfig.XP_AWARD_RADIUS + 200.0, 0.0)) + + srv._award_kill(run, 50, kill) + assert_eq(_xp_of(srv, 11), 50, "standing over it counts") + assert_eq(_xp_of(srv, 22), 0, "parked across the map does not") + DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) + + +func test_the_radius_reaches_past_what_a_player_can_see() -> void: + # Anyone who watched it die must be paid, so the boundary sits outside the + # fog rather than on it. + var run := Instance.make_dungeon(9, 4242) + var srv := _paying_server(run) + var kill := Vector2.ZERO + _seat(srv, run, 11, Vector2(SimConfig.FOG_VIEW_RADIUS, 0.0)) + srv._award_kill(run, 50, kill) + assert_eq(_xp_of(srv, 11), 50, + "at the very edge of sight you still saw it happen") + DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) + + +func test_a_kill_still_pays_the_whole_party_that_was_there() -> void: + # The radius decides who was present; it must not turn into a split. + var run := Instance.make_dungeon(9, 4242) + var srv := _paying_server(run) + var kill := Vector2(100.0, 100.0) + for peer in [11, 22, 33]: + _seat(srv, run, peer, kill + Vector2(40.0, 0.0)) + srv._award_kill(run, 50, kill) + for peer in [11, 22, 33]: + assert_eq(_xp_of(srv, peer), 50, "undivided, for everyone present") + DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) + + +func test_the_dead_are_not_paid_however_close_they_are_lying() -> void: + var run := Instance.make_dungeon(9, 4242) + var srv := _paying_server(run) + var kill := Vector2.ZERO + var corpse := _seat(srv, run, 11, kill) + corpse.alive = false + srv._award_kill(run, 50, kill) + assert_eq(_xp_of(srv, 11), 0) + DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) diff --git a/tools/diag_progression.gd b/tools/diag_progression.gd index 292a3c7..766df39 100644 --- a/tools/diag_progression.gd +++ b/tools/diag_progression.gd @@ -18,6 +18,8 @@ var _step: int = 0 var _srv: ServerRuntime var _account: int = 424242 var _character: Character +var _wounded: int = 0 +var _hub_arrival: int = 0 func _ready() -> void: @@ -77,6 +79,11 @@ func _physics_process(_delta: float) -> void: _check(false, "the dungeon had a living enemy to kill") _finish() return + # Stand over it. Experience now has a radius, so a kill the player is + # nowhere near pays nothing -- which would make this check depend on + # where the generator happened to put the enemy. + var mine_now: SimPlayer = inst.world.players[Net.LOCAL_PEER] + mine_now.pos = victim.pos victim.hp = 1 inst.world._damage_enemy(victim, 100) _srv._dispatch_events(inst) @@ -116,6 +123,68 @@ func _physics_process(_delta: float) -> void: _check(_srv.store.characters_for(_account).size() == before_count, "creating a character inside a dungeon is refused too") + elif _step == 65: + # A kill nobody was near pays nobody. The radius exists because without + # it a player parked in the entrance room collected the full experience + # for a boss they never saw. + var inst := _srv.instance_of(Net.LOCAL_PEER) + var far: SimEnemy = null + for e in inst.world.enemies.values(): + if e.alive: + far = e + break + if far == null: + _check(false, "a second enemy to kill out of sight") + else: + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + p.pos = far.pos + Vector2(SimConfig.XP_AWARD_RADIUS + 400.0, 0.0) + var before: int = _srv.store.get_character(_account, _character.id).total_xp + far.hp = 1 + inst.world._damage_enemy(far, 100) + _srv._dispatch_events(inst) + var after: int = _srv.store.get_character(_account, _character.id).total_xp + _check(after == before, + "a kill out of range awards nothing (%d -> %d)" % [before, after]) + + elif _step == 82: + # Health is carried, not reset. Arriving anywhere used to set it to + # maximum, which made a run free: walk in, take what you can, walk out, + # walk back in whole. + var inst := _srv.instance_of(Net.LOCAL_PEER) + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + p.spawn_grace = 0 + p.hp = int(float(p.max_hp) * 0.4) + _wounded = p.hp + _check(_wounded > 0 and _wounded < p.max_hp, "the player is wounded") + + elif _step == 84: + _srv._send_to_lobby(Net.LOCAL_PEER) + var inst := _srv.instance_of(Net.LOCAL_PEER) + _check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY, + "escaped to the hub") + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + _check(p.hp < p.max_hp, "the wound came with them (%d/%d)" % [p.hp, p.max_hp]) + _check(absi(p.hp - _wounded) <= 2, + "and it is the same wound, not a new number (%d vs %d)" % [p.hp, _wounded]) + _hub_arrival = p.hp + + elif _step == 88: + var inst := _srv.instance_of(Net.LOCAL_PEER) + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + _check(p.hp > _hub_arrival, + "the hub is healing it back (%d -> %d)" % [_hub_arrival, p.hp]) + _check(p.hp < p.max_hp, "but has not finished in four ticks") + _srv._send_to_dungeon(Net.LOCAL_PEER) + + elif _step == 89: + var inst := _srv.instance_of(Net.LOCAL_PEER) + _check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON, + "back into a dungeon") + var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] + _check(p.hp < p.max_hp, + "and walked in still hurt (%d/%d) -- entering is not a heal" + % [p.hp, p.max_hp]) + elif _step == 90: # Stop behaving like a bot before the kill. A bot auto-creates a # replacement the instant it sees an empty roster -- correct in play,