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)

This commit is contained in:
Paul Bailey
2026-11-05 11:49:13 +00:00
parent 657c3140fe
commit 3389ec9a21
27 changed files with 1541 additions and 56 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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