T4: Old Tam's tips in normal worlds, with a setting

This commit is contained in:
Paul Bailey
2026-11-05 16:22:38 +00:00
parent 73d27f1dcb
commit bd7d10a829
12 changed files with 462 additions and 4 deletions

38
data/hints.json Normal file
View File

@@ -0,0 +1,38 @@
{
"guide": "Old Tam",
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), or a plain check (night, stamina_low, wolf_moon, hard_winter). 'hold' is how long it must stay true first (seconds, default 1).",
"hints": [
{"id": "died", "when": "event:died", "title": "Back on your feet",
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
{"id": "freezing", "when": "status:Freezing", "title": "Freezing",
"text": "You're freezing, and that eats at your health. Get to a fire or under a roof, now. A cloak helps too."},
{"id": "cold", "when": "status:Cold", "hold": 3, "title": "Getting cold",
"text": "Feel that chill? A fire warms you, a roof and walls keep the wind off, and a hide cloak buys you time."},
{"id": "sick", "when": "status:Food poisoning", "title": "Bad food",
"text": "Raw meat or something past its best: your belly's turned. It passes. Rest, don't eat for a bit, and cook your meat next time."},
{"id": "rotten", "when": "has:rotten_food", "title": "Food goes off",
"text": "Food doesn't keep forever. Eat the fresh stuff first, smoke meat and fish on a rack, or salt it. Rotten food's no good to anyone: throw it out."},
{"id": "wet", "when": "status:Wet", "hold": 2, "title": "Soaked",
"text": "Wet clothes steal your warmth. Stand under a roof or by a fire and you'll dry off."},
{"id": "hungry", "when": "status:Hungry", "hold": 2, "title": "Hungry",
"text": "Your belly's empty. Berries and mushrooms tide you over, but cooked meat and fish fill you up for longer."},
{"id": "diet", "when": "status:Poor diet|Malnourished", "hold": 2, "title": "Mix it up",
"text": "Eat the same thing every day and your body notices. Watch the three diet bars: meat, fish and greens. Keep them all topped up and you'll be stronger for it."},
{"id": "howl", "when": "event:howl", "title": "Wolves",
"text": "Hear that? Wolves have moved into the hills. They'll come hunting soon after dark. Walls, a door and a fire are your friends."},
{"id": "wolf_moon", "when": "wolf_moon", "title": "The red moon",
"text": "A red moon. The pack's bolder tonight and the big one leads them. Stay behind a good door, or stand by a fire with a weapon ready."},
{"id": "night", "when": "night", "hold": 5, "title": "Nightfall",
"text": "Dark and cold till morning. Keep near a fire or carry a torch. A bed under a roof lets you sleep through it."},
{"id": "hard_winter", "when": "hard_winter", "hold": 3, "title": "Hard winter",
"text": "A hard winter's come in: a few days of bitter cold, and blizzards that freeze you even by day. Stock wood and food, and stay close to home."},
{"id": "snow", "when": "weather:snow|blizzard", "hold": 3, "title": "Snow",
"text": "Snow's falling. It's colder out in it, and heavy snow piles up on roofs. Keep your fire fed."},
{"id": "storm", "when": "weather:storm", "hold": 3, "title": "Storm",
"text": "A storm. Lightning goes for the trees, so don't shelter under one. Your buildings are safe enough."},
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."}
]
}

View File

