From 793b214786542331b8a2d4502775afc8603a8bab Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Fri, 6 Nov 2026 17:08:42 +0000 Subject: [PATCH] W3: the Cove (bay, pond, pier for sea fishing), mackerel and cod (cod only in deep water), non-start worldlets offered for testing when run from source --- data/fish.json | 22 ++++- data/items.json | 66 +++++++++++++ data/recipes.json | 42 ++++++++ data/worldlets/cove.json | 22 +++++ docs/WORLDLETS_DESIGN.md | 26 ++++- scripts/building/processor.gd | 2 + scripts/core/item_db.gd | 3 +- scripts/fishing/bobber.gd | 5 +- scripts/items/item_meshes.gd | 6 ++ scripts/ui/title_screen.gd | 15 ++- scripts/world/pier.gd | 114 ++++++++++++++++++++++ scripts/world/pier.gd.uid | 1 + scripts/world/props.gd | 8 +- scripts/world/world.gd | 10 ++ tests/run_all.sh | 2 +- tests/test_boundary.gd | 3 + tests/test_cove.gd | 175 ++++++++++++++++++++++++++++++++++ tests/test_cove.gd.uid | 1 + tests/test_fishing_diet.gd | 3 +- tests/test_terrain_edges.gd | 2 +- tests/test_worldlet_pick.gd | 13 ++- 21 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 data/worldlets/cove.json create mode 100644 scripts/world/pier.gd create mode 100644 scripts/world/pier.gd.uid create mode 100644 tests/test_cove.gd create mode 100644 tests/test_cove.gd.uid diff --git a/data/fish.json b/data/fish.json index 97ed0ca..7dd92d6 100644 --- a/data/fish.json +++ b/data/fish.json @@ -1,5 +1,5 @@ { - "_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers.", + "_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers. A fish with its own 'min_depth' only bites in water at least that deep (cod: cast well out from the pier).", "modifiers": { "dawn_dusk_wait": 0.6, "rain_wait": 0.8, @@ -24,6 +24,26 @@ "dawn_dusk": 40 } ] + }, + "sea": { + "min_depth": 1.0, + "bite_wait": [ + 6.0, + 16.0 + ], + "fish": [ + { + "id": "mackerel", + "weight": 75, + "dawn_dusk": 65 + }, + { + "id": "cod", + "weight": 25, + "dawn_dusk": 35, + "min_depth": 2.2 + } + ] } } } diff --git a/data/items.json b/data/items.json index d2cc1bb..17fa4f3 100644 --- a/data/items.json +++ b/data/items.json @@ -543,6 +543,72 @@ }, "keeps": 4 }, + { + "id": "mackerel", + "name": "Mackerel", + "category": "food", + "stack": 20, + "description": "A quick, striped sea fish, caught off the pier. Grill it at a campfire. (Fish)", + "mesh": "mackerel", + "food": { + "hunger": 5, + "health": -2, + "diet": { + "fish": 10 + }, + "risk": 0.2 + }, + "keeps": 2 + }, + { + "id": "cod", + "name": "Cod", + "category": "food", + "stack": 20, + "description": "A big sea fish from deeper water: cast well out. (Fish)", + "mesh": "cod", + "food": { + "hunger": 8, + "health": -2, + "diet": { + "fish": 16 + }, + "risk": 0.2 + }, + "keeps": 2 + }, + { + "id": "grilled_mackerel", + "name": "Grilled Mackerel", + "category": "food", + "stack": 20, + "description": "Oily and good. (Fish)", + "mesh": "grilled_mackerel", + "food": { + "hunger": 22, + "health": 8, + "diet": { + "fish": 36 + } + }, + "keeps": 4 + }, + { + "id": "grilled_cod", + "name": "Grilled Cod", + "category": "food", + "stack": 20, + "description": "Thick white flakes: a proper meal. (Fish)", + "mesh": "grilled_cod", + "food": { + "hunger": 32, + "health": 12, + "diet": { + "fish": 52 + } + }, + "keeps": 4 + }, { "id": "iron_nugget", "name": "Iron Nugget", diff --git a/data/recipes.json b/data/recipes.json index 2d219b1..75ed5ee 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -249,6 +249,26 @@ "trout": 1 } }, + { + "id": "grilled_mackerel", + "output": "grilled_mackerel", + "count": 1, + "station": "campfire", + "category": "cooking", + "inputs": { + "mackerel": 1 + } + }, + { + "id": "grilled_cod", + "output": "grilled_cod", + "count": 1, + "station": "campfire", + "category": "cooking", + "inputs": { + "cod": 1 + } + }, { "id": "charcoal_kiln", "output": "charcoal_kiln", @@ -578,6 +598,28 @@ "salt": 1 } }, + { + "id": "salted_mackerel", + "output": "salted_fish", + "count": 2, + "station": "", + "category": "cooking", + "inputs": { + "mackerel": 2, + "salt": 1 + } + }, + { + "id": "salted_cod", + "output": "salted_fish", + "count": 2, + "station": "", + "category": "cooking", + "inputs": { + "cod": 1, + "salt": 1 + } + }, { "id": "antler_pickaxe", "output": "antler_pickaxe", diff --git a/data/worldlets/cove.json b/data/worldlets/cove.json new file mode 100644 index 0000000..e9826bf --- /dev/null +++ b/data/worldlets/cove.json @@ -0,0 +1,22 @@ +{ + "_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; mountains behind. Not a start: you get here on the journey.", + "id": "cove", + "name": "Cove", + "description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.", + "start": false, + "size": 320, + "resolution": 256, + "terrain": [ + {"type": "hills", "height_scale": 5.5, "detail": 1.2}, + {"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45}, + {"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0}, + {"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6}, + {"type": "ocean_edge"} + ], + "climate": {"snowline": 42.0}, + "places": [{"type": "pier", "at": "beach"}], + "next": [ + {"to": "valley", "by": "tunnel", "where": "sea_cave"}, + {"to": "highlands", "by": "sail", "where": "pier"} + ] +} diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index 0f39eff..e069a3f 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -1,9 +1,33 @@ # Worldlets design -Status: **W0, W1 and W2 built**; the rest is planned. The journey between +Status: **W0-W3 built** (the Cove, without its stream yet); the rest is +planned. The journey between worldlets is §10, and the two ways to play (the journey, or one land) are in its decisions. +*As built in W3:* +- `data/worldlets/cove.json`: low hills, mountains behind, a wide sandy + bay (a `beach` block) and a small pond. Not a start; its `next` is back + to the Valley (tunnel, J1) and on to the Highlands (sail). +- The pier (`scripts/world/pier.gd`), a world "place" (`"places": + [{"type": "pier"}]`): planks on posts from where the sand is at deck + height to 24 m past the waterline. Worked out from the terrain, so it's + the same for everyone and never saved or dismantled; scenery keeps clear + of it. ~1.9 m of sea at its end, ~2.7 m a cast beyond. +- Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites + in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire, + salted, or smoked, like the lake's. +- Running from the source, the New World and Host pages also offer + worldlets that aren't starts, marked "(testing)", so the Cove can be + played before the tunnel exists. Release builds offer only the Valley. +- No sea stacks without a sea cliff. +- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod + only in deep water), and the Cove in `test_terrain_edges` and + `test_boundary`. +- Still to do: the stream (it needs water that runs downhill, which the + one flat water surface can't do: its own ribbon of water, planned with + the Cove's sea cave when the tunnel arrives). + *As built in W2:* - `lake` blocks can repeat: every lake and pond is its own body (`terrain.lakes`, `world.water_bodies()`), all at one surface diff --git a/scripts/building/processor.gd b/scripts/building/processor.gd index fdae121..c547325 100644 --- a/scripts/building/processor.gd +++ b/scripts/building/processor.gd @@ -23,6 +23,8 @@ const RECIPES := { {"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}, + {"inputs": {"mackerel": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0}, + {"inputs": {"cod": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0}, ], } ## What the prompt says it makes, and what it needs when idle. diff --git a/scripts/core/item_db.gd b/scripts/core/item_db.gd index 1c5d0c9..c4e9e02 100644 --- a/scripts/core/item_db.gd +++ b/scripts/core/item_db.gd @@ -92,7 +92,8 @@ func define_water(water: String, d: Dictionary) -> void: if not items.has(f.get("id", "")): push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water]) continue - fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1)))}) + fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1))), + "min_depth": float(f.get("min_depth", 0.0))}) # (a fish of deeper water: cod) var w: Array = d.get("bite_wait", [5.0, 14.0]) fishing.waters[water] = {"min_depth": float(d.get("min_depth", 0.5)), "bite_wait": [float(w[0]), float(w[1])], "fish": fish} diff --git a/scripts/fishing/bobber.gd b/scripts/fishing/bobber.gd index 21501f3..75b73db 100644 --- a/scripts/fishing/bobber.gd +++ b/scripts/fishing/bobber.gd @@ -15,6 +15,7 @@ const BITE_WINDOW := 1.5 const LINE_LENGTH := 26.0 var water_type := "" ## "lake", "sea"... (world.water_at) +var water_depth := 0.0 ## how deep it is where the float came down var table: Dictionary = {} ## that water's fishing table (ItemDB.water_fishing) var world: Node3D @@ -91,8 +92,9 @@ func _next_wait() -> float: return rng.randf_range(w[0], w[1]) * k ## A weighted pick from this water's fish (dawn/dusk weights when they apply). +## (A fish with its own "min_depth" only bites in water that deep: cod.) func _pick_fish() -> String: - var fish: Array = table.get("fish", []) + var fish: Array = table.get("fish", []).filter(func(f): return water_depth >= float(f.get("min_depth", 0.0))) var key := "dawn_dusk" if _dawn_dusk() else "weight" var total := 0.0 for f in fish: @@ -120,6 +122,7 @@ func step(dt: float) -> void: if not water.is_empty() and p.y <= float(water.surface): p.y = water.surface water_type = water.type + water_depth = float(water.depth) table = ItemDB.water_fishing(water_type) if table.is_empty() or table.fish.is_empty(): state = "landed" diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 546728e..926884d 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -75,6 +75,10 @@ static func build(key: String) -> ArrayMesh: "trout": m = _fish(TROUT, 0.24, false) "grilled_perch": m = _fish(PERCH, 0.16, true) "grilled_trout": m = _fish(TROUT, 0.24, true) + "mackerel": m = _fish(MACKEREL, 0.2, false) + "cod": m = _fish(COD, 0.3, false) + "grilled_mackerel": m = _fish(MACKEREL, 0.2, true) + "grilled_cod": m = _fish(COD, 0.3, true) "fiber_plant": m = _fiber_plant() "iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6) "iron_ore": m = _ore() @@ -694,6 +698,8 @@ static func _cloak(col: Color = HIDE) -> ArrayMesh: const PERCH := Color(0.45, 0.52, 0.30) const TROUT := Color(0.50, 0.55, 0.58) +const MACKEREL := Color(0.22, 0.42, 0.48) +const COD := Color(0.56, 0.52, 0.40) const GRILLED := Color(0.55, 0.36, 0.18) const FLOAT_RED := Color(0.85, 0.15, 0.12) diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index bb83785..5f01241 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -698,7 +698,7 @@ var _host_worldlet: OptionButton ## description under it. (Only the Valley today: the others are reached on ## the journey, so the list is short and the picker sits still.) func _worldlet_picker(v: VBoxContainer) -> OptionButton: - var starts: Array = Terrain.starting_worldlets() + var starts: Array = offered_worldlets(OS.is_debug_build()) v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM)) var pick := OptionButton.new() pick.custom_minimum_size = Vector2(360, 36) @@ -716,6 +716,19 @@ func _worldlet_picker(v: VBoxContainer) -> OptionButton: about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", ""))) return pick +## The worldlets offered for a new world: the starts, and (running from the +## source, `debug`) the rest too, marked for testing, so a worldlet can be +## tried before the journey reaches it. A release build offers only the starts. +static func offered_worldlets(debug: bool) -> Array: + var out: Array = Terrain.starting_worldlets() + if debug: + for w in Terrain.worldlets(): + if not w.start: + var t: Dictionary = w.duplicate() + t.name = "%s (testing)" % w.name + out.append(t) + return out + ## The Host page's "Where": a hosted island keeps its worldlet, like its ## seed, so once it exists the picker shows that one and stays put. func _show_host_worldlet(existing: bool, meta_path: String) -> void: diff --git a/scripts/world/pier.gd b/scripts/world/pier.gd new file mode 100644 index 0000000..8a82e62 --- /dev/null +++ b/scripts/world/pier.gd @@ -0,0 +1,114 @@ +extends Node3D +## The Cove's pier (W3, docs/WORLDLETS_DESIGN.md §5): a wooden walkway from +## the sand straight out over the bay, so a cast from its end reaches deep +## sea water (mackerel, and cod further out). Part of the world, like the +## barrow: worked out from the terrain alone, so every player and the server +## get the same one, and it's never saved, built or dismantled. +## +## It starts where the beach has risen to deck height (so you walk straight +## on) and runs `LENGTH_OUT` metres past the waterline. + +const WIDTH := 2.4 +const DECK_ABOVE := 1.0 ## deck height over the water +const LENGTH_OUT := 24.0 ## how far past the waterline it reaches +const POST_EVERY := 3.0 +const WOOD := Color(0.42, 0.30, 0.19) +const POST := Color(0.30, 0.22, 0.15) + +var start := Vector3.ZERO ## the landward end, on the sand +var end := Vector3.ZERO ## the seaward end (fish from here) +var dir := Vector3.ZERO ## out to sea along the deck + +## Lays the pier off the terrain's beach. Returns false (and adds nothing) +## when the worldlet has no beach. +func place(terrain: Node3D) -> bool: + if not terrain._has_beach or not terrain.worldlet.get("places", []).any(func(p): return p is Dictionary and p.get("type", "") == "pier"): + return false + var deck_y: float = terrain.water_level + DECK_ABOVE + var out2: Vector2 = terrain.shore_point(LENGTH_OUT) + var root2: Vector2 = terrain.shore_point(-2.0) + var d2 := (out2 - root2).normalized() + # Walk inland from the waterline to where the sand is at deck height. + var s2 := root2 + for _i in range(40): + if terrain.sample_height(s2.x, s2.y) >= deck_y - 0.15: + break + s2 -= d2 * 0.5 + start = Vector3(s2.x, deck_y, s2.y) + end = Vector3(out2.x, deck_y, out2.y) + dir = (end - start).normalized() + _build(terrain) + return true + +## Whether (x, z) is under or right beside the pier (scenery keeps clear). +func covers(x: float, z: float, margin: float = 1.0) -> bool: + var a := Vector2(start.x, start.z) + var b := Vector2(end.x, end.z) + var p := Vector2(x, z) + var t := clampf((p - a).dot(b - a) / maxf((b - a).length_squared(), 0.001), 0.0, 1.0) + return p.distance_to(a.lerp(b, t)) < WIDTH * 0.5 + margin + +func _build(terrain: Node3D) -> void: + var length := start.distance_to(end) + var yaw := atan2(dir.x, dir.z) + var basis := Basis(Vector3.UP, yaw) + var mid := (start + end) * 0.5 + # The deck: planks across it, with a little gap and wobble between them. + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var n := int(length / 0.32) + var rng := RandomNumberGenerator.new() + rng.seed = 7171 + for i in range(n): + var z := -length * 0.5 + (i + 0.5) * length / n + var h := 0.08 + rng.randf_range(-0.01, 0.01) + _box(st, Vector3(rng.randf_range(-0.04, 0.04), -h * 0.5, z), Vector3(WIDTH, h, length / n - 0.03), + WOOD.darkened(rng.randf_range(0.0, 0.18))) + # Posts down to the bed, in pairs, and a rail beam along each side. + var k := 0.0 + while k <= length + 0.01: + var along := -length * 0.5 + k + for side in [-1.0, 1.0]: + var local := Vector3(side * (WIDTH * 0.5 - 0.1), 0.0, along) + var world_p: Vector3 = mid + basis * local + var bed: float = terrain.sample_height(world_p.x, world_p.z) + var tall := start.y - bed + 0.3 + if tall > 0.2: + _box(st, local + Vector3(0, -tall * 0.5 - 0.05, 0), Vector3(0.22, tall, 0.22), POST) + k += POST_EVERY + for side in [-1.0, 1.0]: + _box(st, Vector3(side * (WIDTH * 0.5 - 0.06), -0.16, 0.0), Vector3(0.12, 0.14, length), POST) + st.generate_normals() + var mi := MeshInstance3D.new() + mi.name = "Deck" + mi.mesh = st.commit() + var mat := StandardMaterial3D.new() + mat.vertex_color_use_as_albedo = true + mat.roughness = 0.9 + mi.material_override = mat + add_child(mi) + # Walkable deck (on the ground layer, like the terrain). + var body := StaticBody3D.new() + body.name = "DeckBody" + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(WIDTH, 0.12, length) + cs.shape = box + cs.position = Vector3(0, -0.06, 0) + body.add_child(cs) + add_child(body) + transform = Transform3D(basis, mid) + add_to_group("pier") + +static func _box(st: SurfaceTool, c: Vector3, s: Vector3, col: Color) -> void: + var h := s * 0.5 + var v := [ + c + Vector3(-h.x, -h.y, -h.z), c + Vector3(h.x, -h.y, -h.z), c + Vector3(h.x, h.y, -h.z), c + Vector3(-h.x, h.y, -h.z), + c + Vector3(-h.x, -h.y, h.z), c + Vector3(h.x, -h.y, h.z), c + Vector3(h.x, h.y, h.z), c + Vector3(-h.x, h.y, h.z), + ] + var faces := [[0, 3, 2, 1], [4, 5, 6, 7], [0, 4, 7, 3], [1, 2, 6, 5], [3, 7, 6, 2], [0, 1, 5, 4]] + st.set_color(col) + for f in faces: + # (listed anticlockwise from outside; Godot's front faces are clockwise) + st.add_vertex(v[f[0]]); st.add_vertex(v[f[2]]); st.add_vertex(v[f[1]]) + st.add_vertex(v[f[0]]); st.add_vertex(v[f[3]]); st.add_vertex(v[f[2]]) diff --git a/scripts/world/pier.gd.uid b/scripts/world/pier.gd.uid new file mode 100644 index 0000000..61f9071 --- /dev/null +++ b/scripts/world/pier.gd.uid @@ -0,0 +1 @@ +uid://ps0h2qmbn5ms diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 61764fa..4352441 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -31,6 +31,7 @@ const FLOWER_COLORS := [ @export var grass_tiles_per_update: int = 6 var terrain: Node3D +var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world) var _player: Node3D var _spawn := Vector3.ZERO var _forest_noise := FastNoiseLite.new() @@ -417,7 +418,10 @@ func _scatter_trees() -> void: ## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so ## the view out to sea is clear and you can walk right up to the edge. +## (Also kept clear: the Cove's pier and the sand by its foot.) func _on_clifftop(x: float, z: float) -> bool: + if pier != null and pier.covers(x, z, 2.0): + return true return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \ and terrain.inside_boundary(x, z, -8.0) @@ -486,7 +490,7 @@ func _scatter_rocks() -> void: # Sea stacks: tall pillars standing off the cliff (no collision, unreachable). var placed := 0 var tries := 0 - while placed < 9 and tries < 200: + while placed < 9 and tries < 200 and terrain._has_sea: # (only off a sea cliff) tries += 1 var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0) var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0)) @@ -672,7 +676,7 @@ func _scatter_pickups() -> void: continue var h: float = terrain.sample_height(x, z) var n: Vector3 = terrain.sample_normal(x, z) - if h < terrain.water_level + 0.3 or n.y < 0.8: + if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)): continue var chance := 1.0 match id: diff --git a/scripts/world/world.gd b/scripts/world/world.gd index bec1695..7494367 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -53,6 +53,7 @@ var _spawn := Vector3.ZERO var _spawn_yaw := 0.0 var _pieces: Node3D var hud: CanvasLayer +var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null var _session_time := 0.0 ## seconds played since the last save var _autosave_left := AUTOSAVE_SECONDS var _menu_cam: Camera3D @@ -352,6 +353,15 @@ func _on_terrain_ready() -> void: var to_lake := lake - Vector2(_spawn.x, _spawn.z) _spawn_yaw = atan2(-to_lake.x, -to_lake.y) player.water_level = terrain.water_level + # The pier (W3): off a beach, laid before the scenery so it keeps clear. + var pr := preload("res://scripts/world/pier.gd").new() + pr.name = "Pier" + add_child(pr) + if pr.place(terrain): + pier = pr + props.pier = pr + else: + pr.free() props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn) # The wolf den: part of the scenery, so it's there from day 1 (empty). den = preload("res://scripts/creatures/den.gd").new() diff --git a/tests/run_all.sh b/tests/run_all.sh index 439b574..10f58ff 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_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_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies 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_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove 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_boundary.gd b/tests/test_boundary.gd index a910430..bf5247b 100644 --- a/tests/test_boundary.gd +++ b/tests/test_boundary.gd @@ -30,6 +30,9 @@ func _run() -> void: print("a beach (testbay)") S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"}) await _escape_all(BAY_DIRECTIONS, 90.0) + print("the Cove (all round)") + S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"}) + await _escape_all(DIRECTIONS, -1.0) for w in S.list_worlds(): S.delete_world(w.id) DirAccess.remove_absolute(S.root_dir) diff --git a/tests/test_cove.gd b/tests/test_cove.gd new file mode 100644 index 0000000..651e0a5 --- /dev/null +++ b/tests/test_cove.gd @@ -0,0 +1,175 @@ +extends SceneTree +## W3 (docs/WORLDLETS_DESIGN.md §5): the Cove. A world made there has its +## bay, a pond and a pier; you walk from the sand onto the pier, cast off its +## end into the sea and catch a mackerel; cod only bite in deeper water; the +## sea fish cook like the lake's. The Cove isn't a start (you reach it on +## the journey), and its ways on are back to the Valley and on by sail. +## Run: godot --headless --path . --script res://tests/test_cove.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + S.root_dir = "user://test_cove_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) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _body() -> void: + var T: Script = load("res://scripts/world/terrain.gd") + print("the Cove") + var def: Dictionary = T.load_worldlet("cove") + check(def.get("name", "") == "Cove" and not bool(def.get("start", true)), "a worldlet, not a start (reached on the journey)") + S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"}) + var world: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(8): + await physics_frame + var t: Node3D = world.terrain + var player: CharacterBody3D = world.player + world.day_night.frozen = true + world.weather.force("clear") + world.creatures.enabled = false + world.day_night.time = 0.5 + world.day_night.apply(true) + player.survival.set_process(false) + check(world.worldlet_id() == "cove", "a world made in the Cove is the Cove") + var bodies: Array = world.water_bodies().map(func(b): return b.type) + check(bodies.has("sea") and bodies.has("lake"), "with the sea and a pond (%s)" % [bodies]) + check(world.crossings().any(func(c): return c.to == "valley") and world.crossings().any(func(c): return c.by == "sail"), + "its ways on: back to the Valley, and on by sail") + check(world._spawn.y > t.water_level + 1.0, "you start on dry land") + check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places") + check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)") + + print("the pier") + var pier: Node3D = world.pier + check(pier != null and pier.is_in_group("pier"), "a pier") + if pier == null: + return + var len: float = pier.start.distance_to(pier.end) + check(len > 20.0, "running out over the bay (%.0f m)" % len) + var at_end: Dictionary = world.water_at(pier.end.x, pier.end.z) + check(at_end.get("type", "") == "sea" and float(at_end.depth) > 1.0, "into the sea (%.1f m deep at the end)" % float(at_end.get("depth", 0.0))) + check(absf(t.sample_height(pier.start.x, pier.start.z) - pier.start.y) < 0.3, "starting at the sand, so you walk straight on") + # Walk on from the sand: the deck holds you up above the water. + var on: Vector3 = pier.start - pier.dir * 1.5 + on.y = t.sample_height(on.x, on.z) + 0.2 + player.global_position = on + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, atan2(-pier.dir.x, -pier.dir.z), 0.0) + Input.action_press("move_forward") + for _i in range(int(4.0 * Engine.physics_ticks_per_second)): + await physics_frame + Input.action_release("move_forward") + var p: Vector3 = player.global_position + var along: float = (p - pier.start).dot(pier.dir) + check(along > 10.0 and p.y > t.water_level + 0.5, "walk on from the beach and along it, dry (%.0f m out, %.1f m above the water)" % [along, p.y - t.water_level]) + check(not world._in_current, "(no current up on the deck)") + + print("sea fishing") + player.set_physics_process(false) + player.global_position = pier.end - pier.dir * 0.8 + player.inventory.add("fishing_rod", 1) + _hold(player, "fishing_rod") + var out: Vector3 = pier.end + pier.dir * 8.0 + out.y = t.water_level + var got := {} + var bobs := 0 + for _try in range(25): + player.survival.stamina = 100.0 + if not _cast(world, player, out): + break + var b := _bob_until(world, "floating", 5.0) + if b == null or b.state != "floating": + break + bobs += 1 + if bobs == 1: check(b.water_type == "sea", "the float lands on the sea (%.1f m deep)" % b.water_depth) + b = _bob_until(world, "biting", 60.0) + if b == null or b.state != "biting": + break + var fish: String = b.fish + var before: int = player.inventory.count(fish) + if A.reel(player, world) and player.inventory.count(fish) == before + 1: + got[fish] = int(got.get(fish, 0)) + 1 + if got.has("mackerel") and _try >= 6: + break + check(got.has("mackerel"), "you catch a mackerel off the pier (%s)" % [got]) + check(got.keys().all(func(k): return k in ["mackerel", "cod"]), "(sea fish only: no perch in the sea)") + + print("cod like it deep") + var bob: Node3D = load("res://scripts/fishing/bobber.gd").new() + bob.table = DB.water_fishing("sea") + bob.world = world + bob.rng.seed = 5 + var shallow := {} + var deep := {} + for i in range(200): + bob.water_depth = 1.5 + shallow[bob._pick_fish()] = true + bob.water_depth = 3.0 + deep[bob._pick_fish()] = true + check(not shallow.has("cod") and shallow.has("mackerel"), "in shallow sea only mackerel bite (%s)" % [shallow.keys()]) + check(deep.has("cod") and deep.has("mackerel"), "cast out deep and cod bite too") + bob.free() + + print("cooking them") + for fish in ["mackerel", "cod"]: + var r: Dictionary = DB.recipes.get("grilled_" + fish, {}) + check(r.get("station", "") == "campfire" and r.get("inputs", {}).has(fish) and not DB.get_item("grilled_" + fish).is_empty(), + "grilled %s, at a campfire" % fish) + check(DB.recipes.get("salted_" + fish, {}).get("output", "") == "salted_fish", "and %s salts into salted fish" % fish) + check(not DB.get_item("mackerel").get("food", {}).is_empty() and DB.get_item("cod").get("food", {}).get("diet", {}).has("fish"), + "the raw fish count as fish in your diet") + world.queue_free() + await process_frame + _finished = true + +func _cast(world: Node3D, player: Node3D, toward: Vector3) -> bool: + var origin: Vector3 = player.global_position + Vector3(0, 1.6, 0) + return A.cast_line(player, origin, toward - origin, world) + +## Steps the float until it reaches `want` (or `secs` pass). Returns it. +func _bob_until(world: Node3D, want: String, secs: float) -> Node3D: + var tt := 0.0 + while tt < secs: + var b: Node3D = world.bobber + if b == null or not is_instance_valid(b): + return null + if b.state == want: + return b + b.set_physics_process(false) + b.step(0.05) + tt += 0.05 + return world.bobber + +func _hold(player: Node, id: String) -> void: + var inv: Inventory = player.inventory + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + var tmp = inv.get_slot(0) + inv.set_slot(0, s) + inv.set_slot(i, tmp) + break + player.select(0) diff --git a/tests/test_cove.gd.uid b/tests/test_cove.gd.uid new file mode 100644 index 0000000..be04de5 --- /dev/null +++ b/tests/test_cove.gd.uid @@ -0,0 +1 @@ +uid://cso32qbo6jvw3 diff --git a/tests/test_fishing_diet.gd b/tests/test_fishing_diet.gd index 3a09ded..6274891 100644 --- a/tests/test_fishing_diet.gd +++ b/tests/test_fishing_diet.gd @@ -118,7 +118,8 @@ func _body(world: Node3D) -> void: for f in lt.get("fish", []): ids.append(f.id) check(ids == ["perch", "trout"] and lt.min_depth == 0.5, "the lake's fish come from data/fish.json (%s)" % [ids]) - check(DB.water_fishing("sea").is_empty(), "this island has no sea fish defined (the cliffs are in the way)") + var st: Dictionary = DB.water_fishing("sea") + check(st.get("fish", []).map(func(f): return f.id) == ["mackerel", "cod"], "the sea's fish: mackerel and cod (caught in the Cove; tests/test_cove.gd)") print("casting") sv.stamina = 100.0 diff --git a/tests/test_terrain_edges.gd b/tests/test_terrain_edges.gd index c631194..f843073 100644 --- a/tests/test_terrain_edges.gd +++ b/tests/test_terrain_edges.gd @@ -21,7 +21,7 @@ func _initialize() -> void: ## Each worldlet's edges (W2): the Valley, and a test worldlet with two ## ponds and a beach (tests/worldlets/testbay.json). -const WORLDLETS := ["valley", "testbay"] +const WORLDLETS := ["valley", "cove", "testbay"] func _all() -> void: var failed := 0 diff --git a/tests/test_worldlet_pick.gd b/tests/test_worldlet_pick.gd index 0a524dc..83acf9a 100644 --- a/tests/test_worldlet_pick.gd +++ b/tests/test_worldlet_pick.gd @@ -40,7 +40,7 @@ func _body(S: Node, Net: Node) -> void: var TS: Script = load("res://scripts/ui/title_screen.gd") print("the worldlets to start in") var starts: Array = T.starting_worldlets() - check(starts.size() == 1 and String(starts[0].id) == "valley", "only the Valley (%s)" % [starts.map(func(w): return w.id)]) + check(starts.size() == 1 and String(starts[0].id) == "valley", "only the Valley (%s; the Cove is reached on the journey)" % [starts.map(func(w): return w.id)]) check(T.worldlets().all(func(w): return w.has("start")), "every worldlet says whether it's a start") check(T.worldlet_name("valley") == "Valley", "the Valley's name, for the menus") check(T.worldlet_name("nowhere") == "nowhere", "(an unknown one: its id)") @@ -51,9 +51,14 @@ func _body(S: Node, Net: Node) -> void: root.add_child(title) for _i in range(3): await process_frame + var release: Array = TS.offered_worldlets(false) + check(release.size() == 1 and String(release[0].id) == "valley", "a release build offers only the Valley") + var dev: Array = TS.offered_worldlets(true) + check(String(dev[0].id) == "valley" and dev.any(func(w): return w.id == "cove" and String(w.name) == "Cove (testing)"), + "running from the source, the Cove too, marked for testing (%s)" % [dev.map(func(w): return w.name)]) var pick: OptionButton = title._worldlet_pick - check(pick != null and pick.item_count == 1 and pick.get_item_text(0) == "Valley", "a \"Where\" picker with the Valley in it") - check(pick.disabled, "which sits still with only one choice") + check(pick != null and pick.item_count == dev.size() and pick.get_item_text(0) == "Valley", "a \"Where\" picker, on the Valley first") + check(not pick.disabled, "(with more than one to choose from)") check(TS.picked_worldlet(pick) == "valley", "and gives the Valley") var id: String = S.create_world("Pick test", 4242, {"worldlet": TS.picked_worldlet(pick)}) var meta: Dictionary = S.list_worlds().filter(func(w): return w.id == id)[0] if not id.is_empty() else {} @@ -69,7 +74,7 @@ func _body(S: Node, Net: Node) -> void: check(hp != null and hp.get_item_text(hp.selected) == "Valley", "a \"Where\" picker too, on the Valley") title._show("host") await process_frame - check(hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley (the only start)") + check(not hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley to start with, free to change") # Once the island exists it keeps its worldlet: the picker shows that one. DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world") var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE)