From 3389ec9a217d3439c3507384e6b9f97ed9d27c75 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 11:49:13 +0000 Subject: [PATCH] P4 hazards: food spoilage with rot speed setting, smoking rack and salt, food poisoning, harsher blizzards, hard winters with lake ice, lightning on trees, snow on roofs (protocol 11) --- data/items.json | 131 ++++++++++-- data/recipes.json | 45 ++++ docs/PROGRESSION_DESIGN.md | 32 ++- docs/ROADMAP.md | 1 + scripts/building/processor.gd | 21 +- scripts/core/actions.gd | 10 + scripts/core/inventory.gd | 129 +++++++++++- scripts/core/saves.gd | 11 +- scripts/creatures/spawner.gd | 9 +- scripts/items/item_meshes.gd | 43 ++++ scripts/net/net.gd | 2 +- scripts/net/player_net.gd | 4 +- scripts/net/rules.gd | 5 + scripts/net/server_config.gd | 4 +- scripts/net/world_net.gd | 15 ++ scripts/player/survival.gd | 20 +- scripts/ui/slot.gd | 18 ++ scripts/ui/title_screen.gd | 13 +- scripts/world/hazards.gd | 288 ++++++++++++++++++++++++++ scripts/world/props.gd | 9 +- scripts/world/weather.gd | 52 ++++- scripts/world/world.gd | 65 +++++- tests/run_all.sh | 2 +- tests/test_creatures_combat.gd | 1 + tests/test_food.gd | 365 +++++++++++++++++++++++++++++++++ tests/test_hazards.gd | 289 ++++++++++++++++++++++++++ tests/test_weather_building.gd | 13 +- 27 files changed, 1541 insertions(+), 56 deletions(-) create mode 100644 scripts/world/hazards.gd create mode 100644 tests/test_food.gd create mode 100644 tests/test_hazards.gd diff --git a/data/items.json b/data/items.json index 030bcc0..57fc4b4 100644 --- a/data/items.json +++ b/data/items.json @@ -163,7 +163,8 @@ "diet": { "plants": 14 } - } + }, + "keeps": 3 }, { "id": "mushroom", @@ -178,7 +179,8 @@ "diet": { "plants": 10 } - } + }, + "keeps": 4 }, { "id": "roasted_mushroom", @@ -193,7 +195,8 @@ "diet": { "plants": 30 } - } + }, + "keeps": 6 }, { "id": "torch", @@ -311,8 +314,10 @@ "health": -4, "diet": { "meat": 8 - } - } + }, + "risk": 0.35 + }, + "keeps": 2 }, { "id": "cooked_meat", @@ -334,7 +339,8 @@ "stamina_regen": 1.3, "health_regen": 2.0 } - } + }, + "keeps": 4 }, { "id": "hide", @@ -483,8 +489,10 @@ "health": -2, "diet": { "fish": 8 - } - } + }, + "risk": 0.2 + }, + "keeps": 2 }, { "id": "trout", @@ -498,8 +506,10 @@ "health": -2, "diet": { "fish": 12 - } - } + }, + "risk": 0.2 + }, + "keeps": 2 }, { "id": "grilled_perch", @@ -514,7 +524,8 @@ "diet": { "fish": 30 } - } + }, + "keeps": 4 }, { "id": "grilled_trout", @@ -529,7 +540,8 @@ "diet": { "fish": 45 } - } + }, + "keeps": 4 }, { "id": "iron_nugget", @@ -978,6 +990,101 @@ "stack": 3, "description": "A carved stone token from a barrow coffin. One of three: the standing stones on the rim are said to wake for them.", "mesh": "barrow_key" + }, + { + "id": "rotten_food", + "name": "Rotten Food", + "category": "resource", + "stack": 50, + "description": "It's gone off. Good for nothing: throw it out.", + "mesh": "rotten_food" + }, + { + "id": "salt", + "name": "Salt", + "category": "resource", + "stack": 50, + "description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.", + "mesh": "salt" + }, + { + "id": "smoking_rack", + "name": "Smoking Rack", + "category": "placeable", + "stack": 1, + "description": "Hang raw meat or fish over a smoky fire (E): with a piece of wood it smokes them, and smoked food keeps for days.", + "mesh": "smoking_rack", + "place": { + "piece": "smoker", + "footprint": [ + 1.6, + 1.2 + ] + } + }, + { + "id": "smoked_meat", + "name": "Smoked Meat", + "category": "food", + "stack": 20, + "description": "Dark and chewy. Keeps 12 days.", + "mesh": "smoked_meat", + "keeps": 12, + "food": { + "hunger": 28, + "health": 10, + "diet": { + "meat": 32 + } + } + }, + { + "id": "smoked_fish", + "name": "Smoked Fish", + "category": "food", + "stack": 20, + "description": "Golden and salty-sweet. Keeps 12 days.", + "mesh": "smoked_fish", + "keeps": 12, + "food": { + "hunger": 20, + "health": 8, + "diet": { + "fish": 32 + } + } + }, + { + "id": "salted_meat", + "name": "Salted Meat", + "category": "food", + "stack": 20, + "description": "Cured in salt. Keeps 20 days. Safe to eat as it is.", + "mesh": "salted_meat", + "keeps": 20, + "food": { + "hunger": 22, + "health": 6, + "diet": { + "meat": 26 + } + } + }, + { + "id": "salted_fish", + "name": "Salted Fish", + "category": "food", + "stack": 20, + "description": "Cured in salt. Keeps 20 days. Safe to eat as it is.", + "mesh": "salted_fish", + "keeps": 20, + "food": { + "hunger": 16, + "health": 5, + "diet": { + "fish": 26 + } + } } ] } diff --git a/data/recipes.json b/data/recipes.json index 4596beb..2f3f5b2 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -532,6 +532,51 @@ "bear_hide": 1, "fiber": 2 } + }, + { + "id": "smoking_rack", + "output": "smoking_rack", + "count": 1, + "station": "", + "category": "stations", + "inputs": { + "stick": 6, + "wood": 4, + "fiber": 4 + } + }, + { + "id": "salted_meat", + "output": "salted_meat", + "count": 2, + "station": "", + "category": "cooking", + "inputs": { + "raw_meat": 2, + "salt": 1 + } + }, + { + "id": "salted_fish", + "output": "salted_fish", + "count": 2, + "station": "", + "category": "cooking", + "inputs": { + "perch": 2, + "salt": 1 + } + }, + { + "id": "salted_trout", + "output": "salted_fish", + "count": 2, + "station": "", + "category": "cooking", + "inputs": { + "trout": 1, + "salt": 1 + } } ] } diff --git a/docs/PROGRESSION_DESIGN.md b/docs/PROGRESSION_DESIGN.md index 6b2139d..9ba1727 100644 --- a/docs/PROGRESSION_DESIGN.md +++ b/docs/PROGRESSION_DESIGN.md @@ -1,7 +1,7 @@ # Progression and challenges design -Status: **P1 (iron), P2 (stone) and P3 (adversaries) built**; P4-P5 are -proposals. +Status: **P1 (iron), P2 (stone), P3 (adversaries) and P4 (hazards) +built**; P5 is a proposal. *As built in P1:* - Iron nuggets (70, in the scree under the rim), clay (60, on the lake @@ -95,6 +95,34 @@ proposals. - Not yet: iron scrap (bars and nuggets instead); the standing stones and the Old Stag (P5). +*As built in P4 (hazards):* +- Spoilage: food carries a "rots at" world time on its stack (stamped when + it first goes into a bag or chest; merged stacks average it), so it rots + in bags, chests, graves, racks and on the ground, and through sleep and + saves. Raw meat and fish 2 days, berries 3, mushrooms and cooked food 4, + roasted mushrooms 6, smoked 12, salted 20. Rotten food is junk; food + rotting on the ground just goes. A bar on the slot runs green to brown; + the tooltip says Fresh / Going off / Nearly rotten, with hours left. +- Rot speed: normal, slow (half) or off. Chosen on the New World page + (meta.json "rot"); on a server, "rot" in server.json or /set rot. +- Smoking rack (by hand: 6 sticks, 4 wood, 4 fibre): 2 raw meat + a wood, + or 2 perch (or 1 trout) + a wood, make 2 smoked, a minute a batch. +- Salt: 40 crusts on the cliff tops above the sea. 2 raw meat (or 2 perch, + or 1 trout) + a salt: 2 salted, by hand. +- Food poisoning: raw meat 35%, raw fish 20%. A minute of half-speed + stamina and no eating. Cooked, smoked and salted food are safe. +- Blizzards: fog x7; out in one, an extra 18 warmth lost on top of the + cold, and you cool twice as fast. A hard-winter blizzard freezes you by + day without a coat; a hide cloak keeps you out of Freezing; a hearth room + stays Warm. Snow piles on roofs (a look only). +- Hard winters: days 12-14, 24-26, ... (every 12 days): always cold, 8 + colder than a cold day, snowier, half the animals, the lake's edge frozen + (walkable ice, no fishing through it). Warned the day before. +- Lightning (decided: trees only): in a storm, a strike every 50-110 s on a + tree within 90 m of someone, the taller and nearer the rim the likelier. + It burns 40 s (you can warm yourself by it), falls, and leaves 2 charcoal. +- Network protocol 11. + 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 1156a58..dbdfe43 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,6 +42,7 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). - [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)* - [x] The bear: raids unattended food, palisades and closed huts keep it out, bear hide rug, bear fat *(P3, part 2)* - [x] Crows and foxes steal food left out; the barrow, its guards, the coffin and key pieces *(P3, part 3)* +- [x] Hazards: food spoilage (rot speed setting), smoking rack and salt, food poisoning, harsher blizzards, hard winters, lightning on trees *(P4)* - [ ] **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/processor.gd b/scripts/building/processor.gd index aba1c64..fdae121 100644 --- a/scripts/building/processor.gd +++ b/scripts/building/processor.gd @@ -18,7 +18,17 @@ const RECIPES := { {"inputs": {"iron_ore": 1, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0}, {"inputs": {"iron_nugget": 3, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0}, ], + # P4: smoked food keeps for days (raw rots in two). + "smoking_rack": [ + {"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0}, + {"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0}, + {"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0}, + ], } +## What the prompt says it makes, and what it needs when idle. +const MAKES := {"charcoal_kiln": "charcoal", "smelter": "an iron bar", "smoking_rack": "smoked food"} +const NEEDS := {"charcoal_kiln": "put in wood", "smelter": "put in iron ore (or 3 nuggets) and charcoal", + "smoking_rack": "put in raw meat or fish, and wood for the fire"} var inventory := Inventory.new(SLOTS) var progress := 0.0 ## seconds into the current batch @@ -49,7 +59,9 @@ func _build() -> void: m.emission = Color(1.0, 0.45, 0.12) m.emission_energy_multiplier = 3.0 _glow.material_override = m - _glow.position = Vector3(0, 0.3 if item_id == "smelter" else 0.25, 0.58 if item_id == "smelter" else 0.72) + _glow.position = {"smelter": Vector3(0, 0.3, 0.58), "smoking_rack": Vector3(0, 0.12, 0.0)}.get(item_id, Vector3(0, 0.25, 0.72)) + if item_id == "smoking_rack": + _glow.rotation.x = -PI * 0.5 # (embers on the ground under the rack) _glow.visible = false add_child(_glow) @@ -82,7 +94,7 @@ func step(secs: float) -> void: progress -= float(r.seconds) for id in r.inputs: inventory.remove(id, int(r.inputs[id])) - inventory.add(r.output, 1) + inventory.add(r.output, int(r.get("count", 1))) r = current() if r.is_empty(): progress = 0.0 @@ -107,10 +119,9 @@ func dismantle_block_reason() -> String: func interact_prompt(_player: Node) -> String: var r := current() - var what := "charcoal" if item_id == "charcoal_kiln" else "an iron bar" + var what: String = MAKES.get(item_id, "something") if r.is_empty(): - var needs := "put in wood" if item_id == "charcoal_kiln" else "put in iron ore (or 3 nuggets) and charcoal" - return "%s (idle: %s) [E] Open" % [display_name(), needs] + return "%s (idle: %s) [E] Open" % [display_name(), NEEDS.get(item_id, "put something in")] if WorldNet.is_client(): return "%s (making %s) [E] Open" % [display_name(), what] return "%s (making %s, %d s) [E] Open" % [display_name(), what, ceili(float(r.seconds) - progress)] diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index f8edd3f..1fdebc7 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -622,14 +622,24 @@ func eat(player: Node3D, slot: int) -> bool: var food: Dictionary = ItemDB.get_item(s.id).get("food", {}) if food.is_empty(): return false + if player.survival.sick > 0.0: + tell(player, "You feel too sick to eat") + return false if player.survival.hunger >= FULL_HUNGER: notify.emit("You're full") return false player.inventory.take_from(slot, 1) player.survival.eat(food) notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower()) + # Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't. + if food_rng.randf() < float(food.get("risk", 0.0)): + player.survival.poison() + tell(player, "Your stomach turns: food poisoning. Cook your meat!") return true +## Rolls for food poisoning (tests seed it). +var food_rng := RandomNumberGenerator.new() + ## Feeds a campfire from the player's inventory: wood first, then sticks. func add_fuel(player: Node3D, fire: Node3D) -> bool: diff --git a/scripts/core/inventory.gd b/scripts/core/inventory.gd index 91affe6..c64f5da 100644 --- a/scripts/core/inventory.gd +++ b/scripts/core/inventory.gd @@ -33,19 +33,103 @@ static func _stack_max(id: String) -> int: ## A stack can carry "dur": uses left on a tool or weapon that wears out ## (ItemDB "durability"). No "dur" means as new. +## Food that spoils (ItemDB "keeps", in days) carries "rot": the world time +## (seconds since day 1, 00:00) at which it goes rotten. It's stamped when +## the food first goes into an inventory; two stacks that merge share the +## average. world.gd turns rotten stacks into rotten food (spoil()). static func _norm(stack): if stack == null or int(stack.count) <= 0: return null var out := {"id": String(stack.id), "count": int(stack.count)} if stack.has("dur"): out.dur = int(stack.dur) + if stack.has("rot"): + out.rot = float(stack.rot) return out -## Adds a whole stack, keeping what's special about it (a worn tool's "dur"). -## Returns how many didn't fit. +## What's special about a stack, for when it's dropped on the ground (and +## kept when it's picked up again): a tool's wear, food's freshness. +static func extra_of(stack: Dictionary) -> Dictionary: + var out := {} + if stack.has("dur"): + out.dur = int(stack.dur) + if stack.has("rot"): + out.rot = float(stack.rot) + return out + +# --- spoilage (P4, docs/PROGRESSION_DESIGN.md §5) --------------------------------- + +## The world clock (seconds since day 1, 00:00) and how long a day is, kept +## up to date by world.gd. `rot_speed`: 1 normal, 0.5 slow, 0 off (the +## world's or server's "rot" setting). +static var clock := 0.0 +static var day_seconds := 1200.0 +static var rot_speed := 1.0 +const ROTTEN := "rotten_food" + +static func _db() -> Node: + return (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB") + +## Days `id` keeps before it rots (0: it doesn't). +static func keeps_days(id: String) -> float: + var db := _db() + return float(db.get_item(id).get("keeps", 0.0)) if db != null else 0.0 + +## How long `id` keeps, in world seconds, at the current rot speed (0: forever). +static func keeps_for(id: String) -> float: + var d := keeps_days(id) + if d <= 0.0 or rot_speed <= 0.0: + return 0.0 + return d * day_seconds / rot_speed + +## The "rot" stamp for `id` made right now (-1: it doesn't spoil). +static func fresh_rot(id: String) -> float: + var k := keeps_for(id) + # (Whole seconds, so a stamp comes back from a JSON save exactly as it went in.) + return roundf(clock + k) if k > 0.0 else -1.0 + +## 0..1: how fresh a stack is (1 = just made or doesn't spoil). +static func freshness(stack) -> float: + if stack == null or not stack.has("rot"): + return 1.0 + var k := keeps_for(String(stack.id)) + if k <= 0.0: + return 1.0 + return clampf((float(stack.rot) - clock) / k, 0.0, 1.0) + +## In-game hours until a stack rots (INF: never). +static func hours_left(stack) -> float: + if stack == null or not stack.has("rot") or rot_speed <= 0.0: + return INF + return maxf(float(stack.rot) - clock, 0.0) / day_seconds * 24.0 + +## Stamps food that has no "rot" yet, and turns food past its time into +## rotten food. Returns how many items went rotten. +func spoil() -> int: + if rot_speed <= 0.0: + return 0 + var rotted := 0 + var touched := false + for i in range(slots.size()): + var s = slots[i] + if s == null or keeps_days(String(s.id)) <= 0.0: + continue + if not s.has("rot"): + s.rot = fresh_rot(String(s.id)) + touched = true + elif clock >= float(s.rot): + rotted += int(s.count) + slots[i] = {"id": ROTTEN, "count": int(s.count)} + touched = true + if touched: + changed.emit() + return rotted + +## Adds a whole stack, keeping what's special about it (a worn tool's "dur", +## food's "rot"). Returns how many didn't fit. func add_stack(stack: Dictionary) -> int: if not stack.has("dur"): - return add(String(stack.id), int(stack.count)) + return add(String(stack.id), int(stack.count), float(stack.get("rot", -1.0))) for i in range(slots.size()): if slots[i] == null: slots[i] = _norm(stack) @@ -72,14 +156,18 @@ func space_for(id: String) -> int: return room ## Adds up to `n` of `id`, topping up existing stacks first. Returns the -## leftover that didn't fit (0 = everything fitted). -func add(id: String, n: int) -> int: +## leftover that didn't fit (0 = everything fitted). Food gets a fresh "rot" +## stamp, or `rot` if it's coming from somewhere with one of its own. +func add(id: String, n: int, rot: float = -1.0) -> int: var cap := _stack_max(id) + var r := rot if rot >= 0.0 else fresh_rot(id) for s in slots: if n <= 0: break if s != null and s.id == id and s.count < cap: var take := mini(cap - s.count, n) + if r >= 0.0: + s.rot = _mix_rot(s, r, take) s.count += take n -= take for i in range(slots.size()): @@ -88,10 +176,20 @@ func add(id: String, n: int) -> int: if slots[i] == null: var take := mini(cap, n) slots[i] = {"id": id, "count": take} + if r >= 0.0: + slots[i].rot = r n -= take changed.emit() return n +## The "rot" of stack `s` once `n` more with `rot` are added to it: the +## average, so mixing fresh and old food gives you food in between. +static func _mix_rot(s: Dictionary, rot: float, n: int) -> float: + if rot < 0.0: + return float(s.get("rot", -1.0)) + var have := float(s.get("rot", rot)) + return roundf((have * int(s.count) + rot * n) / float(int(s.count) + n)) + ## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its ## items longest). Returns false and changes nothing if there aren't enough. func remove(id: String, n: int) -> bool: @@ -135,9 +233,16 @@ func quick_move(i: int, other: Inventory) -> void: if s == null: return var left := other.add_stack(s) - slots[i] = null if left <= 0 else _norm({"id": s.id, "count": left, "dur": s.dur} if s.has("dur") else {"id": s.id, "count": left}) + slots[i] = null if left <= 0 else _part(s, left) changed.emit() +## `n` of stack `s`, with its wear or freshness. +static func _part(s: Dictionary, n: int): + var out := extra_of(s) + out.id = s.id + out.count = n + return _norm(out) + ## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new ## cursor stack. Left click: pick up / put down / merge / swap. Right click: ## pick up half, or put down one. @@ -150,23 +255,25 @@ func click(i: int, cursor, right: bool): if right and s.count > 1: var half := int(ceil(s.count / 2.0)) s.count -= half - result = {"id": s.id, "count": half} + result = _part(s, half) else: result = s slots[i] = null elif s == null: if right and cursor.count > 1: - slots[i] = {"id": cursor.id, "count": 1} - result = _norm({"id": cursor.id, "count": cursor.count - 1}) + slots[i] = _part(cursor, 1) + result = _part(cursor, cursor.count - 1) else: slots[i] = cursor result = null - elif s.id == cursor.id: + elif s.id == cursor.id and not s.has("dur") and not cursor.has("dur"): var cap := _stack_max(s.id) var move: int = 1 if right else cursor.count move = mini(move, cap - s.count) + if move > 0 and cursor.has("rot"): + s.rot = _mix_rot(s, float(cursor.rot), move) s.count += move - result = _norm({"id": cursor.id, "count": cursor.count - move}) + result = _part(cursor, cursor.count - move) else: slots[i] = cursor result = s diff --git a/scripts/core/saves.gd b/scripts/core/saves.gd index daf3002..ba65e9f 100644 --- a/scripts/core/saves.gd +++ b/scripts/core/saves.gd @@ -40,7 +40,9 @@ func list_worlds() -> Array: out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0))) return out -func create_world(world_name: String, seed: int) -> String: +## `options` are the world's own settings, kept in meta.json (P4: "rot", +## how fast food spoils: "normal", "slow" or "off"). +func create_world(world_name: String, seed: int, options: Dictionary = {}) -> String: DirAccess.make_dir_recursive_absolute(root_dir) var base := world_name.to_lower().replace(" ", "_").validate_filename() if base.is_empty(): @@ -52,10 +54,13 @@ func create_world(world_name: String, seed: int) -> String: n += 1 DirAccess.make_dir_recursive_absolute(_dir(id)) var now := Time.get_unix_time_from_system() - _write_json(_dir(id).path_join("meta.json"), { + var meta := { "name": world_name, "seed": seed, "created": now, "last_played": now, "play_time": 0.0, "version": VERSION, - }) + } + for k in options: + meta[k] = options[k] + _write_json(_dir(id).path_join("meta.json"), meta) return id ## The world slot called exactly `id` (a server's world), made with `seed` if diff --git a/scripts/creatures/spawner.gd b/scripts/creatures/spawner.gd index e7d8a5c..1f25d75 100644 --- a/scripts/creatures/spawner.gd +++ b/scripts/creatures/spawner.gd @@ -81,7 +81,7 @@ func _process(delta: float) -> void: for kind in BUDGET: if _cooldown[kind] > 0.0: continue - if alive(kind).size() < budget(kind, everyone.size()): + if alive(kind).size() < winter_budget(kind, everyone.size()): var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()] if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null: _cooldown[kind] = RESPAWN_DELAY * 0.25 @@ -91,6 +91,13 @@ func _process(delta: float) -> void: static func budget(kind: String, players: int) -> int: return BUDGET[kind] * clampi(players, 1, 2) +## In a hard winter (P4) half as many: the animals are holed up. +func winter_budget(kind: String, players: int) -> int: + var b := budget(kind, players) + if world != null and world.weather != null and world.weather.in_hard_winter(): + b = maxi(b / 2, 1) + return b + func _on_died(c: Node) -> void: _cooldown[c.kind] = RESPAWN_DELAY diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index b0b57c8..232da6b 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -116,6 +116,13 @@ static func build(key: String) -> ArrayMesh: "bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1) "fur_rug": m = _rug() "barrow_key": m = _barrow_key() + "rotten_food": m = _pebble(321, Color(0.33, 0.3, 0.16), Vector3(0.11, 0.06, 0.1), 0.45) + "salt": m = _salt() + "smoking_rack": m = _smoking_rack() + "smoked_meat": m = _meat(Color(0.36, 0.17, 0.1)) + "salted_meat": m = _meat(Color(0.68, 0.42, 0.38)) + "smoked_fish": m = _fish(Color(0.72, 0.5, 0.22), 0.2, true) + "salted_fish": m = _fish(Color(0.75, 0.73, 0.66), 0.2, false) "berry_bush": m = _berry_bush(true) "berry_bush_bare": m = _berry_bush(false) _: @@ -1019,3 +1026,39 @@ static func _barrow_key() -> ArrayMesh: _box(st, c + Vector3(0, 0.02, 0), Vector3(w, 0.04, 0.12), P._mul(Color(0.45, 0.46, 0.44), r.randf_range(0.9, 1.05)), Basis(Vector3.UP, (a0 + a1) * 0.5)) _box(st, Vector3(0, 0.042, 0.1), Vector3(0.1, 0.006, 0.02), Color(0.82, 0.8, 0.7)) return P._commit(st) + +# --- P4 food keeping --------------------------------------------------------------------- + +## A crust of white salt crystals. +static func _salt() -> ArrayMesh: + var st := P._begin() + var r := _rng(331) + for i in range(7): + var c := Vector3(r.randf_range(-0.07, 0.07), 0.02, r.randf_range(-0.06, 0.06)) + _box(st, c, Vector3.ONE * r.randf_range(0.025, 0.05), P._mul(Color(0.93, 0.93, 0.9), r.randf_range(0.9, 1.05)), + Basis(Vector3(r.randf(), r.randf(), r.randf()).normalized(), r.randf() * TAU)) + return P._commit(st) + +## Two forked posts with a crossbar, strips of meat hanging from it, embers +## below. The fire sits at the origin. +static func _smoking_rack() -> ArrayMesh: + var st := P._begin() + var r := _rng(333) + var n := FastNoiseLite.new() + n.seed = 333 + for sx in [-0.7, 0.7]: + P._cylinder(st, Vector3(sx, 0.0, 0), Vector3(sx, 1.35, 0), 0.05, 0.04, 6, P.BARK, r, -1.0, PLANK) + P._cylinder(st, Vector3(sx, 1.25, 0), Vector3(sx + 0.08 * signf(sx), 1.45, 0), 0.03, 0.02, 4, P.BARK, r) + P._cylinder(st, Vector3(-0.8, 1.32, 0), Vector3(0.8, 1.32, 0), 0.035, 0.035, 6, P.BARK, r, -1.0, PLANK) + for i in range(5): + var x := -0.48 + 0.24 * i + _box(st, Vector3(x, 1.12, 0), Vector3(0.12, 0.32 + 0.06 * sin(i * 2.1), 0.03), P._mul(Color(0.42, 0.18, 0.12), r.randf_range(0.8, 1.1))) + for i in range(6): + var a := TAU * i / 6.0 + P._blob(st, Vector3(cos(a) * 0.32, 0.05, sin(a) * 0.32), Vector3(0.1, 0.07, 0.09), n, 0.3, STONE, r, 1) + P._blob(st, Vector3(0, 0.03, 0), Vector3(0.24, 0.035, 0.24), n, 0.2, ASH, r, 1) + for i in range(3): + var a := 0.9 * i + var d := Vector3(cos(a), 0, sin(a)) * 0.18 + P._cylinder(st, Vector3(0, 0.06, 0) - d, Vector3(0, 0.06, 0) + d, 0.035, 0.035, 5, P.BARK, r, -1.0, CHARCOAL) + return P._commit(st) diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 16cdaad..4b90f8a 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 := 10 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3) +const PROTOCOL := 11 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4) 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 6d86255..722d42f 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -226,7 +226,7 @@ func _on_died(peer: int) -> void: var at: Vector3 = pos + off at.y = w.terrain.sample_height(at.x, at.z) + 0.05 w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at), - {"dur": int(st.dur)} if st.has("dur") else {}) + Inventory.extra_of(st)) p.inventory.load_data(Inventory.new(p.inventory.size()).to_data()) # ("keep": you keep everything.) # They stay down on the death screen for a few seconds, then ask to respawn. @@ -369,7 +369,7 @@ func _run(p: Node3D, what: String, a: Array) -> void: var left: int = p.inventory.add_stack(p.cursor) if left > 0: w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)), - {"dur": int(p.cursor.dur)} if p.cursor.has("dur") else {}) + Inventory.extra_of(p.cursor)) p.cursor = null "debug_give": if cheats and not ItemDB.get_item(_s(a, 0)).is_empty(): diff --git a/scripts/net/rules.gd b/scripts/net/rules.gd index a4ae29f..5a4dc33 100644 --- a/scripts/net/rules.gd +++ b/scripts/net/rules.gd @@ -69,6 +69,11 @@ static func difficulty(k: String) -> float: var d: Variant = v("difficulty") return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k]) +## How fast food rots: "normal", "slow" (half speed) or "off". +static func rot() -> String: + var r: Variant = v("rot") + return String(r) if r != null else "normal" + static func grace_days() -> int: var g: Variant = v("wolf_grace_days") return clampi(int(g), 0, 10) if g != null else 3 diff --git a/scripts/net/server_config.gd b/scripts/net/server_config.gd index 5b39ac5..bfa839b 100644 --- a/scripts/net/server_config.gd +++ b/scripts/net/server_config.gd @@ -23,6 +23,7 @@ const DEFAULTS := { "day_length_minutes": 20, "wolf_grace_days": 3, "difficulty": "normal", + "rot": "normal", "autosave_minutes": 2, "backups_kept": 10, "backup_hours": 6, @@ -32,7 +33,7 @@ const DEFAULTS := { } ## Settings sent to players when they join (never the password or the lists). const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks", - "death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty"] + "death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty", "rot"] var path := DEFAULT_PATH var data: Dictionary = {} ## what's in server.json (saved) @@ -146,6 +147,7 @@ const SETTABLE := { "sleep": ["all_online", "half", "anyone", "off"], "pause_when_empty": ["false", "true"], "difficulty": ["easy", "normal", "hard"], + "rot": ["normal", "slow", "off"], "wolf_grace_days": "0-10", "motd": "text", "server_name": "text", diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index 1ed5355..d35bc27 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -365,6 +365,21 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void: n.apply_remote(hp, point, from) received += 1 +## Lightning struck a tree (hazards.gd, P4): everyone sees it burn. (It +## falls later through _s2c_node, like any felled tree.) +func struck(n: Node3D) -> void: + _all("_s2c_strike", [String(n.uid)]) + +@rpc("authority", "call_remote", "reliable") +func _s2c_strike(uid: String) -> void: + if not is_client(): + return + var n: Node3D = world.props.resource_nodes.get(uid) + var hz: Node = world.get_node_or_null("Hazards") + if n != null and is_instance_valid(n) and hz != null: + hz.show_strike(n) + received += 1 + @rpc("authority", "call_remote", "reliable") func _s2c_regrown(uid: String) -> void: if not is_client(): diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 5377ae8..1fd4960 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -37,6 +37,8 @@ var _shelter_left := 0.0 var _rest_build := 0.0 ## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}. var buffs: Dictionary = {} +var sick := 0.0 ## seconds of food poisoning left (P4): slow stamina, can't eat +const SICK_SECONDS := 60.0 var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit var cause := "" ## what last hurt you, as the death screen says it ("You froze") var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd) @@ -128,7 +130,9 @@ func step(dt: float) -> void: wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off target = clampf(target, 0.0, MAX) # Warm up faster than you cool down: getting to a fire should feel good. - warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt) + # (Out in a blizzard you lose it twice as fast.) + var cool := 1.6 * (2.0 if world.weather.blizzard_chill(sheltered) > 0.0 else 1.0) + warmth = move_toward(warmth, target, (7.0 if target > warmth else cool) * dt) hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0) @@ -137,6 +141,7 @@ func step(dt: float) -> void: else: var malnourished := diet_lacking().size() >= 2 stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen") + * (0.5 if sick > 0.0 else 1.0) * (0.7 if malnourished else 1.0) * dt, MAX) for g in DIET_GROUPS: @@ -148,6 +153,7 @@ func step(dt: float) -> void: if buffs[id].left <= 0.0: buffs.erase(id) _hurt_cd = maxf(_hurt_cd - dt, 0.0) + sick = maxf(sick - dt, 0.0) var dmg := 0.0 if hunger <= 0.0: @@ -196,6 +202,8 @@ func _update_statuses() -> void: statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)]) elif balanced_diet(): statuses.append("Balanced diet") + if sick > 0.0: + statuses.append("Food poisoning") if sheltered: statuses.append("Sheltered") if cozy: @@ -230,6 +238,13 @@ func eat(food: Dictionary) -> void: func soak() -> void: wet = WET_SECONDS +## Something you ate disagreed with you (raw meat, raw fish): stamina comes +## back at half speed and you can't keep anything down for a minute. +func poison() -> void: + sick = SICK_SECONDS + _update_statuses() + changed.emit() + ## After a night's sleep: a little hungrier, dry, warm and rested. func after_sleep(seconds: float) -> void: hunger = maxf(hunger - HUNGER_DRAIN * seconds * 0.3, 0.0) @@ -284,7 +299,7 @@ func add_buff(b: Dictionary) -> void: func to_data() -> Dictionary: return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested, - "buffs": buffs.duplicate(true), "diet": diet.duplicate()} + "buffs": buffs.duplicate(true), "diet": diet.duplicate(), "sick": sick} func load_data(d: Dictionary) -> void: health = float(d.get("health", health)) @@ -293,6 +308,7 @@ func load_data(d: Dictionary) -> void: warmth = float(d.get("warmth", warmth)) wet = float(d.get("wet", 0.0)) rested = float(d.get("rested", 0.0)) + sick = float(d.get("sick", 0.0)) buffs = (d.get("buffs", {}) as Dictionary).duplicate(true) # Older saves have no diet: start it where a new world does. var sd: Dictionary = d.get("diet", {}) diff --git a/scripts/ui/slot.gd b/scripts/ui/slot.gd index bbd4ed0..ea37bb8 100644 --- a/scripts/ui/slot.gd +++ b/scripts/ui/slot.gd @@ -82,9 +82,27 @@ func set_stack(stack) -> void: _wear_fill.anchor_right = k _wear_fill.color = Color(0.85, 0.25, 0.2) if k < 0.2 else (Color(0.9, 0.75, 0.25) if k < 0.5 else Color(0.45, 0.8, 0.35)) tooltip_text += "\n%d of %d uses left" % [left, full] + elif stack.has("rot") and Inventory.keeps_for(String(stack.id)) > 0.0: + # Food: a bar that runs down green to brown as it goes off. + var k := Inventory.freshness(stack) + _wear.visible = true + _wear_fill.anchor_right = maxf(k, 0.04) + _wear_fill.color = Color(0.45, 0.8, 0.35).lerp(Color(0.5, 0.33, 0.15), 1.0 - k) + tooltip_text += "\n" + freshness_text(stack) else: _wear.visible = false +## "Fresh", "Going off (about 9 hours left)", "Nearly rotten (2 hours left)". +static func freshness_text(stack) -> String: + var k := Inventory.freshness(stack) + var h := Inventory.hours_left(stack) + var left := ("%d hours left" % roundi(h)) if h < 48.0 else ("%d days left" % roundi(h / 24.0)) + if k > 0.6: + return "Fresh (%s)" % left + if k > 0.2: + return "Going off (%s)" % left + return "Nearly rotten (%s)" % left + func set_selected(on: bool) -> void: add_theme_stylebox_override("panel", _selected_style if on else _style) diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index 0b64352..b1a855d 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -8,6 +8,9 @@ const WORLD_SCENE := "res://scenes/world.tscn" var _pages: Dictionary = {} ## name -> Control var _name_edit: LineEdit var _seed_edit: LineEdit +var _rot_pick: OptionButton +## [setting, what the New World page calls it] +const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]] var _list: VBoxContainer var _armed_delete := "" @@ -181,6 +184,7 @@ func _show(page: String) -> void: elif page == "new": _name_edit.text = _unique_name() _seed_edit.text = "" + _rot_pick.select(0) _name_edit.grab_focus() _name_edit.select_all() @@ -571,6 +575,12 @@ func _new_page() -> Control: _seed_edit.custom_minimum_size = Vector2(360, 40) _seed_edit.text_submitted.connect(func(_t): _create()) v.add_child(_seed_edit) + v.add_child(UITheme.label("Food spoils", 15, UITheme.TEXT_DIM)) + _rot_pick = OptionButton.new() + for t in ROT_CHOICES: + _rot_pick.add_item(t[1]) + _rot_pick.custom_minimum_size = Vector2(360, 36) + v.add_child(_rot_pick) var h := HBoxContainer.new() h.add_theme_constant_override("separation", 10) h.add_child(_button("Create & Play", _create, 170)) @@ -660,7 +670,8 @@ func _create() -> void: world_name = _unique_name() var seed_text := _seed_edit.text.strip_edges() var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi()) - _play(Saves.create_world(world_name, seed)) + var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0] + _play(Saves.create_world(world_name, seed, {"rot": rot})) func _play(id: String) -> void: Saves.current_id = id diff --git a/scripts/world/hazards.gd b/scripts/world/hazards.gd new file mode 100644 index 0000000..32ea31f --- /dev/null +++ b/scripts/world/hazards.gd @@ -0,0 +1,288 @@ +extends Node3D +## Weather hazards (P4, docs/PROGRESSION_DESIGN.md §5) that need more than +## the weather model: +## +## - Hard winters (weather.gd hard_winter()): a warning the day before and +## on the day; the lake's edge freezes over (walkable ice you can't fish +## through), on every machine since it comes from the shared clock. +## - Lightning: in a storm, every minute or so a bolt hits a tree near +## someone, preferring tall trees near the rim. It burns for a while (a +## fire you can warm yourself by), then falls, leaving charcoal. Decided: +## trees only for now; buildings are never struck. +## +## Strikes and warnings happen where the game decides (single-player, a +## server); players' games are told (world_net.gd _s2c_strike). + +const Weather = preload("res://scripts/world/weather.gd") + +const STRIKE_MIN := 50.0 +const STRIKE_MAX := 110.0 +const STRIKE_RANGE := 90.0 ## trees this near someone can be struck +const BURN_SECONDS := 40.0 +const CHARCOAL := 2 +const ICE_IN := 0.62 ## the ice: from this x lake radius out to the shore +const ICE_OUT := 1.7 + +var world: Node3D +var deciding := true ## false on a player's game online +var strikes := 0 +var _strike_left := 30.0 +var _rng := RandomNumberGenerator.new() +var _burning: Dictionary = {} ## resource node -> [fire node, seconds left] +var _ice: MeshInstance3D +var _ice_body: StaticBody3D +var _warned := {} ## day -> true + +func setup(w: Node3D, decides: bool) -> void: + world = w + deciding = decides + _rng.seed = w.terrain.noise_seed + 1201 + _build_ice() + w.day_night.new_day.connect(_on_new_day) + +func _physics_process(delta: float) -> void: + step(delta) + +func step(dt: float) -> void: + _set_ice(world.weather.in_hard_winter()) + _snow_left -= dt + if _snow_left <= 0.0: + _snow_left = 2.0 + _update_roof_snow() + for n in _burning.keys(): + var e: Array = _burning[n] + e[1] = float(e[1]) - dt + if float(e[1]) <= 0.0 or not is_instance_valid(n): + _burn_out(n) + if not deciding: + return + var w: Node = world.weather + if w.state == "storm" and w.precip > 0.7: + _strike_left -= dt + if _strike_left <= 0.0: + _strike_left = _rng.randf_range(STRIKE_MIN, STRIKE_MAX) + var t := pick_tree() + if t != null: + strike(t) + +# --- lightning ------------------------------------------------------------------------- + +## A standing tree near someone, the taller and nearer the rim the likelier. +func pick_tree() -> Node3D: + var best: Node3D = null + var best_s := -INF + for n in world.props.resource_nodes.values(): + if n.kind != "tree" or n.depleted or _burning.has(n): + continue + var near := false + for p in world.players(): + if (n as Node3D).global_position.distance_to(p.global_position) < STRIKE_RANGE: + near = true + break + if not near: + continue + var pos: Vector3 = n.global_position + var s: float = world.props._near_rim(pos.x, pos.z) * 2.0 + n.base_xf.basis.get_scale().y + _rng.randf() * 1.5 + if s > best_s: + best_s = s + best = n + return best + +## Lightning hits tree `n`: a flash, a crack, and it's on fire. +func strike(n: Node3D) -> void: + strikes += 1 + show_strike(n) + if world.server_mode: + WorldNet.struck(n) + for p in world.players(): + if p.global_position.distance_to(n.global_position) < 60.0: + Actions.tell(p, "Lightning struck a tree! It's burning.") + +## What everyone sees: the flash, and the tree alight. +func show_strike(n: Node3D) -> void: + world.weather.flash = 1.0 + if _burning.has(n): + return + var fire := TreeFire.new() + fire.name = "TreeFire" + add_child(fire) + fire.global_position = n.global_position + fire.build(DisplayServer.get_name() != "headless") + _burning[n] = [fire, BURN_SECONDS] + +func burning(n: Node3D) -> bool: + return _burning.has(n) + +## Burnt through: the tree falls (where the game decides; players' games +## see it fall via world_net), leaving charcoal at its foot. +func _burn_out(n: Node3D) -> void: + var e: Array = _burning[n] + _burning.erase(n) + if is_instance_valid(e[0]): + (e[0] as Node).queue_free() + if not deciding or not is_instance_valid(n) or n.depleted: + return + var top: Vector3 = n.global_position + Vector3(0, 2.0, 0) + var from: Vector3 = n.global_position + Vector3(1, 0, 0) + n._show_hit(0.0, top, from) + if world.server_mode: + WorldNet.node_hit(n, top, from) + world.drop_item("charcoal", CHARCOAL, Transform3D(Basis(), n.global_position + Vector3(0.8, 0.3, 0.4))) + +## A burning tree's fire: flames up the trunk, a light, and warmth. +class TreeFire extends Node3D: + const RADIUS := 6.0 + func build(visual: bool) -> void: + add_to_group("heat") + if not visual: + return + var light := OmniLight3D.new() + light.light_color = Color(1.0, 0.55, 0.25) + light.light_energy = 3.0 + light.omni_range = 14.0 + light.position = Vector3(0, 3.0, 0) + add_child(light) + # The campfire's soft additive flames, bigger and up the trunk. + var CF: Script = load("res://scripts/building/campfire.gd") + var ramp: Gradient = CF._gradient([[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.8)], + [0.6, Color(0.95, 0.3, 0.06, 0.5)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]]) + for i in range(3): + var p: CPUParticles3D = CF._particles("Flames%d" % i, 30, 1.0, Vector3(0, 1.2 + 1.6 * i, 0), 0.8, 2.0, + 0.6, 1.2, ramp, true, 0.9, 0.7 - 0.15 * i) + add_child(p) + p.emitting = true + func heat_at(pos: Vector3) -> float: + return 1.0 - smoothstep(2.0, RADIUS, pos.distance_to(global_position)) + +# --- hard winters --------------------------------------------------------------------- + +func _on_new_day(day: int) -> void: + if not deciding or _warned.has(day): + return + _warned[day] = true + if Weather.hard_winter(day + 1) and not Weather.hard_winter(day): + Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.") + elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1): + Actions.tell_all("A hard winter has set in. The lake's edge has frozen, and the animals are holed up.") + elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day): + Actions.tell_all("The hard winter is over.") + +## A ring of ice round the lake, at the water line: it shows (and you can +## walk on it) wherever the water is, from the shore out to ICE_IN of the +## lake's radius. Off except in a hard winter. +func _build_ice() -> void: + var t: Node3D = world.terrain + var c: Vector2 = t.lake_center + var r0: float = t.lake_radius * ICE_IN + var r1: float = t.lake_radius * ICE_OUT + var y: float = t.water_level + 0.04 + var segs := 64 + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var faces := PackedVector3Array() + var nz := FastNoiseLite.new() + nz.seed = t.noise_seed + 1202 + for i in range(segs): + var a0 := TAU * i / segs + var a1 := TAU * (i + 1) / segs + var k0 := 1.0 + 0.12 * nz.get_noise_1d(i * 7.0) + var k1 := 1.0 + 0.12 * nz.get_noise_1d((i + 1) * 7.0) + var p := [Vector3(c.x + cos(a0) * r0 * k0, y, c.y + sin(a0) * r0 * k0), Vector3(c.x + cos(a0) * r1, y, c.y + sin(a0) * r1), + Vector3(c.x + cos(a1) * r1, y, c.y + sin(a1) * r1), Vector3(c.x + cos(a1) * r0 * k1, y, c.y + sin(a1) * r0 * k1)] + for tri in [[p[0], p[2], p[1]], [p[0], p[3], p[2]]]: + for v in tri: + st.set_normal(Vector3.UP) + st.add_vertex(v) + faces.append(v) + _ice = MeshInstance3D.new() + _ice.name = "LakeIce" + _ice.mesh = st.commit() + var m := StandardMaterial3D.new() + m.albedo_color = Color(0.86, 0.93, 0.98, 0.96) + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.roughness = 0.35 + m.metallic_specular = 0.8 + _ice.material_override = m + _ice.visible = false + add_child(_ice) + _ice_body = StaticBody3D.new() + _ice_body.name = "IceBody" + var cs := CollisionShape3D.new() + var shape := ConcavePolygonShape3D.new() + shape.set_faces(faces) + shape.backface_collision = true + cs.shape = shape + _ice_body.add_child(cs) + _ice_body.collision_layer = 0 + add_child(_ice_body) + +func _set_ice(on: bool) -> void: + if _ice.visible == on: + return + _ice.visible = on + _ice_body.collision_layer = 1 if on else 0 + +# --- snow on roofs ---------------------------------------------------------------------- + +var _snow_left := 0.0 +var _snow_mat: StandardMaterial3D +var _snow_mesh: BoxMesh + +## Snow piles up on roofs as it settles (weather.gd snow_cover), and melts +## off with it. Just a look: a white layer on each roof piece, thicker the +## more has settled. (Visual only, so not on a headless server.) +func _update_roof_snow() -> void: + if DisplayServer.get_name() == "headless": + return + var cover: float = world.weather.snow_cover + if _snow_mesh == null: + var BP: Script = load("res://scripts/building/build_piece.gd") + _snow_mesh = BoxMesh.new() + _snow_mesh.size = Vector3(2.04, 0.12, sqrt(5.0) + 0.25) + _snow_mat = StandardMaterial3D.new() + _snow_mat.albedo_color = Color(0.93, 0.95, 0.98) + _snow_mat.roughness = 0.9 + _snow_mesh.material = _snow_mat + _snow_angle = BP.ROOF_ANGLE + _snow_t = BP.ROOF_T + for p in world.build.build_pieces(): + if p.kind != "roof": + continue + var s: MeshInstance3D = p.get_node_or_null("Snow") + if cover < 0.2: + if s != null: + s.visible = false + continue + if s == null: + s = MeshInstance3D.new() + s.name = "Snow" + s.mesh = _snow_mesh + p.add_child(s) + var k := clampf((cover - 0.2) / 0.8, 0.0, 1.0) + var n := Vector3(0, cos(_snow_angle), sin(_snow_angle)) + var along := Vector3(0, sin(_snow_angle), -cos(_snow_angle)) + s.visible = true + s.transform = Transform3D(Basis(Vector3.RIGHT, _snow_angle).scaled(Vector3(1.0, maxf(k, 0.15), 1.0)), + along * (sqrt(5.0) * 0.5 - 0.1) + n * (_snow_t + 0.11 + 0.06 * k)) + +var _snow_angle := 0.4636476 +var _snow_t := 0.12 + +## How many roofs have snow on them right now (tests). +func snowy_roofs() -> int: + var n := 0 + for p in world.build.build_pieces(): + var s: Node = p.get_node_or_null("Snow") + if s != null and s.visible: + n += 1 + return n + +func lake_frozen() -> bool: + return _ice.visible + +## Frozen over at (x, z): ice, not open water. +func iced(x: float, z: float) -> bool: + if not lake_frozen(): + return false + var c: Vector2 = world.terrain.lake_center + return Vector2(x - c.x, z - c.y).length() > world.terrain.lake_radius * ICE_IN * 0.9 diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 650152d..3c12099 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -611,9 +611,9 @@ func _scatter_pickups() -> void: rng.seed = world_seed + 6 # Order matters: new kinds go at the END so existing saves keep their ids. var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150, - "iron_nugget": 70, "clay": 60} + "iron_nugget": 70, "clay": 60, "salt": 40} var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45, - "iron_nugget": 0.25, "clay": 0.3} + "iron_nugget": 0.25, "clay": 0.3, "salt": 0.25} for id: String in want: # Fibre grows as a plant (wild flax) you pick, 2 fibre a clump. var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id) @@ -655,8 +655,11 @@ func _scatter_pickups() -> void: var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6 var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h) chance = shore * 0.95 if by_lake else 0.0 + "salt": + # Crusts on the cliff tops above the sea (P4). + chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0 # A few right by the spawn so the first craft is quick. - if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay"]: + if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]: chance += 0.5 if keep > chance: continue diff --git a/scripts/world/weather.gd b/scripts/world/weather.gd index 06efdbb..f84a046 100644 --- a/scripts/world/weather.gd +++ b/scripts/world/weather.gd @@ -11,6 +11,11 @@ extends Node ## (and a storm becomes a blizzard). Above the snowline it always snows. ## Outputs ease toward the state's targets over about a minute, so fronts ## roll in rather than switching. +## +## P4 (docs/PROGRESSION_DESIGN.md §5): every 12 days a hard winter, three +## bitter cold days in a row (hard_winter()), colder still, with more snow and +## blizzards. A blizzard cuts visibility right down and its wind strips the +## warmth from anyone out in it (blizzard_chill(); a shelter keeps it out). signal state_changed(state: String) @@ -30,12 +35,18 @@ const TARGETS := { "rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0}, "storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0}, "snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0}, - "blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 0.90, "light": 0.45, "fog": 4.5, "warmth": -24.0}, + "blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.40, "fog": 7.0, "warmth": -24.0}, } const COLD_DAY_WARMTH := -8.0 const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another const CALM_START := 600.0 ## a new world's first 10 minutes are always clear const COLD_DAY_CHANCE := 0.25 +const HARD_WINTER_EVERY := 12 ## days 12-14, 24-26, ... +const HARD_WINTER_DAYS := 3 +const HARD_WINTER_WARMTH := -8.0 ## on top of a cold day's +const BLIZZARD_CHILL := 18.0 ## more warmth lost out in a blizzard's wind (a hard winter's freezes you by day) +## In a hard winter the weather leans wet (more snow and blizzards). +const HARD_WINTER_FRONT := -0.18 var world_seed := 1337 var day_night: Node @@ -76,9 +87,22 @@ func setup(seed: int, dn: Node) -> void: func world_seconds() -> float: return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length) +## Day `day` is one of a hard winter's three bitter days. +static func hard_winter(day: int) -> bool: + return day >= HARD_WINTER_EVERY and (day - HARD_WINTER_EVERY) % HARD_WINTER_EVERY < HARD_WINTER_DAYS + +## The first day of the next hard winter on or after `day`. +static func next_hard_winter(day: int) -> int: + var d := maxi(day, HARD_WINTER_EVERY) + while not hard_winter(d): + d += 1 + return d + func is_cold_day(day: int) -> bool: if day <= 1: return false + if hard_winter(day): + return true return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE ## The raw front value at an absolute time (higher = fairer). @@ -100,7 +124,7 @@ func _state_for(v: float, cold: bool) -> String: ## The state at an absolute time, ignoring smoothing and overrides. func state_at(t: float) -> String: var day := int(floor(t / float(day_night.day_length))) + 1 - return _state_for(front_at(t), is_cold_day(day)) + return _state_for(front_at(t) + (HARD_WINTER_FRONT if hard_winter(day) else 0.0), is_cold_day(day)) func force(s: String) -> void: override = s @@ -116,7 +140,7 @@ func snap() -> void: wind = tg.wind light = tg.light fog = tg.fog - warmth = tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0) + warmth = tg.warmth + _cold_extra() ground_wet = 1.0 if state in ["rain", "storm"] else 0.0 snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0) @@ -128,7 +152,7 @@ func _pick_state() -> void: if frozen and s.is_empty(): s = "clear" if s.is_empty(): - s = _state_for(front_at(world_seconds()), cold_day) + s = _state_for(front_at(world_seconds()) + (HARD_WINTER_FRONT if hard_winter(day_night.day) else 0.0), cold_day) if s != state: state = s state_changed.emit(state) @@ -145,7 +169,7 @@ func step(dt: float) -> void: wind = move_toward(wind, tg.wind, k) light = move_toward(light, tg.light, k * 0.8) fog = move_toward(fog, tg.fog, k * 3.0) - warmth = move_toward(warmth, tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0), k * 20.0) + warmth = move_toward(warmth, tg.warmth + _cold_extra(), k * 20.0) # The ground soaks in a couple of minutes of rain and dries over ten. var raining := precip > 0.2 and not snowing_globally() ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0)) @@ -163,6 +187,22 @@ func step(dt: float) -> void: flash = 1.0 lightning_count += 1 +## Extra cold from the day: a cold day, and a hard winter on top. +func _cold_extra() -> float: + if not cold_day: + return 0.0 + return COLD_DAY_WARMTH + (HARD_WINTER_WARMTH if day_night != null and hard_winter(day_night.day) else 0.0) + +func in_hard_winter() -> bool: + return day_night != null and hard_winter(day_night.day) + +## Extra warmth lost by someone out in a blizzard's wind (0 when sheltered or +## when it isn't blowing). +func blizzard_chill(sheltered: bool) -> float: + if sheltered or state != "blizzard": + return 0.0 + return BLIZZARD_CHILL * clampf(precip, 0.0, 1.0) + func snowing_globally() -> bool: return state in ["snow", "blizzard"] @@ -188,4 +228,6 @@ func label() -> String: var s: String = names[state] if cold_day and state in ["clear", "cloudy"]: s += ", cold" + if in_hard_winter(): + s += " (hard winter)" return s diff --git a/scripts/world/world.gd b/scripts/world/world.gd index e0c05d1..7a3b65c 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -26,6 +26,7 @@ const PIECES := { "bed": preload("res://scripts/building/bed.gd"), "kiln": preload("res://scripts/building/processor.gd"), "smelter": preload("res://scripts/building/processor.gd"), + "smoker": preload("res://scripts/building/processor.gd"), "forge": preload("res://scripts/building/forge.gd"), "hearth": preload("res://scripts/building/hearth.gd"), "rug": preload("res://scripts/building/rug.gd"), @@ -65,6 +66,7 @@ var bobber: Node3D ## bobber.gd: the cast fishing float, if any var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site) var bears: Node3D ## bear_range.gd: the island's bear (null online as a client) var thieves: Node3D ## thieves.gd: crows and foxes after food left out +var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4) var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run) var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does) var net_sync: Node ## net_sync.gd when playing on a server, else null @@ -165,7 +167,7 @@ func air_warmth(pos: Vector3, sheltered: bool = false) -> float: if sheltered: w += ww * 0.25 + 8.0 else: - w += ww + w += ww - weather.blizzard_chill(false) # (P4: the wind cuts through you) return w ## 0..1 heat from fires and a held torch. @@ -326,6 +328,7 @@ func skip_time(secs: float) -> void: for f in get_tree().get_nodes_in_group("heat"): if f.has_method("burn"): f.burn(secs) + spoil_all() # (food goes off overnight too) func _on_terrain_ready() -> void: var t0 := Time.get_ticks_msec() @@ -357,6 +360,12 @@ func _on_terrain_ready() -> void: print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0)) if menu_mode: return + # Weather hazards (P4): lightning on trees, the hard winter's ice. Decided + # here unless we're a player's game online (then the server tells us). + hazards = preload("res://scripts/world/hazards.gd").new() + hazards.name = "Hazards" + add_child(hazards) + hazards.setup(self, not is_online()) if server_mode: if Saves.has_world(): var saved: Dictionary = Saves.load_state(Saves.current_id) @@ -376,6 +385,7 @@ func _on_terrain_ready() -> void: _setup_bear() apply_rules() return + _apply_world_rot() _respawn() if Saves.has_world(): var state: Dictionary = Saves.load_state(Saves.current_id) @@ -430,6 +440,48 @@ func apply_rules() -> void: wolves.pack_bonus = int(R.difficulty("pack")) for p in PlayerNet.proxies.values(): p.survival.drain_mult = R.difficulty("drain") + Inventory.rot_speed = ROT_SPEEDS.get(R.rot(), 1.0) + +# --- food spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ---------------------------- + +const ROT_SPEEDS := {"normal": 1.0, "slow": 0.5, "off": 0.0} +const SPOIL_EVERY := 2.0 +var _spoil_left := 0.0 + +## Keeps Inventory's clock (food's "rot" stamps) on world time. Players' +## games online run it too (for showing freshness; the server does the rotting). +func _tick_clock() -> void: + Inventory.day_seconds = float(day_night.day_length) + Inventory.clock = weather.world_seconds() + +## The single-player world's rot setting, from its meta.json (a server's +## comes from its rules: apply_rules()). +func _apply_world_rot() -> void: + var r := String(Saves.read_meta(Saves.current_id).get("rot", "normal")) if Saves.has_world() else "normal" + Inventory.rot_speed = ROT_SPEEDS.get(r, 1.0) + +## Food rots: in everyone's bags, in chests, graves and racks, and lying on +## the ground (where rotten food just goes). Where the game decides (single +## player, a server); players' games online are sent the result. +func spoil_all() -> void: + if net_sync != null or menu_mode: + return + _tick_clock() + for p in players(): + if p.get("inventory") != null: + p.inventory.spoil() + for piece in all_pieces(): + if piece.get("inventory") != null: + piece.inventory.spoil() + if Inventory.rot_speed <= 0.0: + return + for d in all_dropped(): + if Inventory.keeps_days(d.item_id) <= 0.0: + continue + if not d.extra.has("rot"): + d.extra.rot = Inventory.fresh_rot(d.item_id) + elif Inventory.clock >= float(d.extra.rot): + take_dropped(d) func _process(delta: float) -> void: if menu_mode: @@ -442,6 +494,11 @@ func _process(delta: float) -> void: var lake: Vector2 = terrain.lake_center _menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP) return + _tick_clock() + _spoil_left -= delta + if _spoil_left <= 0.0: + _spoil_left = SPOIL_EVERY + spoil_all() if not Saves.has_world() or get_tree().paused: return _session_time += delta @@ -619,8 +676,8 @@ func add_dropped_entry(e: Dictionary) -> Node3D: if ItemDB.get_item(e.get("item", "")).is_empty(): return null var ex: Dictionary = {} - if e.get("extra") is Dictionary and e.extra.has("dur"): - ex = {"dur": int(e.extra.dur)} + if e.get("extra") is Dictionary: + ex = Inventory.extra_of(e.extra) # (a worn tool's "dur", food's "rot") var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf), ex) if e.has("nid"): d.set_meta("nid", int(e.nid)) @@ -781,6 +838,8 @@ func _update_grass_cuts() -> void: func water_at(x: float, z: float) -> Dictionary: var ground: float = terrain.sample_height(x, z) if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level: + if hazards != null and hazards.iced(x, z): + return {} # (frozen over in a hard winter: cast further out) return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground} if terrain.sea_weight(x, z) > 0.5 and ground < terrain.sea_level: return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground} diff --git a/tests/run_all.sh b/tests/run_all.sh index bf116b3..6faa406 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_alpha test_bear test_thieves test_barrow 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_bear test_thieves test_barrow test_den_sites test_fishing_diet test_food test_hazards 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_creatures_combat.gd b/tests/test_creatures_combat.gd index 3adaff6..3feb8f0 100644 --- a/tests/test_creatures_combat.gd +++ b/tests/test_creatures_combat.gd @@ -265,6 +265,7 @@ func _body(world: Node3D) -> void: A.eat(player, 0) check(sv.health >= 1.0 and sv.health <= 2.0, "raw meat can hurt, but never kills (health %.0f)" % sv.health) sv.health = 60.0 + sv.sick = 0.0 # (raw meat may have made you ill: tests/test_food.gd covers that) _hold(player, "cooked_meat") A.eat(player, 0) check(sv.buffs.has("well_fed") and sv.buff_mult("stamina_regen") > 1.2, "cooked meat makes you Well fed") diff --git a/tests/test_food.gd b/tests/test_food.gd new file mode 100644 index 0000000..f71c157 --- /dev/null +++ b/tests/test_food.gd @@ -0,0 +1,365 @@ +extends SceneTree +## P4 food (docs/PROGRESSION_DESIGN.md §5): food spoils on schedule wherever +## it is (bag, chest, the ground), stacks average their freshness, the +## smoking rack and salt preserve it, raw meat and fish can make you ill, +## and a world (or server) can make food rot slowly or not at all. +## Run: godot --headless --path . --script res://tests/test_food.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 inv: Inventory +const DAY := 1200.0 + +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("Food", 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) + Inventory.rot_speed = 1.0 + 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() + if world.wolves != null: + world.wolves.enabled = false + world.bears.enabled = false + if world.bears.bear != null: + world.bears.bear.queue_free() + world.thieves.enabled = false + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + inv = player.inventory + +func _reload() -> void: + check(world.save_now(), "saved") + world.queue_free() + await process_frame + await _load() + +func _clear() -> void: + inv.load_data(Inventory.new(40).to_data()) + +func _slot_of(id: String) -> int: + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + return i + return -1 + +func _place(id: String, side: float) -> Node3D: + var at: Vector3 = player.global_position + player.global_basis.x * side - player.global_basis.z * 3.0 + at.y = world.terrain.sample_height(at.x, at.z) + return world.spawn_piece(DB.get_item(id), Transform3D(Basis(), at)) + +## Moves the world on `secs` (like sleeping): food rots in the meantime. +func _pass(secs: float) -> void: + world.skip_time(secs) + +func _body() -> void: + _data() + _stamps() + await _rotting() + await _in_chests_and_on_the_ground() + await _keeping() + await _poisoning() + await _settings() + await _saving() + _finished = true + +# --- what keeps how long -------------------------------------------------------------- + +func _data() -> void: + print("how long food keeps") + var want := {"raw_meat": 2, "perch": 2, "trout": 2, "berries": 3, "mushroom": 4, "cooked_meat": 4, + "grilled_trout": 4, "roasted_mushroom": 6, "smoked_meat": 12, "smoked_fish": 12, "salted_meat": 20, "salted_fish": 20} + var wrong := [] + for id in want: + if absf(Inventory.keeps_days(id) - float(want[id])) > 0.01: + wrong.append("%s=%s" % [id, Inventory.keeps_days(id)]) + check(wrong.is_empty(), "raw meat and fish 2 days, berries 3, cooked 4, smoked 12, salted 20 (wrong: %s)" % [wrong]) + check(Inventory.keeps_days("wood") == 0.0 and Inventory.keeps_days("rotten_food") == 0.0, "wood (and rotten food) never spoil") + check(DB.get_item("rotten_food").get("food") == null, "rotten food isn't food") + for m in ["rotten_food", "salt", "smoking_rack", "smoked_meat", "smoked_fish", "salted_meat", "salted_fish"]: + check(DB.mesh_for(m).get_aabb().size.length() > 0.05 and DB.mesh_for(m).get_aabb().size.length() != DB.mesh_for("nonexistent_x").get_aabb().size.length(), "%s has its own mesh" % m) + +func _stamps() -> void: + print("freshness") + _clear() + world._tick_clock() + var t0: float = Inventory.clock + inv.add("raw_meat", 4) + var s = inv.get_slot(_slot_of("raw_meat")) + check(s.has("rot") and absf(float(s.rot) - (t0 + 2.0 * DAY)) < 1.5, "raw meat put in your bag rots in 2 days (stamp %.0f, now %.0f)" % [float(s.get("rot", 0)), t0]) + check(Inventory.freshness(s) > 0.99 and absf(Inventory.hours_left(s) - 48.0) < 0.1, "fresh: 48 hours left") + check(not inv.get_slot(_slot_of("raw_meat")).has("dur"), "(and no wear)") + inv.add("wood", 5) + check(not inv.get_slot(_slot_of("wood")).has("rot"), "wood gets no stamp") + # A day later, 4 more: the stack is halfway between. + Inventory.clock = t0 + DAY + inv.add("raw_meat", 4, -1.0) + s = inv.get_slot(_slot_of("raw_meat")) + check(int(s.count) == 8 and absf(float(s.rot) - (t0 + 2.5 * DAY)) < 1.5, "4 fresh added a day later: 8 that rot in 1.5 days (the average)") + var Slot: Script = load("res://scripts/ui/slot.gd") + check(Slot.freshness_text(s).begins_with("Going off") or Slot.freshness_text(s).begins_with("Fresh"), "the tooltip says so: \"%s\"" % Slot.freshness_text(s)) + print("splitting and moving") + var cursor = inv.click(_slot_of("raw_meat"), null, true) # (right-click: pick up half) + check(cursor != null and int(cursor.count) == 4 and float(cursor.rot) == float(s.rot), "half a stack keeps its freshness") + var empty := inv.slots.find(null) + cursor = inv.click(empty, cursor, true) # (right-click: put one down) + check(inv.get_slot(empty).has("rot") and float(inv.get_slot(empty).rot) == float(s.rot), "so does one put down on its own") + var fresh := {"id": "raw_meat", "count": 3, "rot": Inventory.clock + 2.0 * DAY} + var other := inv.slots.find(null) + inv.set_slot(other, fresh) + cursor = inv.click(other, cursor, false) # (merge the 3 left on the cursor into the fresh 3) + check(cursor == null and int(inv.get_slot(other).count) == 6 and absf(float(inv.get_slot(other).rot) - (Inventory.clock + 1.75 * DAY)) < 1.5, + "dropping older food onto fresher food averages them") + var box := Inventory.new(8) + inv.quick_move(other, box) + check(box.get_slot(0) != null and absf(float(box.get_slot(0).rot) - (Inventory.clock + 1.75 * DAY)) < 1.5, "shift-click into a chest: same freshness") + Inventory.clock = t0 + _clear() + +func _rotting() -> void: + print("rotting") + _clear() + world._tick_clock() + inv.add("raw_meat", 3) + inv.add("cooked_meat", 2) + inv.add("berries", 5) + inv.add("wood", 4) + _pass(DAY * 1.9) + check(inv.count("raw_meat") == 3, "a little under 2 days: the raw meat is still good") + check(Inventory.freshness(inv.get_slot(_slot_of("raw_meat"))) < 0.1, "but nearly rotten") + _pass(DAY * 0.2) + check(inv.count("raw_meat") == 0 and inv.count("rotten_food") == 3, "past 2 days: 3 rotten food") + check(inv.count("cooked_meat") == 2 and inv.count("berries") == 5 and inv.count("wood") == 4, "cooked meat and berries keep longer (and wood forever)") + _pass(DAY * 1.1) + check(inv.count("berries") == 0 and inv.count("cooked_meat") == 2, "berries go after 3 days") + _pass(DAY * 1.0) + check(inv.count("cooked_meat") == 0 and inv.count("rotten_food") == 10, "cooked meat after 4 (10 rotten food now)") + _clear() + +func _in_chests_and_on_the_ground() -> void: + print("in a chest") + world._tick_clock() + var chest := _place("chest", 3.0) + chest.inventory.add("trout", 2) + _pass(DAY * 2.1) + check(chest.inventory.count("trout") == 0 and chest.inventory.count("rotten_food") == 2, "food rots in a chest too") + world.remove_piece(chest) + print("on the ground") + _clear() + world._tick_clock() + inv.add("cooked_meat", 2) + var st: Dictionary = inv.get_slot(_slot_of("cooked_meat")) + var stamp := float(st.rot) + _pass(DAY * 1.0) + var at: Vector3 = player.global_position - player.global_basis.z * 1.2 + Vector3(0, 0.2, 0) + var dropped: Node3D = world.drop_item("cooked_meat", 2, Transform3D(Basis(), at), Inventory.extra_of(st)) + _clear() + _pass(DAY * 1.0) + check(not dropped.taken and float(dropped.extra.rot) == stamp, "dropped, it keeps its stamp (a day in the bag, a day on the ground)") + check(A.pickup(player, dropped), "picked up again") + check(float(inv.get_slot(_slot_of("cooked_meat")).rot) == stamp, "and it's just as old as it was") + var st2: Dictionary = inv.get_slot(_slot_of("cooked_meat")) + var d2: Node3D = world.drop_item("cooked_meat", 2, Transform3D(Basis(), at), Inventory.extra_of(st2)) + _clear() + _pass(DAY * 2.1) + check(d2.taken or not is_instance_valid(d2), "left on the ground past its time, it rots away") + +func _keeping() -> void: + print("the smoking rack") + _clear() + world._tick_clock() + inv.add("stick", 6) + inv.add("wood", 6) + inv.add("fiber", 4) + check(A.craft(player, "smoking_rack"), "made by hand (6 sticks, 4 wood, 4 fibre)") + var rack := _place("smoking_rack", -3.0) + check(rack != null and rack.get("inventory") != null, "placed, it opens like a chest") + rack.inventory.add("raw_meat", 4) + check(not rack.working() and rack.interact_prompt(player).contains("wood"), "no wood, no smoke: \"%s\"" % rack.interact_prompt(player)) + rack.inventory.add("wood", 2) + check(rack.working(), "with wood it gets to work") + rack.step(59.0) + check(rack.inventory.count("smoked_meat") == 0, "not yet at 59 s") + rack.step(62.0) + check(rack.inventory.count("smoked_meat") == 4 and rack.inventory.count("raw_meat") == 0 and rack.inventory.count("wood") == 0, + "2 meat and a wood a minute: 4 smoked meat") + rack.inventory.add("perch", 2) + rack.inventory.add("trout", 1) + rack.inventory.add("wood", 2) + rack.step(121.0) + check(rack.inventory.count("smoked_fish") == 4, "and fish (2 perch, or 1 trout, make 2 smoked fish)") + _pass(DAY * 5.0) + check(rack.inventory.count("smoked_meat") == 4 and Inventory.freshness(rack.inventory.get_slot(0)) > 0.5, "smoked meat keeps on the rack: still good after 5 days") + _pass(DAY * 7.5) + check(rack.inventory.count("smoked_meat") == 0, "(but not forever: 12 days)") + world.remove_piece(rack) + print("salt") + var salt: Array = world.props.pickups.values().filter(func(p): return p.item_id == "salt") + check(salt.size() >= 20, "%d salt crusts on the island" % salt.size()) + var by_sea := salt.filter(func(p): return world.terrain.sea_weight(p.global_position.x, p.global_position.z) > 0.5).size() + check(by_sea == salt.size(), "all of them up above the sea cliffs") + check(world.props.pickups.has("p0") and world.props.pickups["p0"].item_id == "stick", "(older pickups keep their ids)") + _clear() + world._tick_clock() + inv.add("raw_meat", 2) + inv.add("salt", 1) + inv.add("perch", 2) + inv.add("trout", 1) + inv.add("salt", 2) + check(A.craft(player, "salted_meat") and inv.count("salted_meat") == 2, "2 raw meat and a salt: 2 salted meat") + check(A.craft(player, "salted_fish") and A.craft(player, "salted_trout") and inv.count("salted_fish") == 4, "and salted fish (perch or trout)") + _pass(DAY * 15.0) + check(inv.count("salted_meat") == 2 and inv.count("salted_fish") == 4, "15 days on, still good") + _clear() + +func _poisoning() -> void: + print("food poisoning") + var sv: Node = player.survival + A.food_rng.seed = 12345 + _clear() + world._tick_clock() + inv.add("cooked_meat", 20) + var bad := 0 + for _i in range(20): + sv.hunger = 10.0 + sv.sick = 0.0 + A.eat(player, _slot_of("cooked_meat")) + if sv.sick > 0.0: + bad += 1 + check(bad == 0, "20 cooked meals: never ill") + inv.add("raw_meat", 20) + var tries := 0 + sv.sick = 0.0 + while sv.sick <= 0.0 and tries < 20: + sv.hunger = 10.0 + A.eat(player, _slot_of("raw_meat")) + tries += 1 + check(sv.sick > 0.0, "raw meat: ill after %d" % tries) + sv._update_statuses() + check("Food poisoning" in sv.statuses, "the HUD says Food poisoning (%s)" % [sv.statuses]) + var n := inv.count("raw_meat") + _toasts.clear() + sv.hunger = 10.0 + check(not A.eat(player, _slot_of("raw_meat")) and inv.count("raw_meat") == n, "you can't eat while you're ill") + check(_toasts.any(func(t): return t.contains("too sick")), "\"%s\"" % [_toasts]) + sv.stamina = 0.0 + sv._stamina_wait = 0.0 + sv.step(1.0) + var ill_gain: float = sv.stamina + sv.sick = 0.0 + sv.stamina = 0.0 + sv._stamina_wait = 0.0 + sv.step(1.0) + check(ill_gain > 0.0 and absf(ill_gain * 2.0 - sv.stamina) < 0.05, "and stamina comes back at half speed (%.2f vs %.2f a second)" % [ill_gain, sv.stamina]) + sv.poison() + for _i in range(61): + sv.step(1.0) + check(sv.sick == 0.0 and A.eat(player, _slot_of("raw_meat")) or sv.hunger >= A.FULL_HUNGER, "a minute later you can eat again") + var risky := 0 + var tried := 0 + A.food_rng.seed = 1 + for _i in range(200): + if A.food_rng.randf() < 0.35: + risky += 1 + tried += 1 + check(risky > 50 and risky < 90, "(about 1 raw meat in 3 makes you ill: %d of %d)" % [risky, tried]) + for id in ["perch", "trout"]: + check(float(DB.get_item(id).food.get("risk", 0.0)) > 0.0, "raw %s is risky too" % id) + for id in ["smoked_meat", "salted_meat", "smoked_fish", "salted_fish", "berries"]: + check(float(DB.get_item(id).food.get("risk", 0.0)) == 0.0, "%s is safe" % id) + sv.sick = 0.0 + _clear() + +func _settings() -> void: + print("rot settings") + check(Inventory.rot_speed == 1.0, "a new world rots normally") + var meta: Dictionary = S.read_meta(S.current_id) + meta["rot"] = "slow" + S._write_json(S._dir(S.current_id).path_join("meta.json"), meta) + world._apply_world_rot() + check(Inventory.rot_speed == 0.5, "slow: half speed") + _clear() + world._tick_clock() + inv.add("raw_meat", 2) + check(absf(Inventory.hours_left(inv.get_slot(0)) - 96.0) < 0.1, "raw meat now lasts 4 days") + _pass(DAY * 3.0) + check(inv.count("raw_meat") == 2, "and is still good after 3") + meta["rot"] = "off" + S._write_json(S._dir(S.current_id).path_join("meta.json"), meta) + world._apply_world_rot() + _pass(DAY * 10.0) + check(inv.count("raw_meat") == 2 and Inventory.rot_speed == 0.0, "off: nothing rots, ever") + inv.add("berries", 3) + check(not inv.get_slot(_slot_of("berries")).has("rot"), "(and new food isn't even stamped)") + meta["rot"] = "normal" + S._write_json(S._dir(S.current_id).path_join("meta.json"), meta) + world._apply_world_rot() + var id: String = S.create_world("Slow Island", 99, {"rot": "slow"}) + check(String(S.read_meta(id).get("rot", "")) == "slow", "a new world remembers its choice") + S.delete_world(id) + var cfg: RefCounted = load("res://scripts/net/server_config.gd").new() + cfg.data = cfg.DEFAULTS.duplicate(true) + check(String(cfg.data.rot) == "normal", "a server rots normally unless told") + cfg.path = "user://test_worlds/_cfg.json" + check(cfg.set_rule("rot", "slow") == "" and String(cfg.v("rot")) == "slow", "an admin can /set rot slow") + check(cfg.set_rule("rot", "fast") != "", "(but not to nonsense)") + check("rot" in cfg.PUBLIC, "and players are told the server's setting") + DirAccess.remove_absolute("user://test_worlds/_cfg.json") + _clear() + +func _saving() -> void: + print("saving") + world._tick_clock() + _clear() + inv.add("raw_meat", 3) + var stamp := float(inv.get_slot(0).rot) + var chest := _place("chest", 3.0) + chest.inventory.add("berries", 4) + var cstamp := float(chest.inventory.get_slot(0).rot) + var at: Vector3 = chest.global_position + await _reload() + check(inv.count("raw_meat") == 3 and float(inv.get_slot(_slot_of("raw_meat")).rot) == stamp, "your food is as fresh after a reload as before") + var back: Node3D = null + for p in world.all_pieces(): + if p.item_id == "chest" and p.global_position.distance_to(at) < 0.1: + back = p + check(back != null and float(back.inventory.get_slot(0).rot) == cstamp, "and the chest's") + _pass(DAY * 2.1) + check(inv.count("rotten_food") == 3, "and rots on the same schedule") diff --git a/tests/test_hazards.gd b/tests/test_hazards.gd new file mode 100644 index 0000000..dd30366 --- /dev/null +++ b/tests/test_hazards.gd @@ -0,0 +1,289 @@ +extends SceneTree +## P4 weather hazards (docs/PROGRESSION_DESIGN.md §5): hard winters every 12 +## days (warnings, bitter cold, fewer animals, the lake's edge freezing), +## blizzards that freeze you outdoors but not in a hearth room, and +## lightning that sets trees (never buildings) on fire. +## Run: godot --headless --path . --script res://tests/test_hazards.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 W: Script + +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") + W = load("res://scripts/world/weather.gd") + 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("Hazards", 1337) + 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() + if world.wolves != null: + world.wolves.enabled = false + world.bears.enabled = false + world.thieves.enabled = false + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.hazards.set_physics_process(false) + 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 _set_time(day: int, hours: float) -> void: + world.day_night.day = day + world.day_night.time = hours / 24.0 + world.day_night.apply(true) + +func _toast_has(s: String) -> bool: + for t in _toasts: + if t.contains(s): + return true + return false + +func _body() -> void: + _calendar() + await _blizzard() + _animals() + await _ice() + await _lightning() + _finished = true + +# --- hard winters -------------------------------------------------------------------------- + +func _calendar() -> void: + print("hard winters") + var hw := [] + for d in range(1, 40): + if W.hard_winter(d): + hw.append(d) + check(hw == [12, 13, 14, 24, 25, 26, 36, 37, 38], "every 12 days, three in a row: %s" % [hw]) + check(W.next_hard_winter(3) == 12 and W.next_hard_winter(15) == 24 and W.next_hard_winter(25) == 25, "(the next one from any day)") + var wx: Node = world.weather + check(wx.is_cold_day(12) and wx.is_cold_day(13) and wx.is_cold_day(14), "they're all cold days") + _set_time(13, 12.0) + wx.force("clear") + var bitter: float = wx.warmth + var cold := -1 + for d in range(2, 12): + if wx.is_cold_day(d): + cold = d + check(cold > 0, "(an ordinary cold day for comparison: day %d)" % cold) + _set_time(cold, 12.0) + wx.force("clear") + check(bitter < wx.warmth - 5.0, "colder than an ordinary cold day (%.0f against %.0f)" % [bitter, wx.warmth]) + _set_time(13, 12.0) + wx.force("clear") + check(wx.label().contains("hard winter"), "the HUD says so: \"%s\"" % wx.label()) + var wet := 0 + var wet_hw := 0 + for i in range(600): + var t := (11.0 + i / 600.0 * 3.0) * 1200.0 + var t2 := (2.0 + i / 600.0 * 3.0) * 1200.0 + if wx.state_at(t) in ["snow", "blizzard"]: + wet_hw += 1 + if wx.state_at(t2) in ["rain", "storm", "snow", "blizzard"]: + wet += 1 + check(wet_hw > wet, "more snow in a hard winter (%d%% of the time, against %d%% wet weather on days 3-5)" % [wet_hw / 6, wet / 6]) + print("warnings") + _toasts.clear() + world.hazards._on_new_day(11) + check(_toast_has("hard winter is coming"), "the day before: %s" % [_toasts]) + _toasts.clear() + world.hazards._on_new_day(12) + check(_toast_has("has set in"), "on the day: %s" % [_toasts]) + _toasts.clear() + world.hazards._on_new_day(15) + check(_toast_has("is over"), "and when it's over") + _toasts.clear() + world.hazards._on_new_day(8) + check(_toasts.is_empty(), "(an ordinary day says nothing)") + +# --- blizzard ---------------------------------------------------------------------------- + +func _blizzard() -> void: + print("a blizzard") + var bs: Node = world.build + var sv: Node = player.survival + _set_time(13, 15.0) + var wx: Node = world.weather + wx.force("blizzard") + check(wx.fog >= 6.0 and wx.light < 0.5, "you can barely see (fog x%.1f)" % wx.fog) + var site: Vector3 = bs.debug_find_site(player.global_position - player.global_basis.z * 16.0, 3.0) + var yaw: float = player.rotation.y + var hut: Dictionary = await bs.debug_build_hut(site, yaw, "stone") + for _i in range(3): + await physics_frame + check(hut.placed == 20, "a stone hut (%d pieces)" % hut.placed) + var b := Basis(Vector3.UP, yaw) + var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) + var hearth: Node3D = world.spawn_piece(DB.get_item("stone_hearth"), Transform3D(b, center + b * Vector3(0, 0, -1.25))) + hearth.fuel = hearth.MAX_FUEL + var inside: Vector3 = center + b * Vector3(0.6, 0.05, 0.8) + var outside: Vector3 = site + b * Vector3(9, 0, 9) + outside.y = world.terrain.sample_height(outside.x, outside.z) + player.inventory.load_data(Inventory.new(40).to_data()) + # Outside: no fire, no coat. + player.global_position = outside + sv.warmth = 70.0 + sv.wet = 0.0 + sv._shelter_left = 0.0 + for _i in range(15): + sv.step(1.0) + var lost: float = 70.0 - sv.warmth + check(lost > 45.0, "outside, a blizzard strips your warmth: %.0f lost in 15 s" % lost) + for _i in range(30): + sv.step(1.0) + check(sv.warmth < 12.0 and "Freezing" in sv.statuses, "soon you're Freezing (%.0f)" % sv.warmth) + player.worn = "hide_cloak" + for _i in range(40): + sv.step(1.0) + check(sv.warmth >= 12.0 and not "Freezing" in sv.statuses, "a hide cloak keeps you out of Freezing (%.0f)" % sv.warmth) + player.worn = "" + var air_out: float = world.air_warmth(outside, false) + wx.force("snow") + var air_snow: float = world.air_warmth(outside, false) + wx.force("blizzard") + check(air_out < air_snow - 10.0, "the wind makes it far colder than plain snow (%.0f against %.0f)" % [air_out, air_snow]) + # Inside the stone hut by the hearth. + player.global_position = inside + sv.warmth = 20.0 + sv._shelter_left = 0.0 + for _i in range(30): + sv.step(1.0) + check(sv.sheltered and "Warm" in sv.statuses and sv.warmth > 60.0, + "in the hearth room it's Sheltered and Warm: %.0f (%s)" % [sv.warmth, sv.statuses]) + hearth._set_lit(false) + sv._shelter_left = 0.0 + sv.step(0.5) + check(world.air_warmth(inside, true) > air_out + 30.0, "even with the fire out, walls keep the wind off (%.0f inside, %.0f out)" % [world.air_warmth(inside, true), air_out]) + sv.health = 100.0 + wx.force("") + for p in hut.pieces: + if is_instance_valid(p): + p.queue_free() + hearth.queue_free() + for _i in range(3): + await physics_frame + +func _animals() -> void: + print("animals") + var sp: Node = world.creatures + _set_time(5, 12.0) + var normal: int = sp.winter_budget("deer", 1) + _set_time(13, 12.0) + var winter: int = sp.winter_budget("deer", 1) + check(winter * 2 == normal, "half as many deer in a hard winter (%d, not %d)" % [winter, normal]) + check(sp.winter_budget("boar", 1) >= 1, "(but never none)") + +func _ice() -> void: + print("the lake freezes") + var hz: Node = world.hazards + var t: Node3D = world.terrain + var c: Vector2 = t.lake_center + _set_time(5, 12.0) + hz.step(0.1) + check(not hz.lake_frozen(), "not on an ordinary day") + # Somewhere in the lake near its edge, and its middle. + var edge := Vector3.INF + for i in range(72): + var a := TAU * i / 72.0 + for k in [0.95, 0.9, 0.85, 0.8, 0.75]: + var x: float = c.x + cos(a) * t.lake_radius * k + var z: float = c.y + sin(a) * t.lake_radius * k + if t.sample_height(x, z) < t.water_level - 0.3: + edge = Vector3(x, t.water_level, z) + break + if edge != Vector3.INF: + break + var mid := Vector3(c.x, t.water_level, c.y) + check(edge != Vector3.INF and not world.water_at(edge.x, edge.z).is_empty(), "(open water by the edge, %.0f m out)" % Vector2(edge.x - c.x, edge.z - c.y).length()) + _set_time(13, 12.0) + hz.step(0.1) + await physics_frame + await physics_frame + check(hz.lake_frozen(), "in a hard winter it's frozen over at the edge") + check(world.water_at(edge.x, edge.z).is_empty(), "no fishing through the ice there") + check(not world.water_at(mid.x, mid.z).is_empty() or t.sample_height(mid.x, mid.z) >= t.water_level, "but the middle is still open water") + var q := PhysicsRayQueryParameters3D.create(edge + Vector3(0, 3, 0), edge + Vector3(0, -3, 0), 1) + var hit := world.get_world_3d().direct_space_state.intersect_ray(q) + check(not hit.is_empty() and absf(hit.position.y - t.water_level) < 0.1, "and you can walk on the ice") + _set_time(16, 12.0) + hz.step(0.1) + await physics_frame + await physics_frame + hit = world.get_world_3d().direct_space_state.intersect_ray(q) + check(not hz.lake_frozen() and (hit.is_empty() or hit.position.y < t.water_level - 0.2), "after it, the ice is gone") + +# --- lightning ------------------------------------------------------------------------------- + +func _lightning() -> void: + print("lightning") + var hz: Node = world.hazards + var wx: Node = world.weather + _set_time(6, 14.0) + # Stand among trees near the rim. + var trees: Array = world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted) + trees.sort_custom(func(a, b): return world.props._near_rim(a.global_position.x, a.global_position.z) > world.props._near_rim(b.global_position.x, b.global_position.z)) + var at: Vector3 = trees[0].global_position + Vector3(4, 0, 0) + at.y = world.terrain.sample_height(at.x, at.z) + player.global_position = at + wx.force("clear") + hz._strike_left = 0.0 + hz.step(0.1) + check(hz.strikes == 0, "no lightning on a clear day") + wx.force("storm") + _toasts.clear() + var before: int = world.all_dropped().filter(func(d): return d.item_id == "charcoal").size() + hz._strike_left = 0.0 + hz.step(0.1) + check(hz.strikes == 1 and hz._burning.size() == 1, "in a storm, a bolt hits a tree") + var tree: Node3D = hz._burning.keys()[0] + check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at)) + check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts]) + check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it") + check(not tree.depleted, "still standing while it burns") + for _i in range(41): + hz.step(1.0) + await physics_frame + check(tree.depleted and not hz.burning(tree), "then it falls (%s)" % ("felled" if tree.depleted else "standing")) + var coal: Array = world.all_dropped().filter(func(d): return d.item_id == "charcoal") + check(coal.size() == before + 1 and int(coal.back().amount) == 2 and coal.back().global_position.distance_to(tree.global_position) < 2.0, "leaving 2 charcoal at its foot") + print("a long storm") + var n0: int = hz.strikes + var pieces0: int = world.all_pieces().size() + var wall: Node3D = world.spawn_piece(DB.get_item("wood_wall"), Transform3D(Basis(), at + Vector3(0, 0, 3))) + for _i in range(600): + hz.step(1.0) + var n: int = hz.strikes - n0 + check(n >= 4 and n <= 13, "about one strike a minute or so (%d in 10 minutes)" % n) + check(is_instance_valid(wall) and not wall.is_queued_for_deletion() and world.all_pieces().size() == pieces0 + 1, "it never strikes a building") + wx.force("") diff --git a/tests/test_weather_building.gd b/tests/test_weather_building.gd index 9e2abb1..aa1e01a 100644 --- a/tests/test_weather_building.gd +++ b/tests/test_weather_building.gd @@ -59,20 +59,27 @@ func _weather_model() -> void: var cold_days := 0 var snow_on_warm_day := false var days := 60 + var samples := 0 var t := 0.0 while t < days * 1200.0: var s: String = wa.state_at(t) if s != wb.state_at(t): same = false - counts[s] = counts.get(s, 0) + 1 var day := int(t / 1200.0) + 1 + if not wa.hard_winter(day): # (the mix over ordinary days; hard winters lean snowy) + counts[s] = counts.get(s, 0) + 1 + samples += 1 if s in ["snow", "blizzard"] and not wa.is_cold_day(day): snow_on_warm_day = true t += 20.0 + var ordinary := 0 for d in range(1, days + 1): + if wa.hard_winter(d): + continue # (P4's hard winters are always cold: tests/test_hazards.gd) + ordinary += 1 if wa.is_cold_day(d): cold_days += 1 - var total := float(days * 60) + var total := float(samples) var frac := func(names: Array) -> float: var n := 0 for k in names: @@ -84,7 +91,7 @@ func _weather_model() -> void: check(wet > 0.2 and wet < 0.4, "rain or snow about 30%% of the time (%.0f%%)" % (wet * 100)) var hard: float = frac.call(["storm", "blizzard"]) check(hard > 0.04 and hard < 0.16, "storms about 10%% of the time (%.0f%%)" % (hard * 100)) - check(cold_days >= 8 and cold_days <= 24, "about a quarter of days are cold (%d of %d)" % [cold_days, days]) + check(cold_days >= ordinary * 0.12 and cold_days <= ordinary * 0.4, "about a quarter of ordinary days are cold (%d of %d)" % [cold_days, ordinary]) check(not snow_on_warm_day, "it only snows in the valley on cold days") check(wa.state_at(0.0) == "clear" and wa.state_at(500.0) == "clear", "a new world starts with clear weather") var other: Node = load("res://scripts/world/weather.gd").new()