@@ -1,8 +1,24 @@
# Tutorial world design # Tutorial world design
Status: **T1 and T2 built**: the whole tutorial, on the Valley island Status: **T1, T2 and T4 built**: the whole tutorial, on the Valley island
(seed 1337), with Old Tam the hermit. T3 (its own island) and T4 (hints in (seed 1337), with Old Tam the hermit, and his tips in normal worlds. T3
real worlds) are proposals. (its own island) waits for the worldlets.
*As built in T4:*
- 16 tips in data/hints.json, in Old Tam's voice: soaked, cold, freezing,
hungry, a poor diet, food poisoning, food gone rotten, nightfall, the
wolves' howl, the red moon, a hard winter, snow, a storm, out of
breath, a first proper shelter, and back on your feet after dying.
- The `Hints` autoload (scripts/core/hints.gd) checks your own survival
statuses, the weather, the clock and your bag, plus two events (a howl,
a death), so tips work the same online (the server mirrors statuses).
Each shows once per player (user://hints.json), in list order, 16 s
apart, never while you're dead, and not in the tutorial world or on a
server.
- A card top right (scripts/ui/hint_card.gd) for 14 s; H puts it away.
Pause menu: **Old Tam's tips** on/off and **Show again**. On by default.
- tests/test_hints.gd. Headless runs never start tips unless a test asks
(so the suites can't use up your real ones).
*As built in T2:* *As built in T2:*
- All five chapters, 22 steps (data/tutorial.json), in Old Tam's voice: - All five chapters, 22 steps (data/tutorial.json), in Old Tam's voice:

View File

@@ -26,6 +26,7 @@ Boot="*res://scripts/core/boot.gd"
ItemDB="*res://scripts/core/item_db.gd" ItemDB="*res://scripts/core/item_db.gd"
Actions="*res://scripts/core/actions.gd" Actions="*res://scripts/core/actions.gd"
Guide="*res://scripts/core/guide.gd" Guide="*res://scripts/core/guide.gd"
Hints="*res://scripts/core/hints.gd"
Saves="*res://scripts/core/saves.gd" Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd" Net="*res://scripts/net/net.gd"
WorldNet="*res://scripts/net/world_net.gd" WorldNet="*res://scripts/net/world_net.gd"

137
scripts/core/hints.gd Normal file
View File

@@ -0,0 +1,137 @@
extends Node
## T4 (docs/TUTORIAL_DESIGN.md §5): Old Tam's tips in normal worlds. Each tip
## (data/hints.json) shows once per player, the first time it fits: first
## rain, first night, the wolves' howl, a poor diet, a first death... They
## read only your own state (survival statuses, the weather, the clock, your
## bag) plus two events, so they work the same online. Not in the tutorial
## world (the tutorial covers it) or on a server. A setting turns them off.
## The HUD's hint card (scripts/ui/hint_card.gd) shows them.
signal shown(hint: Dictionary)
const GAP := 16.0 ## seconds between tips, so they don't pile up
const START_QUIET := 4.0 ## nothing in the first seconds of a world
var path := "user://hints.json" ## your settings: on or off, and the tips seen
var enabled := true
var seen: Array = []
var hints: Array = []
var guide_name := ""
var world: Node3D = null
var active := false
var _held: Dictionary = {} ## hint id -> seconds its condition has held
var _events: Dictionary = {} ## "howl" -> true, until the tip shows
var _cooldown := 0.0
func _ready() -> void:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/hints.json"))
if d is Dictionary:
hints = d.get("hints", [])
guide_name = String(d.get("guide", ""))
load_prefs()
func load_prefs() -> void:
enabled = true
seen = []
if not FileAccess.file_exists(path):
return
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if d is Dictionary:
enabled = bool(d.get("enabled", true))
for id in d.get("seen", []):
seen.append(String(id))
func save_prefs() -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify({"enabled": enabled, "seen": seen}, "\t"))
func set_enabled(on: bool) -> void:
enabled = on
save_prefs()
## Every tip can show again (the pause menu's "Show tips again").
func reset_seen() -> void:
seen = []
save_prefs()
var allow_headless := false ## tests: run with no screen (and their own `path`)
func start(w: Node3D) -> void:
if DisplayServer.get_name() == "headless" and not allow_headless:
return # (the other suites' worlds mustn't use up your real tips)
world = w
active = true
_held = {}
_events = {}
_cooldown = START_QUIET
func stop() -> void:
active = false
world = null
## Something that a condition can't read happened: "howl", "died".
func event(id: String) -> void:
if active:
_events[id] = true
func _process(delta: float) -> void:
if active:
step(delta)
func step(dt: float) -> void:
if not active or not enabled or world == null or not is_instance_valid(world) or Guide.active:
return
_cooldown -= dt
var due: Dictionary = {}
for h in hints:
var id := String(h.id)
if seen.has(id):
continue
if _met(String(h.when)):
_held[id] = float(_held.get(id, 0.0)) + dt
if due.is_empty() and _held[id] >= float(h.get("hold", 1.0)):
due = h
else:
_held[id] = 0.0
if not due.is_empty() and _cooldown <= 0.0:
_show(due)
func _show(h: Dictionary) -> void:
seen.append(String(h.id))
_events.erase(String(h.when).trim_prefix("event:"))
save_prefs()
_cooldown = GAP
shown.emit(h)
func _met(when: String) -> bool:
var player: Node = world.player
if player == null or not is_instance_valid(player):
return false
var dead: bool = world.dead_waiting
var kind := when.get_slice(":", 0)
var vals := when.get_slice(":", 1).split("|", false) if when.contains(":") else PackedStringArray()
match kind:
"event":
return not dead and _events.has(vals[0])
"status":
if dead:
return false
for s in player.survival.statuses:
for v in vals:
if String(s).begins_with(v):
return true
return false
"weather":
return not dead and world.weather != null and String(world.weather.state) in vals
"has":
return not dead and player.inventory.count(vals[0]) > 0
"night":
return not dead and world.day_night.is_night()
"stamina_low":
return not dead and player.survival.stamina < 8.0
"wolf_moon":
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
"hard_winter":
return not dead and world.weather != null and world.weather.in_hard_winter()
return false

View File

@@ -396,6 +396,7 @@ func howl() -> void:
if world.server_mode: if world.server_mode:
CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it
return return
Hints.event("howl")
if _audio == null: if _audio == null:
return return
var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper) var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper)

View File

@@ -250,6 +250,7 @@ func _s2c_howl(at: Vector3) -> void:
if not _alive_world(): if not _alive_world():
return return
howls_heard += 1 howls_heard += 1
Hints.event("howl")
if DisplayServer.get_name() == "headless": if DisplayServer.get_name() == "headless":
return return
if _howl_player == null or not is_instance_valid(_howl_player): if _howl_player == null or not is_instance_valid(_howl_player):

63
scripts/ui/hint_card.gd Normal file
View File

@@ -0,0 +1,63 @@
extends Control
## Old Tam's tips in normal worlds (scripts/core/hints.gd): a card top right,
## under the clock, for 14 seconds. H puts it away early.
const UITheme = preload("res://scripts/ui/theme.gd")
const SHOW_FOR := 14.0
var _box: PanelContainer
var _title: Label
var _text: Label
var _tween: Tween
func setup() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_box = UITheme.panel()
_box.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_box.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_box.offset_right = -22
_box.offset_top = 48
_box.custom_minimum_size = Vector2(340, 0)
_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
_box.modulate.a = 0.0
_box.visible = false
add_child(_box)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 4)
v.custom_minimum_size = Vector2(320, 0)
_box.add_child(v)
v.add_child(UITheme.label("%s's tip (H hides · tips can be turned off in the menu)" % Hints.guide_name if not Hints.guide_name.is_empty() else "Tip (H hides)", 13, UITheme.TEXT_DIM))
_title = UITheme.label("", 19)
v.add_child(_title)
_text = UITheme.label("", 15, UITheme.TEXT_DIM)
_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_text.custom_minimum_size = Vector2(320, 0)
v.add_child(_text)
Hints.shown.connect(show_hint)
func show_hint(h: Dictionary) -> void:
_title.text = String(h.title)
_text.text = ("\"%s\"" % String(h.text)) if not Hints.guide_name.is_empty() else String(h.text)
_box.visible = true
if _tween != null:
_tween.kill()
_tween = create_tween()
_tween.tween_property(_box, "modulate:a", 1.0, 0.4)
_tween.tween_interval(SHOW_FOR)
_tween.tween_property(_box, "modulate:a", 0.0, 0.8)
_tween.tween_callback(func(): _box.visible = false)
func is_showing() -> bool:
return _box.visible
func hide_hint() -> void:
if _tween != null:
_tween.kill()
_box.visible = false
_box.modulate.a = 0.0
func _unhandled_input(event: InputEvent) -> void:
if _box.visible and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
hide_hint()
get_viewport().set_input_as_handled()

View File

@@ -179,6 +179,11 @@ func _build_stats(root: Control) -> void:
guide.name = "Guide" guide.name = "Guide"
root.add_child(guide) root.add_child(guide)
guide.setup(player) guide.setup(player)
# Old Tam's tips in normal worlds (T4), in the same place.
var tips := preload("res://scripts/ui/hint_card.gd").new()
tips.name = "HintCard"
root.add_child(tips)
tips.setup()
func _process(_d: float) -> void: func _process(_d: float) -> void:
_fade_chat() _fade_chat()

View File

