From fc58c8d0e11b5f0038dd8dfe6bf3e35e64c5afc8 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 01:40:52 +0000 Subject: [PATCH] P3 alpha: wolf moon every 7th night, the alpha breaks wooden doors, iron-banded door, broken-door repair, alpha pelt and cloak (protocol 7) --- data/items.json | 42 +++++ data/recipes.json | 25 +++ docs/PROGRESSION_DESIGN.md | 16 +- docs/ROADMAP.md | 1 + scripts/building/door.gd | 88 +++++++-- scripts/core/actions.gd | 16 ++ scripts/creatures/creature.gd | 14 +- scripts/creatures/creature_meshes.gd | 42 ++++- scripts/creatures/wolf.gd | 109 ++++++++++- scripts/creatures/wolf_pack.gd | 83 ++++++++- scripts/items/item_meshes.gd | 42 +++++ scripts/net/creature_net.gd | 4 +- scripts/net/net.gd | 2 +- scripts/net/player_net.gd | 7 +- scripts/net/world_net.gd | 4 +- scripts/world/day_night.gd | 20 +- tests/run_all.sh | 2 +- tests/test_alpha.gd | 265 +++++++++++++++++++++++++++ 18 files changed, 742 insertions(+), 40 deletions(-) create mode 100644 tests/test_alpha.gd diff --git a/data/items.json b/data/items.json index f047416..2f90667 100644 --- a/data/items.json +++ b/data/items.json @@ -895,6 +895,48 @@ "piece": "door", "kind": "door" } + }, + { + "id": "iron_door", + "name": "Iron-banded Door", + "category": "building", + "stack": 10, + "description": "A heavy door bound with iron. Nothing on the island can break it down.", + "mesh": "iron_door", + "place": { + "piece": "door", + "kind": "door", + "sturdy": true, + "panel": "iron_door_panel" + } + }, + { + "id": "alpha_pelt", + "name": "Alpha Pelt", + "category": "resource", + "stack": 5, + "description": "The alpha's black fur, thick as a blanket.", + "mesh": "alpha_pelt" + }, + { + "id": "wolf_fang", + "name": "Wolf Fang", + "category": "resource", + "stack": 20, + "description": "A long yellowed fang from the alpha. A trophy, and a fine clasp.", + "mesh": "wolf_fang" + }, + { + "id": "alpha_cloak", + "name": "Alpha Cloak", + "category": "tool", + "stack": 1, + "description": "Right-click to wear it (+35 warmth, rain barely touches you). Take it off from the inventory screen.", + "mesh": "alpha_cloak", + "wear": { + "warmth": 35, + "wet_chill": 0.25 + } } ] } diff --git a/data/recipes.json b/data/recipes.json index 677cc93..71b53f6 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -496,6 +496,31 @@ "wood": 6, "nails": 4 } + }, + { + "id": "iron_door", + "output": "iron_door", + "count": 1, + "station": "forge", + "category": "building", + "inputs": { + "wood": 4, + "iron_bar": 2, + "nails": 4 + } + }, + { + "id": "alpha_cloak", + "output": "alpha_cloak", + "count": 1, + "station": "workbench", + "category": "clothing", + "inputs": { + "alpha_pelt": 1, + "wolf_pelt": 2, + "wolf_fang": 1, + "fiber": 4 + } } ] } diff --git a/docs/PROGRESSION_DESIGN.md b/docs/PROGRESSION_DESIGN.md index 08cd13c..3e08431 100644 --- a/docs/PROGRESSION_DESIGN.md +++ b/docs/PROGRESSION_DESIGN.md @@ -1,6 +1,7 @@ # Progression and challenges design -Status: **P1 (iron) and P2 (stone) built**; P3-P5 are proposals. +Status: **P1 (iron) and P2 (stone) built; P3 started (the alpha)**; the +rest of P3, and P4-P5, are proposals. *As built in P1:* - Iron nuggets (70, in the scree under the rim), clay (60, on the lake @@ -37,6 +38,19 @@ Status: **P1 (iron) and P2 (stone) built**; P3-P5 are proposals. windows still counts. - Palisade: 3 m tall, blocks like a wall online too. No fences. +*As built in P3 so far (the alpha):* +- Wolf moon: nights 7, 14, 21... (worked out from the day, so every game on + a server agrees). The moon turns red and a toast warns you. +- The alpha (135 HP, bites for 16) comes out of the den with the pack and + leaves at dawn. It isn't afraid of fire and never runs. If it can't reach + you it batters the nearest shut door near you: a wooden door (or the + wooden leaf in a stone doorway or palisade gate) gives way after about + five blows and hangs broken until mended (E, 2 wood); then it comes in. +- Iron-banded door (forge: 2 iron, 4 wood, 4 nails) can't be broken. +- Drops: alpha pelt, 1-2 fangs, meat. Alpha cloak (+35 warmth) at the + workbench. Network protocol 7. +- Not yet: the bear, crows and foxes, the barrow and its guards. + Today the island runs out of goals after a few days: once you have stone tools, a hut, a bow and a fire, the only threats are wolves at night, the cold and your diet. This plan adds a **second tier of materials** (iron and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0d851a9..5e030da 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,6 +39,7 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). - [x] Wild flax (fibre plants) *(polish)* - [x] Iron: nuggets, ore, clay, kiln, smelter, forge, iron tools and arrows, lantern, tool wear and repair *(P1)* - [x] Stone building: stonecutter, cut stone, stone floors, walls, pillars, stairs, windows, doorways, hearth; wooden stairs, windows, posts, palisade and gate *(P2)* +- [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)* - [ ] **Iron, stone building and new adversaries**: iron nuggets and ore, smelting and a forge, iron tools and durability, cut-stone building, an alpha wolf, a bear, a barrow with guards, spoilage and blizzards diff --git a/scripts/building/door.gd b/scripts/building/door.gd index baecf23..f6dcbd0 100644 --- a/scripts/building/door.gd +++ b/scripts/building/door.gd @@ -1,17 +1,28 @@ extends "res://scripts/building/build_piece.gd" -## Wooden door: a wall-sized frame (snaps like a wall) with a hinged panel. -## E opens or closes it. The panel is an AnimatableBody3D so it pushes the -## player out of the way rather than trapping them. +## Door: a wall-sized frame (snaps like a wall) with a hinged panel. E opens +## or closes it. The panel is an AnimatableBody3D so it pushes the player +## out of the way rather than trapping them. +## +## An alpha wolf can break a closed wooden door (bash()): after DOOR_HP worth +## of blows the panel gives way and hangs open, and the door shows as broken +## until you repair it (E, two wood). An iron-banded door (items.json +## place.sturdy) can't be broken. The frame stays either way, so whatever it +## holds up keeps standing. const OPEN_ANGLE := 1.75 ## radians: swings toward -Z, away from where you stood to place it +const DOOR_HP := 100.0 +const REPAIR_COST := {"wood": 2} var open := false +var broken := false +var door_hp := DOOR_HP var _hinge: Node3D var _panel: AnimatableBody3D +var _panel_shape: CollisionShape3D var _tween: Tween func _visual_mesh() -> Mesh: - return ItemDB.mesh_for(String(def.get("mesh", item_id)) + "_frame") # (wood_door_frame, stone_door_frame) + return ItemDB.mesh_for(String(def.get("mesh", item_id)) + "_frame") # (wood_door_frame, stone_doorway_frame...) func _build() -> void: _hinge = Node3D.new() @@ -27,23 +38,38 @@ func _build() -> void: _panel.set_meta("owner_piece", self) _hinge.add_child(_panel) var mi := MeshInstance3D.new() - mi.mesh = ItemDB.mesh_for("wood_door_panel") + mi.mesh = ItemDB.mesh_for(String(def.place.get("panel", "wood_door_panel"))) mi.material_override = ItemDB.item_material() _panel.add_child(mi) - var cs := CollisionShape3D.new() + _panel_shape = CollisionShape3D.new() var box := BoxShape3D.new() box.size = Vector3(1.06, 1.86, 0.08) - cs.shape = box - cs.position = Vector3(0.54, 0.95, 0.0) - _panel.add_child(cs) + _panel_shape.shape = box + _panel_shape.position = Vector3(0.54, 0.95, 0.0) + _panel.add_child(_panel_shape) + +## Iron-banded: nothing breaks it. +func sturdy() -> bool: + return bool(def.place.get("sturdy", false)) + +## Shut and in one piece (something trying to get in has to get past it). +func blocks() -> bool: + return not open and not broken func interact_prompt(_player: Node) -> String: + if broken: + return "Broken door [E] Repair (2 wood)" return "Door [E] %s" % ("Close" if open else "Open") func interact(player: Node) -> void: - Actions.toggle_door(player, self) + if broken: + Actions.repair_door(player, self) + else: + Actions.toggle_door(player, self) func set_open(on: bool, animate: bool = true) -> void: + if broken: + on = true # (it hangs open off one hinge) open = on var target := OPEN_ANGLE if on else 0.0 if _tween != null: @@ -54,8 +80,46 @@ func set_open(on: bool, animate: bool = true) -> void: else: _hinge.rotation.y = target +## A blow from something trying to get in. Returns "hit", "held" (iron +## bands), "broke" (this blow broke it) or "" (open or already broken). +func bash(damage: float) -> String: + if not blocks(): + return "" + if sturdy(): + return "held" + door_hp -= damage + if door_hp > 0.0: + return "hit" + set_broken(true) + return "broke" + +## Broken: the panel hangs open, askew, and stops anything. Repaired: shut +## and whole again. +func set_broken(on: bool) -> void: + broken = on + if on: + door_hp = 0.0 + set_open(true, false) + _hinge.rotation = Vector3(0.0, OPEN_ANGLE + 0.35, 0.18) + _panel_shape.disabled = true + else: + door_hp = DOOR_HP + _hinge.rotation = Vector3.ZERO + _panel_shape.disabled = false + set_open(false, false) + func save_extra() -> Dictionary: - return {"open": open} + return {"open": open, "broken": broken} func load_extra(d: Dictionary) -> void: - set_open(bool(d.get("open", false)), false) + set_broken(bool(d.get("broken", false))) + if not broken: + set_open(bool(d.get("open", false)), false) + +## A server telling us this door's state: swing it if it only opened or +## closed, snap it if it broke or was mended. +func net_state(d: Dictionary) -> void: + if bool(d.get("broken", false)) != broken: + load_extra(d) + elif not broken: + set_open(bool(d.get("open", false))) diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 4f4fb59..bd026d6 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -305,6 +305,22 @@ func toggle_door(player: Node3D, door: Node3D) -> bool: door.set_open(not door.open) return true +## E on a door an alpha broke: mend it (two wood), shut. +func repair_door(player: Node3D, door: Node3D) -> bool: + if not is_instance_valid(door) or not door.get("broken") or not _in_reach(player, door, 1.0): + return false + if _online(): + return _ask("door_repair", [WorldNet.nid(door)]) + for id in door.REPAIR_COST: + if player.inventory.count(id) < int(door.REPAIR_COST[id]): + tell(player, "Need %d %s to mend the door" % [int(door.REPAIR_COST[id]), ItemDB.item_name(id).to_lower()]) + return false + for id in door.REPAIR_COST: + player.inventory.remove(id, int(door.REPAIR_COST[id])) + door.set_broken(false) + tell(player, "Door mended") + return true + ## E on a bed: claim it as your spawn point, or (if it's already yours) sleep. func use_bed(player: Node3D, bed: Node3D) -> bool: if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0): diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index b30b54f..ab75bda 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -39,6 +39,13 @@ const KINDS := { "notice": 0.0, "notice_loud": 0.0, "calm": 40.0, "damage": 9.0, "attack_range": 1.8, "attack_cd": 1.2, "drops": {"raw_meat": [1, 2], "wolf_pelt": [1, 1]}}, + # The alpha: leads the pack on a wolf moon (wolf_pack.gd). Three wolves' + # worth of health, bites harder, isn't afraid of fire and breaks down + # wooden doors (wolf.gd). + "alpha": {"name": "Alpha Wolf", "hp": 135.0, "walk": 1.6, "run": 8.2, "turn": 5.0, + "notice": 0.0, "notice_loud": 0.0, "calm": 40.0, + "damage": 16.0, "attack_range": 2.1, "attack_cd": 1.4, "door_damage": 22.0, + "drops": {"raw_meat": [2, 3], "alpha_pelt": [1, 1], "wolf_fang": [1, 2]}}, } const CARCASS_SECONDS := 300.0 @@ -105,9 +112,10 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: var eyes := MeshInstance3D.new() eyes.mesh = parts.eyes var em := StandardMaterial3D.new() - em.albedo_color = Color(1.0, 0.85, 0.4) + var red := k == "alpha" + em.albedo_color = Color(1.0, 0.3, 0.2) if red else Color(1.0, 0.85, 0.4) em.emission_enabled = true - em.emission = Color(1.0, 0.75, 0.3) + em.emission = Color(1.0, 0.18, 0.1) if red else Color(1.0, 0.75, 0.3) em.emission_energy_multiplier = 2.5 eyes.material_override = em eyes.set_meta("keep_mat", true) @@ -129,7 +137,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: cs.position = Vector3(0, parts.height * 0.5, 0) if k != "rabbit": # Long bodies: lay the capsule along the spine. - cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25}.get(k, 1.4) + cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65}.get(k, 1.4) cs.rotation.x = PI * 0.5 cs.position = Vector3(0, parts.height * 0.55, 0) add_child(cs) diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd index eeda8b4..033de66 100644 --- a/scripts/creatures/creature_meshes.gd +++ b/scripts/creatures/creature_meshes.gd @@ -18,6 +18,7 @@ static func build(kind: String) -> Dictionary: "rabbit": d = _rabbit() "deer": d = _deer() "wolf": d = _wolf() + "alpha": d = _wolf(true) _: d = _boar() _cache[kind] = d return d @@ -100,13 +101,42 @@ static func _boar() -> Dictionary: ## Grey wolf: lean body, long snout, pointed ears, bushy tail. "eyes" is a ## separate mesh so it can glow (creature.gd gives it an emissive material). -static func _wolf() -> Dictionary: - var coat := Color(0.42, 0.42, 0.44) - var pale := Color(0.72, 0.71, 0.68) +## `alpha`: a third bigger, near-black with a grizzled ruff and a scarred +## muzzle (wolf moon nights). +static func _wolf(alpha: bool = false) -> Dictionary: + if alpha: + return _scaled(_wolf_parts(Color(0.17, 0.16, 0.17), Color(0.5, 0.48, 0.45), 241), 1.32) + return _wolf_parts(Color(0.42, 0.42, 0.44), Color(0.72, 0.71, 0.68), 231) + +## The same parts scaled up `k` times (body, eyes and leg meshes, hips, radius, height). +static func _scaled(d: Dictionary, k: float) -> Dictionary: + var out := d.duplicate() + for key in ["body", "eyes", "leg"]: + if d.has(key): + out[key] = _scale_mesh(d[key], k) + var hips: Array = [] + for h: Vector3 in d.hips: + hips.append(h * k) + out.hips = hips + out.radius = float(d.radius) * k + out.height = float(d.height) * k + return out + +static func _scale_mesh(m: ArrayMesh, k: float) -> ArrayMesh: + var arrays := m.surface_get_arrays(0) + var v: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + for i in range(v.size()): + v[i] *= k + arrays[Mesh.ARRAY_VERTEX] = v + var out := ArrayMesh.new() + out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return out + +static func _wolf_parts(coat: Color, pale: Color, seed: int) -> Dictionary: var dark := Color(0.16, 0.15, 0.15) var st := P._begin() - var r := _rng(231) - var n := _noise(231, 1.5) + var r := _rng(seed) + var n := _noise(seed, 1.5) P._blob(st, Vector3(0, 0.7, 0.05), Vector3(0.22, 0.24, 0.55), n, 0.15, coat, r, 1) P._blob(st, Vector3(0, 0.8, -0.32), Vector3(0.24, 0.26, 0.3), n, 0.25, P._mul(coat, 1.08), r, 1) # ruff P._blob(st, Vector3(0, 0.6, -0.1), Vector3(0.16, 0.12, 0.35), n, 0.1, pale, r, 1) # belly @@ -120,6 +150,6 @@ static func _wolf() -> Dictionary: var est := P._begin() for s in [-1.0, 1.0]: P._blob(est, Vector3(s * 0.065, 0.97, -0.75), Vector3.ONE * 0.022, n, 0.0, Color(1.0, 0.8, 0.3), r, 0) - return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.52, 0.045, 0.03, coat, dark, 232), + return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.52, 0.045, 0.03, coat, dark, seed + 1), "hips": [Vector3(-0.11, 0.52, -0.38), Vector3(0.11, 0.52, -0.38), Vector3(-0.11, 0.54, 0.38), Vector3(0.11, 0.54, 0.38)], "radius": 0.32, "height": 1.0} diff --git a/scripts/creatures/wolf.gd b/scripts/creatures/wolf.gd index ff95302..f22f743 100644 --- a/scripts/creatures/wolf.gd +++ b/scripts/creatures/wolf.gd @@ -13,22 +13,32 @@ extends "res://scripts/creatures/creature.gd" ## is safe. ## - A wolf on its own is timid: it keeps its distance and backs off. ## - A badly hurt wolf runs, and comes back another night. +## +## The alpha (kind "alpha", wolf moon nights only) is different: it isn't +## afraid of fire, it doesn't take turns, and if it can't get at you it +## goes for your door. A wooden door gives way after a few blows (door.gd); +## an iron-banded one holds. It doesn't run when hurt. var pack: Node var slot := 0 var wounded := false ## ran off hurt: stays away until dawn +func is_alpha() -> bool: + return kind == "alpha" + func display_name() -> String: - return "Wolf" + return def.name func interact_prompt(p: Node) -> String: + var nm: String = def.name if is_dead(): - return "Wolf carcass [E] Butcher" - var s := "Wolf" + return "%s carcass [E] Butcher" % nm + var s := nm match state: - "rest": s = "Wolf (resting)" - "alert", "stalk": s = "Wolf (watching you)" - "lunge", "defend": s = "Wolf (attacking!)" + "rest": s = nm + " (resting)" + "alert", "stalk": s = nm + " (watching you)" + "lunge", "defend": s = nm + " (attacking!)" + "bash": s = nm + " (breaking a door!)" if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"): s += " (you need a weapon)" return s @@ -54,7 +64,9 @@ func _think(dt: float) -> void: "den": _think_den(pp, d) "hunt": - if _aggro > 0.0 and d < 25.0: + if is_alpha(): + _think_alpha(dt, pp, d) + elif _aggro > 0.0 and d < 25.0: _attack(pp, d, "defend") elif pack.alive_count() <= 1: _think_alone(pp, d) @@ -105,6 +117,85 @@ func _think_hunt(pp: Vector3, d: float) -> void: state = "lunge" _state_t = 4.0 +# --- the alpha ---------------------------------------------------------------- + +const BASH_STAND := 1.5 ## stands this far out from a door to hit it +const BASH_REACH := 2.4 +const DOOR_SEARCH := 12.0 ## doors within this of the target are worth trying + +var _door: Node3D = null ## the door it's working on +var _bash_at := Vector3.ZERO +var _door_look := 0.0 +var _entry: Node3D = null ## an open doorway it's going in through +var _entry_out := Vector3.ZERO +var _entry_in := Vector3.ZERO +var _entry_stage := 0 ## 0: to the outside of it, 1: through it + +## Straight at you if nothing's in the way (fire or no fire). Otherwise the +## nearest closed door near you, and through it. Otherwise prowl round. +func _think_alpha(dt: float, pp: Vector3, d: float) -> void: + if player.survival.dead: + state = "stalk" + _go_to(pack.circle_point(self)) + return + if d < 25.0 and _can_reach_player(): + _door = null + _attack(pp, d, "defend") + return + _door_look -= dt + if _door != null and (not is_instance_valid(_door) or _door.is_queued_for_deletion() or not _door.blocks()): + _door = null + if _door == null and _door_look <= 0.0: + _door_look = 1.0 + _door = pack.door_for(self, player, DOOR_SEARCH) + if _door != null: + # Stand on the side of the door the alpha is on (the outside). + var n: Vector3 = _door.global_basis.z + var side := 1.0 if (global_position - _door.global_position).dot(n) > 0.0 else -1.0 + _bash_at = _door.global_position + n * side * BASH_STAND + if _door == null: + # No shut door left: in through an open (or broken) one, if any. + if _entry == null or not is_instance_valid(_entry) or _entry.blocks(): + _entry = pack.door_for(self, player, DOOR_SEARCH, true) + if _entry != null: + var n: Vector3 = _entry.global_basis.z + var side := 1.0 if (global_position - _entry.global_position).dot(n) > 0.0 else -1.0 + _entry_out = _entry.global_position + n * side * BASH_STAND + _entry_in = _entry.global_position - n * side * 1.2 + _entry_stage = 0 + if _entry != null: + state = "bash" # (on its way in: shown the same) + var goal: Vector3 = _entry_out if _entry_stage == 0 else _entry_in + if Vector2(goal.x - global_position.x, goal.z - global_position.z).length() < 0.5: + _entry_stage += 1 + if _entry_stage > 1: + _entry = null # inside: next think it can reach you + return + _go_to(goal) + return + state = "stalk" + _go_to(pack.circle_point(self)) + return + state = "bash" + var flat := Vector2(_bash_at.x - global_position.x, _bash_at.z - global_position.z).length() + if flat > 0.6: + _go_to(_bash_at) + return + _goal_d = 0.0 + var to := _door.global_position - global_position + steer(atan2(-to.x, -to.z)) + if _attack_cd <= 0.0 and Vector2(to.x, to.z).length() < BASH_REACH: + _attack_cd = def.attack_cd + pack.bash_door(self, _door, float(def.get("door_damage", def.damage))) + +## (Near the door it's battering, the door itself isn't an obstacle.) +func _blocked(dir: Vector3) -> bool: + if state == "bash" and _door != null and global_position.distance_to(_bash_at) < 2.5: + return false + if state == "bash" and _entry != null and is_instance_valid(_entry) and global_position.distance_to(_entry.global_position) < 2.8: + return false # (lined up with the doorway: walking through it) + return super._blocked(dir) + ## The last wolf of a pack keeps its distance and won't start a fight. func _think_alone(pp: Vector3, d: float) -> void: if d < 10.0: @@ -151,12 +242,16 @@ func _desired_speed() -> float: "home": return 2.2 if _goal_d < 8.0 else 6.0 "stalk": return 0.0 if _goal_d < 1.0 else (3.4 if _goal_d < 7.0 else 7.5) "lunge", "defend": return def.run + "bash": return 0.0 if _goal_d < 0.6 else (3.0 if _goal_d < 4.0 else def.run) "retreat": return 6.0 "flee": return def.run return 0.0 func _react(from: Vector3) -> void: pack.alert(from) + if is_alpha(): + _aggro = 20.0 # (never runs) + return if hp < def.hp * 0.4: wounded = true _threat = from diff --git a/scripts/creatures/wolf_pack.gd b/scripts/creatures/wolf_pack.gd index 1b5c493..4cd745c 100644 --- a/scripts/creatures/wolf_pack.gd +++ b/scripts/creatures/wolf_pack.gd @@ -15,8 +15,13 @@ extends Node3D ## While hunting the pack circles you and lets one wolf at a time dart in ## (every few seconds, slower if you hold a torch). Near a lit campfire it ## circles wide around the fire and never closes in. +## +## Every 7th night is a wolf moon (day_night.gd turns the moon red): an +## alpha comes down from the hills with the pack. It leaves at dawn whether +## or not you killed it, and comes back the next wolf moon (wolf.gd). const WolfScript = preload("res://scripts/creatures/wolf.gd") +const DayNight = preload("res://scripts/world/day_night.gd") const DEN_DAY := 3 ## wolves arrive in the den const HUNT_DAY := 4 ## first night they hunt @@ -42,6 +47,14 @@ var _warned := {} ## day -> true once that night's toast was shown var _howl_left := 0.0 var _audio: AudioStreamPlayer3D static var _howl: AudioStreamWAV +var alpha: Node = null ## tonight's alpha (wolf moon) +var _alpha_day := -1 ## the night it came +var _told := {} ## "door id:outcome" -> true: door toasts already shown + +## Night `day` is a wolf moon (once the pack hunts at all). The same rule as +## day_night.gd's red moon. +func wolf_moon(day: int) -> bool: + return day >= hunt_day and DayNight.wolf_moon_day(day) func setup(w: Node3D, d: Node3D) -> void: world = w @@ -139,6 +152,10 @@ func step(dt: float) -> void: func _enter(m: String, day: int) -> void: mode = m + if m == "hunt" and wolf_moon(day) and _alpha_day != day: + spawn_alpha(day) + elif m != "hunt": + _alpha_leaves() if m == "none": for w in members: if is_instance_valid(w): @@ -160,6 +177,8 @@ func _warn(day: int) -> void: Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.") elif day == hunt_day: Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.") + elif wolf_moon(day): + Actions.tell_all("The moon is red: a wolf moon. Something big is hunting with the pack tonight.") _howl_left = _rng.randf_range(2.0, 6.0) ## Brings the pack up to size, wolves lying in the den. `force` ignores @@ -189,6 +208,65 @@ func fill_den(force: bool) -> void: need -= 1 i += 1 +## The alpha comes out of the den's mouth with the pack. +func spawn_alpha(day: int) -> Node: + _alpha_leaves() + _alpha_day = day + var w: AnimatableBody3D = WolfScript.new() + w.setup("alpha", world, den.mouth_pos(), _rng.randi()) + w.pack = self + w.slot = 4 + w.state = "stalk" + w.yaw = den_yaw + w.rotation.y = w.yaw + add_child(w) + members.append(w) + alpha = w + return w + +## At dawn it goes back into the hills (a carcass stays for butchering). +func _alpha_leaves() -> void: + if alpha != null and is_instance_valid(alpha) and not alpha.is_dead(): + members.erase(alpha) + alpha.queue_free() + alpha = null + +func alpha_alive() -> bool: + return alpha != null and is_instance_valid(alpha) and not alpha.is_dead() + +## The door the alpha `w` should go for to get at `target`: the closed door +## nearest `target` (within `r`), a little biased toward ones nearer `w`. +## `open_ones`: the nearest open or broken door instead (a way in). +func door_for(w: Node3D, target: Node3D, r: float, open_ones: bool = false) -> Node3D: + var best: Node3D = null + var best_s := INF + var tp: Vector3 = target.global_position + for p in world.build.build_pieces(): + if not p.has_method("bash") or p.blocks() == open_ones: + continue + var d: float = p.global_position.distance_to(tp) + if d > r: + continue + var score: float = d + 0.3 * p.global_position.distance_to(w.global_position) + if score < best_s: + best_s = score + best = p + return best + +## The alpha hits `door`. Tells the player it's after (once per door and +## outcome); on a server, every game hears the door broke. +func bash_door(w: Node, door: Node3D, damage: float) -> void: + var res: String = door.bash(damage) + var key := "%d:%s" % [door.get_instance_id(), res] + if not _told.has(key): + _told[key] = true + match res: + "hit": Actions.tell(w.player, "Something is battering at your door!") + "held": Actions.tell(w.player, "Something is battering at your door, but the iron bands hold.") + "broke": Actions.tell(w.player, "The alpha has smashed your door in!") + if res == "broke" and world.server_mode: + WorldNet.piece_state(door) + func home_slot(w: Node) -> Vector3: return den.slot_pos(w.slot) @@ -320,8 +398,9 @@ func howl() -> void: return if _audio == null: return - _audio.global_position = (live[0] as Node3D).global_position + Vector3(0, 1, 0) - _audio.pitch_scale = _rng.randf_range(0.9, 1.1) + var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper) + _audio.global_position = from.global_position + Vector3(0, 1, 0) + _audio.pitch_scale = _rng.randf_range(0.68, 0.76) if alpha_alive() else _rng.randf_range(0.9, 1.1) _audio.play() ## A synthesised wolf howl: a rising then falling tone with vibrato and a diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 779ce16..355e549 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -106,6 +106,12 @@ static func build(key: String) -> ArrayMesh: "palisade": m = _palisade() "palisade_gate": m = _palisade_gate(true) "palisade_gate_frame": m = _palisade_gate(false) + "iron_door": m = _iron_door(true) + "iron_door_frame": m = _door(false) + "iron_door_panel": m = _iron_door(false) + "alpha_pelt": m = _pelt(ALPHA_FUR) + "alpha_cloak": m = _cloak(ALPHA_FUR) + "wolf_fang": m = _fang() "berry_bush": m = _berry_bush(true) "berry_bush_bare": m = _berry_bush(false) _: @@ -937,3 +943,39 @@ static func _palisade_gate(with_panel: bool) -> ArrayMesh: if with_panel: _door_panel_into(st, Vector3(-0.54, 0, 0)) return P._commit(st) + +# --- P3 the alpha ------------------------------------------------------------------ + +const ALPHA_FUR := Color(0.17, 0.16, 0.17) + +## Iron-banded door: the wooden leaf with three iron straps and studs. +## `in_frame` = the closed door in a wood frame (icon and ghost); otherwise +## just the leaf, hinge at the origin. +static func _iron_door(in_frame: bool) -> ArrayMesh: + var st := P._begin() + var o := Vector3.ZERO + if in_frame: + var r := _rng(79) + for side in [-1.0, 1.0]: + _wall_body(st, r, side * 0.55 if side > 0 else -0.84, 0.84 if side > 0 else -0.55, 0.1, 1.9) + _box(st, Vector3(side * 0.92, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM) + _box(st, Vector3(side * 0.6, 0.95, 0), Vector3(0.1, 1.9, 0.2), BEAM) + _box(st, Vector3(0, 1.95, 0), Vector3(1.68, 0.1, 0.2), BEAM) + o = Vector3(-0.54, 0, 0) + var rr := _rng(251) + for i in range(4): + _box(st, o + Vector3(0.135 + 0.27 * i, 0.95, 0), Vector3(0.26, 1.86, 0.08), _board(rr, PLANK_DARK)) + for y in [0.3, 0.95, 1.6]: + for z in [0.05, -0.05]: + _box(st, o + Vector3(0.54, y, z), Vector3(1.04, 0.1, 0.02), IRON) + for i in range(4): + _box(st, o + Vector3(0.14 + 0.27 * i, y, z * 1.4), Vector3(0.03, 0.03, 0.02), IRON_BRIGHT) + _box(st, o + Vector3(0.95, 1.0, 0.08), Vector3(0.06, 0.16, 0.05), IRON_BRIGHT) + _box(st, o + Vector3(0.95, 1.0, -0.08), Vector3(0.06, 0.16, 0.05), IRON_BRIGHT) + return P._commit(st) + +static func _fang() -> ArrayMesh: + var st := P._begin() + var r := _rng(253) + P._cylinder(st, Vector3(0, 0.02, 0), Vector3(0.02, 0.02, -0.12), 0.022, 0.003, 6, Color(0.88, 0.82, 0.62), r, -1.0, Color(0.6, 0.45, 0.35)) + return P._commit(st) diff --git a/scripts/net/creature_net.gd b/scripts/net/creature_net.gd index 9c060b4..adb2b98 100644 --- a/scripts/net/creature_net.gd +++ b/scripts/net/creature_net.gd @@ -95,8 +95,8 @@ func _server_tick(w: Node3D, delta: float) -> void: const PER_PACKET := 40 const ENTRY := 23 -const KIND_IDS := ["rabbit", "deer", "boar", "wolf"] -const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend"] +const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha"] +const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash"] ## One creature in 23 bytes: id, kind, state, position, facing, speed, health. static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void: diff --git a/scripts/net/net.gd b/scripts/net/net.gd index ba2f972..ae99807 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") -const PROTOCOL := 6 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6) +const PROTOCOL := 7 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors (P3) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 95b569c..f3a39c1 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -324,6 +324,11 @@ func _run(p: Node3D, what: String, a: Array) -> void: if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \ and Actions.toggle_door(p, d): WorldNet.piece_state(d) + "door_repair": + var d := WorldNet.piece(_i(a, 0)) + if d != null and d.get("broken") != null and _allowed(Rules.use_problem(p.player_id, d)) \ + and Actions.repair_door(p, d): + WorldNet.piece_state(d) "fuel": var f := WorldNet.piece(_i(a, 0)) if f != null and f.get("fuel") != null and Actions.add_fuel(p, f): @@ -421,7 +426,7 @@ func _run(p: Node3D, what: String, a: Array) -> void: for m in w.get_tree().get_nodes_in_group("processor"): m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0) "debug_spawn": - if cheats and Creature.KINDS.has(_s(a, 0)) and _s(a, 0) != "wolf" and _v(a, 1).is_finite(): + if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha"]) and _v(a, 1).is_finite(): w.creatures.spawn(_s(a, 0), _v(a, 1)) "debug_time": if cheats: diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index 1cf9110..1ed5355 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -427,8 +427,8 @@ func _s2c_piece_state(nid_: int, extra: Dictionary) -> void: var p: Node3D = pieces.get(nid_) if p == null or not is_instance_valid(p): return - if extra.has("open") and p.has_method("set_open"): - p.set_open(bool(extra.open)) + if p.has_method("net_state"): + p.net_state(extra) # (a door) elif p.has_method("load_extra"): p.load_extra(extra) received += 1 diff --git a/scripts/world/day_night.gd b/scripts/world/day_night.gd index acf9751..52efeeb 100644 --- a/scripts/world/day_night.gd +++ b/scripts/world/day_night.gd @@ -57,6 +57,17 @@ func clock_text() -> String: func is_night() -> bool: return daylight < 0.3 +## Every 7th night is a wolf moon: the moon turns red and an alpha hunts with +## the pack (wolf_pack.gd). Worked out from the day alone, so every game on a +## server agrees without being told. +static func wolf_moon_day(d: int) -> bool: + return d >= 7 and d % 7 == 0 + +## Tonight's moon is a wolf moon (the small hours count as the night before). +func wolf_moon_now() -> bool: + var h := hours() + return (h >= 19.5 or h < 5.5) and wolf_moon_day(day - (1 if h < 5.5 else 0)) + func advance(seconds: float) -> void: time += seconds / day_length while time >= 1.0: @@ -103,7 +114,9 @@ func apply(force: bool) -> void: var moon_dir := Vector3(-to_sun.x, absf(to_sun.y) * 0.8 + 0.25, -to_sun.z * 0.5).normalized() moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD) - moon.light_energy = 0.38 * (1.0 - daylight) * (1.0 - 0.6 * overcast) + var red := wolf_moon_now() + moon.light_color = Color(1.0, 0.38, 0.3) if red else Color(0.55, 0.65, 1.0) + moon.light_energy = (0.5 if red else 0.38) * (1.0 - daylight) * (1.0 - 0.6 * overcast) moon.visible = moon.light_energy > 0.01 moon.shadow_enabled = moon.visible and not sun.visible @@ -121,7 +134,10 @@ func apply(force: bool) -> void: for i in range(3): var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight * (1.0 - overcast * 0.7)) dayc = dayc.lerp(OVERCAST_COLS[i], overcast * 0.85) - cols.append(NIGHT_COLS[i].lerp(dayc, daylight)) + var night: Color = NIGHT_COLS[i] + if red: + night = night.lerp(Color(0.2, 0.04, 0.04), 0.5) # a blood-red night sky + cols.append(night.lerp(dayc, daylight)) env.fog_light_color = cols[2] if sky_mat != null: sky_mat.set_shader_parameter("zenith_col", cols[0]) diff --git a/tests/run_all.sh b/tests/run_all.sh index 330f815..2319dca 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_alpha.gd b/tests/test_alpha.gd new file mode 100644 index 0000000..f762df5 --- /dev/null +++ b/tests/test_alpha.gd @@ -0,0 +1,265 @@ +extends SceneTree +## P3 the alpha (docs/PROGRESSION_DESIGN.md §4): the wolf moon every 7th +## night, the alpha that hunts with the pack, wooden doors it breaks and +## iron-banded ones it can't, mending a door, and what the alpha drops. +## Run: godot --headless --path . --script res://tests/test_alpha.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var _toasts: Array[String] = [] +var world: Node3D +var player: CharacterBody3D +var pack: Node + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + A.notify.connect(func(t): _toasts.append(t)) + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + S.current_id = S.create_world("Alpha", 1337) + await _load() + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.id) + DirAccess.remove_absolute(S.root_dir) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _load() -> void: + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + world.creatures.enabled = false + for c in world.creatures.alive(): + c.queue_free() + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + pack = world.wolves + pack.enabled = false + +func _settle() -> void: + for _i in range(3): + await physics_frame + +func _set_time(day: int, hours: float) -> void: + world.day_night.day = day + world.day_night.time = hours / 24.0 + world.day_night.apply(true) + +## Runs the pack and every wolf for up to `secs`, stopping early once +## `until` (if given) returns true. Returns the seconds it took. +func _sim(secs: float, until: Callable = Callable()) -> float: + var t := 0.0 + while t < secs: + pack.step(0.1) + for w in pack.alive(): + w.set_physics_process(false) + w.step(0.1) + t += 0.1 + if until.is_valid() and until.call(): + break + if int(t * 10) % 10 == 0: + await physics_frame # (let the physics server catch up with moved bodies) + return t + +func _toast_has(s: String) -> bool: + for t in _toasts: + if t.contains(s): + return true + return false + +func _body() -> void: + _moon() + await _arrives() + await _doors() + await _dawn() + _drops() + await _saving() + _finished = true + +# --- the wolf moon ------------------------------------------------------------------- + +func _moon() -> void: + print("the wolf moon") + var DN: Script = load("res://scripts/world/day_night.gd") + check(DN.wolf_moon_day(7) and DN.wolf_moon_day(14) and DN.wolf_moon_day(21), "nights 7, 14 and 21 are wolf moons") + check(not DN.wolf_moon_day(6) and not DN.wolf_moon_day(8) and not DN.wolf_moon_day(0), "6, 8 (and day 0) aren't") + var dn: Node = world.day_night + _set_time(7, 22.0) + check(dn.wolf_moon_now() and dn.moon.light_color.r > dn.moon.light_color.b * 2.0, "on night 7 the moon is red (%s)" % dn.moon.light_color) + _set_time(8, 3.0) + check(dn.wolf_moon_now(), "still red in the small hours of day 8") + _set_time(7, 12.0) + check(not dn.wolf_moon_now(), "not by day") + _set_time(8, 22.0) + check(not dn.wolf_moon_now() and dn.moon.light_color.b > dn.moon.light_color.r, "night 8 is an ordinary moon") + check(pack.wolf_moon(7) and not pack.wolf_moon(8), "the pack keeps the same calendar") + +# --- the alpha comes ------------------------------------------------------------------- + +func _arrives() -> void: + print("the alpha") + _set_time(7, 12.0) + await _sim(2.0) + check(pack.mode == "den" and not pack.alpha_alive(), "day 7: the pack rests, no alpha yet") + _toasts.clear() + _set_time(7, 21.0) + await _sim(1.0) + check(pack.mode == "hunt" and pack.alpha_alive(), "night 7: an alpha hunts with the pack") + var al: Node = pack.alpha + check(al.kind == "alpha" and absf(al.hp - 3.0 * al.KINDS.wolf.hp) < 0.01, "with three times a wolf's health (%.0f)" % al.hp) + check(_toast_has("wolf moon"), "a toast warns you (%s)" % [_toasts]) + check(pack.alive().has(al) and pack.alive_count() == pack.pack_size(7) + 1, "it runs with the pack (%d wolves and the alpha)" % pack.pack_size(7)) + al.take_hit(100.0, al.global_position + Vector3(3, 0, 0)) + check(al.state != "flee" and not al.wounded, "badly hurt, it doesn't run (%s)" % al.state) + al.hp = al.def.hp + +# --- doors ------------------------------------------------------------------------------ + +var hut: Dictionary +var door: Node3D +var door_xf: Transform3D +var inside: Vector3 + +func _doors() -> void: + print("a wooden door") + var bs: Node = world.build + var yaw: float = player.rotation.y + var b := Basis(Vector3.UP, yaw) + var site: Vector3 = bs.debug_find_site(player.global_position - player.global_basis.z * 14.0, 3.0) + hut = await bs.debug_build_hut(site, yaw) + await _settle() + check(hut.placed == 20, "a closed hut (%d pieces, %s)" % [hut.placed, hut.failed]) + for p in hut.pieces: + if p.item_id == "wood_door": + door = p + door_xf = door.global_transform + var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) + inside = center + b * Vector3(-0.6, 0.05, -0.6) + player.global_position = inside + var fire: Node3D = world.spawn_piece(DB.get_item("campfire"), Transform3D(b, center + b * Vector3(0.6, 0.0, -0.8))) + fire.fuel = fire.MAX_FUEL + await _settle() + var al: Node3D = pack.alpha + var out_front: Vector3 = door.global_position + door.global_basis.z * 7.0 + out_front.y = world.terrain.sample_height(out_front.x, out_front.z) + al.global_position = out_front + for w in pack.alive(): + if w != al: + w.global_position = out_front + Vector3(4, 0, 4) + _toasts.clear() + check(pack.fire_near(player) != null, "you're inside by a fire (wolves won't close in)") + var took: float = await _sim(45.0, func(): return door.broken) + check(door.broken and not door.blocks(), "the alpha breaks the door down anyway (%.0f s)" % took) + check(_toast_has("battering") and _toast_has("smashed"), "and you hear it coming (%s)" % [_toasts]) + var space := world.get_world_3d().direct_space_state + var q := PhysicsRayQueryParameters3D.create(door.global_transform * Vector3(0, 1.0, 1.5), door.global_transform * Vector3(0, 1.0, -1.5), 1) + check(space.intersect_ray(q).is_empty(), "the doorway is open") + check(door.interact_prompt(player).begins_with("Broken door"), "it shows as broken: \"%s\"" % door.interact_prompt(player)) + check(door.bash(50.0) == "", "(a broken door takes no more blows)") + var hp0: float = player.survival.health + var got_in: float = await _sim(20.0, func(): return player.survival.health < hp0) + check(player.survival.health < hp0, "then it comes in through the doorway and bites (%.0f s, health %.0f)" % [got_in, player.survival.health]) + player.survival.health = 100.0 + al.global_position = out_front + al._entry = null + print("mending it") + player.global_position = door.global_transform * Vector3(0.0, 0.05, -1.0) + player.inventory.load_data(Inventory.new(40).to_data()) + check(not A.repair_door(player, door) and door.broken, "no wood, no repair") + player.inventory.add("wood", 3) + check(A.repair_door(player, door) and not door.broken and door.blocks(), "two wood and it's shut and whole again") + check(player.inventory.count("wood") == 1 and door.door_hp == door.DOOR_HP, "(for 2 wood; back to full strength)") + player.global_position = inside + + print("an iron-banded door") + player.inventory.add("iron_bar", 2) + player.inventory.add("wood", 4) + player.inventory.add("nails", 4) + var forge: Node3D = world.spawn_piece(DB.get_item("forge"), Transform3D(b, center + b * Vector3(-5.0, 0, 0))) + player.global_position = forge.global_position + b * Vector3(0, 0, 1.5) + check(A.craft(player, "iron_door"), "made at the forge (2 iron, 4 wood, 4 nails)") + world.remove_piece(forge) + world.remove_piece(door) + await _settle() + var idef: Dictionary = DB.get_item("iron_door") + check(bs.check(idef, door_xf) == "", "it goes where the wooden one was") + door = world.spawn_piece(idef, door_xf) + player.global_position = inside + await _settle() + al.global_position = out_front + al._door = null + _toasts.clear() + await _sim(30.0, func(): return door.broken) + check(not door.broken and door.blocks(), "30 s of the alpha's blows and it holds") + check(_toast_has("iron bands hold"), "you hear it holding (%s)" % [_toasts]) + check(player.survival.health > 99.0, "and the alpha never got at you (health %.0f)" % player.survival.health) + world.remove_piece(fire) + +func _dawn() -> void: + print("dawn") + var al: Node = pack.alpha + _set_time(8, 6.5) + await _sim(1.0) + check(pack.mode == "den" and not pack.alpha_alive() and not pack.members.has(al), "at dawn the alpha goes back into the hills") + _set_time(8, 21.0) + await _sim(1.0) + check(pack.mode == "hunt" and not pack.alpha_alive(), "night 8: just the wolves") + +func _drops() -> void: + print("its pelt") + var al: Node = pack.spawn_alpha(14) + al.take_hit(500.0, al.global_position + Vector3(2, 0, 0)) + check(al.is_dead(), "an alpha can be killed") + var got: Dictionary = al.butcher_drops() + check(int(got.get("alpha_pelt", 0)) == 1 and int(got.get("wolf_fang", 0)) >= 1 and int(got.get("raw_meat", 0)) >= 2, + "butchered: an alpha pelt, fangs and meat (%s)" % [got]) + check(int(DB.get_item("alpha_cloak").wear.warmth) > int(DB.get_item("wolf_cloak").wear.warmth), "the alpha cloak is the warmest thing to wear") + check(DB.recipes.has("alpha_cloak") and DB.recipes.alpha_cloak.inputs.has("alpha_pelt"), "made from its pelt") + print("online") + var CN: Node = root.get_node("CreatureNet") + al.set_meta("cid", 77) + al.state = "bash" + var buf := PackedByteArray() + buf.resize(CN.ENTRY) + CN._encode(buf, 0, al) + var e: Array = CN._decode(buf, 0) + check(e[1] == "alpha" and e[4] == "bash", "it goes over the network as an alpha (%s, %s)" % [e[1], e[4]]) + al.queue_free() + +func _saving() -> void: + print("saving") + door.queue_free() + await _settle() + var wd: Node3D = world.spawn_piece(DB.get_item("wood_door"), door_xf) + await _settle() + check(wd.bash(250.0) == "broke", "a wooden door, broken") + check(world.save_now(), "saved") + world.queue_free() + await process_frame + await _load() + var back: Node3D = null + for p in world.all_pieces(): + if p.item_id == "wood_door" and p.global_position.distance_to(door_xf.origin) < 0.05: + back = p + check(back != null and back.broken and not back.blocks(), "comes back broken")