@@ -80,6 +80,26 @@ func bind(p: CharacterBody3D) -> void:
sens_row.add_child(sens) sens_row.add_child(sens)
_main.add_child(sens_row) _main.add_child(sens_row)
# Old Tam's tips in normal worlds (T4): on or off, or all of them again.
if not Guide.active:
var tips_row := HBoxContainer.new()
var tips := CheckButton.new()
tips.text = "Old Tam's tips"
tips.focus_mode = Control.FOCUS_NONE
tips.button_pressed = Hints.enabled
tips.add_theme_font_size_override("font_size", 18)
tips.size_flags_horizontal = Control.SIZE_EXPAND_FILL
tips.toggled.connect(Hints.set_enabled)
tips_row.add_child(tips)
var again := Button.new()
again.text = "Show again"
again.focus_mode = Control.FOCUS_NONE
again.add_theme_font_size_override("font_size", 15)
again.tooltip_text = "Every tip shows once more, when it next fits"
again.pressed.connect(func(): Hints.reset_seen(); again.text = "Done")
tips_row.add_child(again)
_main.add_child(tips_row)
# The tutorial: skip a step, or leave it (saved, to carry on later). # The tutorial: skip a step, or leave it (saved, to carry on later).
if Guide.active: if Guide.active:
_main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close())) _main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close()))

View File

@@ -187,6 +187,7 @@ func heat_at(pos: Vector3, holder: Node = null) -> float:
## Death: everything you carried goes into a grave where you fell, and you ## Death: everything you carried goes into a grave where you fell, and you
## wake up at the island start. The world carries on. ## wake up at the island start. The world carries on.
func _on_player_died() -> void: func _on_player_died() -> void:
Hints.event("died")
var pos := player.global_position var pos := player.global_position
pos.y = terrain.sample_height(pos.x, pos.z) pos.y = terrain.sample_height(pos.x, pos.z)
if pos.y < terrain.water_level: if pos.y < terrain.water_level:
@@ -245,6 +246,7 @@ func respawn_after_death() -> void:
## Online: the server says we died. Same screen; Respawn asks the server. ## Online: the server says we died. Same screen; Respawn asks the server.
func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void: func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
Hints.event("died")
dead_waiting = true dead_waiting = true
player.velocity = Vector3.ZERO player.velocity = Vector3.ZERO
player.set_physics_process(false) player.set_physics_process(false)
@@ -404,6 +406,10 @@ func _on_terrain_ready() -> void:
player.set_physics_process(true) player.set_physics_process(true)
set_physics_process(true) set_physics_process(true)
creatures.setup(self) creatures.setup(self)
# Old Tam's tips (T4): your own world or a server's, not the tutorial's.
if not server_mode and not menu_mode and not bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) \
and not "--tutorial" in OS.get_cmdline_user_args():
Hints.start(self)
if is_online(): if is_online():
_go_online() _go_online()
_maybe_capture() _maybe_capture()
@@ -553,6 +559,8 @@ func _exit_tree() -> void:
save_now() save_now()
if Guide.world == self: if Guide.world == self:
Guide.stop() Guide.stop()
if Hints.world == self:
Hints.stop()
func _notification(what: int) -> void: func _notification(what: int) -> void:
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode: if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi 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_boss 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_boss test_net_rules test_host test_tutorial test_tutorial_full 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_boss 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_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

168
tests/test_hints.gd Normal file
View File

@@ -0,0 +1,168 @@
extends SceneTree
## T4 (docs/TUTORIAL_DESIGN.md §5): Old Tam's tips in a normal world. Each
## fits its moment, shows once, waits its turn, stays put while you're dead,
## is remembered across sessions, can be switched off or shown again, and
## never runs in the tutorial world.
## Run: godot --headless --path . --script res://tests/test_hints.gd
var _fails := 0
var _finished := false
var H: Node
var S: Node
var world: Node3D
var player: CharacterBody3D
var sv: Node
var got: Array = []
var card: Control
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:
H = root.get_node("Hints")
S = root.get_node("Saves")
H.path = "user://test_hints.json"
H.allow_headless = true
DirAccess.remove_absolute(H.path)
H.load_prefs()
H.shown.connect(func(h): got.append(String(h.id)))
S.root_dir = "user://test_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
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)
DirAccess.remove_absolute(H.path)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _load(tutorial: bool) -> void:
if world != null:
world.queue_free()
await process_frame
S.current_id = S.create_world("Tips" if not tutorial else "Tutorial", 1337, {"tutorial": tutorial})
world = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(5):
await physics_frame
player = world.player
player.set_physics_process(false)
sv = player.survival
sv.set_process(false)
world.day_night.frozen = true
world.day_night.time = 10.0 / 24.0
world.day_night.apply(true)
world.weather.set_process(false)
world.weather.force("clear")
if world.wolves != null:
world.wolves.set_physics_process(false)
world.creatures.enabled = false
## Runs the tips for `secs` (as frames of 0.5 s), with your statuses fresh.
func _for(secs: float) -> void:
var t := 0.0
while t < secs:
sv._update_statuses()
H.step(0.5)
t += 0.5
func _body() -> void:
print("the tips")
var kinds := ["event", "status", "weather", "has", "night", "stamina_low", "wolf_moon", "hard_winter"]
var bad: Array = []
for h in H.hints:
if not String(h.when).get_slice(":", 0) in kinds or String(h.title).is_empty() or String(h.text).is_empty():
bad.append(h.id)
check(H.hints.size() >= 12 and bad.is_empty(), "%d tips, each with a title, text and a condition it knows %s" % [H.hints.size(), bad])
check(H.enabled and H.seen.is_empty(), "on by default, none seen yet")
print("a normal world")
await _load(false)
check(H.active and H.world == world, "they run in your own world")
card = load("res://scripts/ui/hint_card.gd").new()
root.add_child(card)
card.setup()
await _for(10.0)
check(got.is_empty(), "a calm, dry, fed morning: nothing to say")
sv.wet = 60.0
await _for(3.0)
check(got == ["wet"], "soaked: the wet tip")
check(card.is_showing() and card._title.text == "Soaked" and card._text.text.begins_with("\"Wet clothes"), "the card shows it, in Old Tam's words")
sv.wet = 0.0
sv.hunger = 10.0
await _for(6.0)
check(got.size() == 1, "hungry straight after: it waits its turn")
await _for(14.0)
check(got.back() == "hungry", "then the hungry tip")
sv.hunger = 90.0
sv.wet = 60.0
await _for(20.0)
check(got.count("wet") == 1, "soaked again: once is enough")
sv.wet = 0.0
print("the setting")
H.set_enabled(false)
world.day_night.time = 23.0 / 24.0
world.day_night.apply(true)
await _for(25.0)
check(got.size() == 2, "tips off: nightfall goes by without one")
var f: Variant = JSON.parse_string(FileAccess.get_file_as_string(H.path))
check(f is Dictionary and f.enabled == false and f.seen == ["wet", "hungry"], "the setting and the seen tips are saved")
H.set_enabled(true)
await _for(6.0)
check(got.back() == "night", "tips on: the night tip")
world.day_night.time = 10.0 / 24.0
world.day_night.apply(true)
print("events")
H.event("howl")
await _for(17.0)
check(got.back() == "howl", "the wolves howl: the wolf tip")
world.dead_waiting = true
H.event("died")
await _for(25.0)
check(got.back() == "howl", "dead, on the death screen: no tip yet")
world.dead_waiting = false
await _for(1.0)
check(got.back() == "died", "back on your feet: the death tip")
player.inventory.add("rotten_food", 1)
await _for(17.0)
check(got.back() == "rotten", "something rots in your bag: the food tip")
player.inventory.remove("rotten_food", 1)
print("next time")
H.load_prefs()
check(H.seen.size() == 6 and H.seen.has("died"), "the seen tips come back from the file (%d)" % H.seen.size())
await _load(false)
got.clear()
sv.wet = 60.0
sv.hunger = 10.0
await _for(25.0)
check(got.is_empty(), "a new session: wet and hungry again, but they've been said")
H.reset_seen()
await _for(5.0)
check(got == ["wet"], "Show again: the wet tip comes back")
sv.wet = 0.0
sv.hunger = 90.0
print("not in the tutorial")
await _load(true)
check(not H.active, "the tutorial world has its own guide: no tips")
got.clear()
sv.wet = 60.0
await _for(10.0)
check(got.is_empty(), "(soaked there: nothing)")
card.queue_free()
world.queue_free()
await process_frame
_finished = true