Compare commits

...
Sign in to create a new pull request.

13 commits

179 changed files with 6820 additions and 550 deletions

Binary file not shown.

View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://5hgmht4bq1my"
path="res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample"
[deps]
source_file="res://audio/music/world/tulips.wav"
dest_files=["res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

View file

@ -28,18 +28,21 @@ directory. The templates in `scripts/portmaster/` are authoritative.
- `port.json` uses schema version 4, names `netfishing.zip`, and marks the
self-contained package ready to run.
- `NETfishing.sh` starts with `# PORTMASTER: netfishing.zip, NETfishing.sh`.
- `NETfishing.sh` does not contain a `# PORTMASTER:` signature in the source or
distributable archive. HarbourMaster adds that installation marker when it
installs the port.
- The executable is `netfishing/NETfishing.aarch64`.
- The package declares AArch64, two analog sticks, GLIBC 2.28, and
`weston_pkg_0.2.squashfs`.
- The launcher starts GPTOKEYB with the explicit `netfishing.gptk` no-op map.
Every keyboard and mouse binding is disabled, leaving only PortMaster's
Every keyboard and mouse binding is disabled, leaving only GPTOKEYB's
device-specific force-quit chord while Godot and NETfishing's controller
mapping manager handle gameplay input.
mapping manager handle gameplay input. The explicit map also avoids the
anomalous controller behavior observed on ROCKNIX without a passthrough map.
- The launcher calls `pm_platform_helper` for the game executable after
starting GPTOKEYB and calls `pm_finish` after the game exits.
- New installations automatically create player data under
`ports/saves/netfishing/` and do not present the in-game folder picker.
- NETfishing automatically creates player data for new installations under
`ports/saves/netfishing/` and does not present the in-game folder picker.
- Existing installations with a selected data root keep that location when
upgraded. Device-local data, configuration, and cache remain under
`netfishing/conf/data`, `netfishing/conf/config`, and
@ -103,15 +106,15 @@ Before upgrading an existing installation, preserve
device. After installation:
1. Confirm the installed executable and PCK hashes match the staged release.
2. Confirm the installed launcher contains the canonical PortMaster header,
starts GPTOKEYB with `netfishing.gptk`, and calls
`pm_platform_helper` for the game executable.
2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to
the installed launcher, then confirm the launcher starts GPTOKEYB with
`netfishing.gptk` and calls `pm_platform_helper` for the game executable.
3. Confirm `conf/` and the external save directory were not replaced or
removed.
4. Launch the installed port through the normal muOS menu.
5. Verify the displayed game version and controller face-button mapping.
6. Verify PortMaster's force-quit chord exits the game. This is Start+Select on
most devices.
6. Verify GPTOKEYB's device-specific force-quit chord exits the game. This is
Start+Select on most devices.
Installing the public PortMaster catalog entry can still install an older
catalog build until the upstream `netfishing.zip` is updated. A local release
@ -173,12 +176,12 @@ the game process. This applies the mapping at the same point as the working
reconstruction. Do not move the mapping back to either side of
`westonwrap.sh`.
Legacy GPTOKEYB builds can load their compiled default keyboard map when no
`-c` file is supplied. That map translates face buttons, D-pad directions, and
sticks into keyboard or mouse events on top of Godot's native controller input.
Always pass `netfishing.gptk`, which explicitly unassigns every direct and
hotkey binding with GPTOKEYB's `\"` value. Do not add gameplay mappings to this
file. The Start+Select kill chord remains independent of the no-op bindings.
hotkey binding with GPTOKEYB's `\"` value. This keeps GPTOKEYB from injecting
keyboard or mouse events on top of Godot's native controller input and retains
the device-specific kill chord. Supplying the explicit passthrough map also
avoids the anomalous controller behavior observed on ROCKNIX when no map is
provided. Do not add gameplay mappings to this file.
Do not call `Input.add_joy_mapping(..., true)` for a recognized connected muOS
controller. Updating this virtual controller after connection can stop Godot

View file

@ -1,6 +1,6 @@
NETfishing
v0.13.1-alpha
Alpha 0.13.1
v0.14.0-alpha
Alpha 0.14.0
Thank you for trying this early private playtest.

View file

@ -12,6 +12,7 @@ const MAGNET_ID: StringName = &"magnet"
const BATTERIES_ID: StringName = &"batteries"
const CRAB_NET_ID: StringName = &"crab_net"
const STANDARD_SHOVEL_ID: StringName = &"standard_shovel"
const FISHING_NET_ID: StringName = &"fishing_net"
const ITEM_PRICES: Dictionary[StringName, int] = {
&"worms": 1,
@ -30,6 +31,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = {
BATTERIES_ID: 15,
CRAB_NET_ID: 50,
STANDARD_SHOVEL_ID: 75,
FISHING_NET_ID: 250,
}
const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = {
&"snails": 400,
@ -52,6 +54,7 @@ const ITEM_ORDER: Array[StringName] = [
MAGNET_ID,
CRAB_NET_ID,
STANDARD_SHOVEL_ID,
FISHING_NET_ID,
&"coffee",
&"energy_drink",
&"snack",
@ -120,6 +123,7 @@ static func is_permanent_unlock(
or item_id == MAGNET_ID
or item_id == CRAB_NET_ID
or item_id == STANDARD_SHOVEL_ID
or item_id == FISHING_NET_ID
or item is FishingRodDataType
)
)

View file

@ -9,7 +9,7 @@ custom_features=""
export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*"
export_path="builds/v0.13.1-alpha/windows-x86_64/NETfishing.exe"
export_path="builds/v0.14.0-alpha/windows-x86_64/NETfishing.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@ -35,11 +35,11 @@ application/modify_resources=true
application/icon="res://art/exported/system_icons/netfishing.ico"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.13.1.0"
application/product_version="0.13.1.0"
application/file_version="0.14.0.0"
application/product_version="0.14.0.0"
application/company_name="Woofmeow"
application/product_name="NETfishing"
application/file_description="NETfishing v0.13.1-alpha"
application/file_description="NETfishing v0.14.0-alpha"
application/copyright="Copyright © 2026 Woofmeow"
application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md"
application/export_angle=0
@ -62,7 +62,7 @@ custom_features=""
export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*"
export_path="builds/v0.13.1-alpha/linux-arm64/NETfishing.arm64"
export_path="builds/v0.14.0-alpha/linux-arm64/NETfishing.arm64"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@ -92,7 +92,7 @@ custom_features=""
export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*"
export_path="builds/v0.13.1-alpha/macos/NETfishing.zip"
export_path="builds/v0.14.0-alpha/macos/NETfishing.zip"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@ -108,8 +108,8 @@ custom_template/release=""
application/bundle_identifier="io.woofmeow.netfishing"
application/icon="res://art/exported/system_icons/netfishing_1024.png"
application/icon_interpolation=0
application/short_version="0.13.1"
application/version="0.13.1"
application/short_version="0.14.0"
application/version="0.14.0"
application/architecture="universal"
codesign/enable=false
notarization/enable=false
@ -125,7 +125,7 @@ custom_features=""
export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*,tests/*"
export_path="builds/v0.13.1-alpha/server-linux-x86_64/NETfishingServer.x86_64"
export_path="builds/v0.14.0-alpha/server-linux-x86_64/NETfishingServer.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@ -155,7 +155,7 @@ custom_features=""
export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*"
export_path="builds/v0.13.1-alpha/android/NETfishing.apk"
export_path="builds/v0.14.0-alpha/android/NETfishing.apk"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@ -176,8 +176,8 @@ architectures/armeabi-v7a=false
architectures/arm64-v8a=true
architectures/x86=false
architectures/x86_64=false
version/code=130100
version/name="v0.13.1-alpha"
version/code=140000
version/name="v0.14.0-alpha"
package/unique_name="io.woofmeow.netfishing"
package/name="NETfishing"
package/signed=true
@ -214,7 +214,7 @@ custom_features=""
export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*"
export_path="builds/v0.13.1-alpha/linux-x86_64/NETfishing.x86_64"
export_path="builds/v0.14.0-alpha/linux-x86_64/NETfishing.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""

View file

@ -36,6 +36,12 @@ enum LogbookSection {
SHELLFISH,
}
enum CreatureGroup {
FISH,
INSECT,
SHELLFISH,
}
@export var id: StringName
@export var display_name: String
@export_category("Developer Catalog")
@ -94,6 +100,14 @@ func is_fishable() -> bool:
return is_selectable() and collection_method == CollectionMethod.FISHING
func get_creature_group() -> CreatureGroup:
if logbook_section == LogbookSection.SHELLFISH:
return CreatureGroup.SHELLFISH
if collection_method == CollectionMethod.FISHING:
return CreatureGroup.FISH
return CreatureGroup.INSECT
func get_habitat_label() -> String:
if not habitat_label.strip_edges().is_empty():
return habitat_label.strip_edges().to_lower()

View file

@ -111,7 +111,9 @@ const BITE_QUICK_PROBABILITY: float = 0.30
const BITE_TYPICAL_PROBABILITY: float = 0.65
const BITE_LONG_PROBABILITY: float = 0.04
const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1
const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate"
@export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0
@export_range(0.1, 5.0, 0.05) var minimum_showcase_duration: float = 0.8
@export_category("Selection")
@export_range(0.0, 10.0, 0.05) var undiscovered_weight_multiplier: float = 1.5
@ -172,6 +174,7 @@ var _selected_fish: FishDataType
var _pending_catch: FishCatchType
var _showcase_ready: bool = false
var _put_away_press_armed: bool = false
var _showcase_input_lock_remaining: float = 0.0
var _showcase_restore_generation: int = 0
var _showcase_outcome_completed: bool = false
var _network_auto_click_accumulator: float = 0.0
@ -444,6 +447,7 @@ func _secure_showcase_catch_for_recovery() -> void:
_pending_catch = null
_showcase_ready = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false)
if _active_player != null:
_active_player.end_catch_showcase(Callable(), true)
@ -473,14 +477,21 @@ func _exit_tree() -> void:
_selected_fish = null
_showcase_ready = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
func _process(delta: float) -> void:
if state == FishingState.READY and not Input.is_action_pressed("fish_primary"):
_new_cast_press_armed = true
if state == FishingState.SHOWING_CATCH:
_showcase_input_lock_remaining = maxf(
_showcase_input_lock_remaining - delta,
0.0,
)
if (
state == FishingState.SHOWING_CATCH
and _showcase_ready
and _showcase_input_lock_remaining <= 0.0
and not Input.is_action_pressed("fish_primary")
):
_put_away_press_armed = true
@ -501,7 +512,7 @@ func _process(delta: float) -> void:
_get_effective_reel_speed(),
_get_effective_barrier_damage()
)
if not Input.is_action_pressed("fish_primary"):
if not _is_reel_input_pressed():
_catch_controller.set_reel_input(false)
else:
_resend_network_input_state(delta)
@ -521,7 +532,11 @@ func _unhandled_input(event: InputEvent) -> void:
return
if _local_player == null or not _local_player.is_local_control_enabled():
return
if not event.is_action("fish_primary"):
var primary_action: bool = event.is_action("fish_primary")
var alternate_reel: bool = event.is_action(ALTERNATE_REEL_ACTION)
if not primary_action and not (
alternate_reel and state == FishingState.FIGHTING
):
return
var selected_item: ItemDataType = _get_active_item()
if (
@ -582,7 +597,11 @@ func _unhandled_input(event: InputEvent) -> void:
FishingPresentationType.LineMode.TAUT
)
FishingState.SHOWING_CATCH:
if _showcase_ready and _put_away_press_armed:
if (
_showcase_ready
and _showcase_input_lock_remaining <= 0.0
and _put_away_press_armed
):
_put_away_catch()
else:
return
@ -619,15 +638,23 @@ func _unhandled_input(event: InputEvent) -> void:
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
get_viewport().set_input_as_handled()
elif state == FishingState.FIGHTING:
var reel_input_held: bool = _is_reel_input_pressed()
if _network_fishing != null:
_network_primary_input_held = false
_network_primary_input_held = reel_input_held
_network_input_resend_elapsed = 0.0
_network_fishing.submit_local_input(false, false)
_network_fishing.submit_local_input(reel_input_held, false)
else:
_catch_controller.set_reel_input(false)
_catch_controller.set_reel_input(reel_input_held)
get_viewport().set_input_as_handled()
func _is_reel_input_pressed() -> bool:
return (
Input.is_action_pressed("fish_primary")
or Input.is_action_pressed(ALTERNATE_REEL_ACTION)
)
func _begin_aiming(player: PlayerType) -> void:
if state != FishingState.READY or _active_player != null:
return
@ -756,6 +783,17 @@ func is_returning() -> bool:
return state == FishingState.RETURNING
func is_fishing_sequence_active() -> bool:
return state in [
FishingState.AIMING_CAST,
FishingState.CASTING,
FishingState.WAITING_FOR_BITE,
FishingState.FIGHTING,
FishingState.SHOWING_CATCH,
FishingState.RETURNING,
]
func refresh_active_item_status() -> void:
if state == FishingState.READY and has_active_fishing_rod():
status_changed.emit("")
@ -780,6 +818,7 @@ func present_external_catch(fish_catch: FishCatchType) -> bool:
_showcase_ready = true
_showcase_outcome_completed = true
_put_away_press_armed = false
_showcase_input_lock_remaining = minimum_showcase_duration
_active_player.begin_catch_showcase(fish_catch)
showcase_changed.emit(
fish_catch.fish.display_name,
@ -1112,7 +1151,7 @@ func _activate_bite(confirmation_override: bool = false) -> void:
_pending_catch.quality,
)
_catch_controller.set_reel_input(
Input.is_action_pressed("fish_primary")
_is_reel_input_pressed()
)
_presentation.show_bite()
@ -1197,6 +1236,7 @@ func _on_catch_completed() -> void:
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
_catch_controller.reset()
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.play_outcome(&"catch")
@ -1232,6 +1272,7 @@ func _on_outcome_completed(outcome: StringName) -> void:
return
_showcase_outcome_completed = true
_showcase_ready = true
_showcase_input_lock_remaining = minimum_showcase_duration
_active_player.begin_catch_showcase(_pending_catch)
showcase_changed.emit(
_pending_catch.fish.display_name,
@ -1265,6 +1306,7 @@ func _put_away_catch() -> void:
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false)
_showcase_restore_generation += 1
var restore_generation: int = _showcase_restore_generation
@ -1338,6 +1380,7 @@ func _cleanup_attempt(
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false)
_catch_controller.reset()
state = FishingState.RETURNING
@ -1387,6 +1430,7 @@ func _finalize_attempt_cleanup(
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false)
_catch_controller.reset()
_presentation.cleanup()
@ -1724,7 +1768,7 @@ func _on_network_bite_started(_attempt_id: String) -> void:
if _active_player != null:
_active_player.set_fighting_visual(true)
_withdrawal_input_held = false
_network_primary_input_held = Input.is_action_pressed("fish_primary")
_network_primary_input_held = _is_reel_input_pressed()
_network_input_resend_elapsed = 0.0
_network_auto_click_accumulator = 0.0
_network_active_barrier_index = -1
@ -1796,7 +1840,7 @@ func _on_network_fishing_snapshot(snapshot: Dictionary) -> void:
func _update_network_auto_click(delta: float) -> void:
if (
not _catch_controller.auto_click_enabled
or not Input.is_action_pressed("fish_primary")
or not _is_reel_input_pressed()
or _network_active_barrier_index < 0
):
_network_auto_click_accumulator = 0.0
@ -1823,6 +1867,7 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void:
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
catch_display_changed.emit(
0.0, 0.0, PackedFloat32Array(), PackedInt32Array(),
PackedInt32Array(), -1, false

View file

@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
[resource]
script = ExtResource("1_item")
item_id = &"fishing_net"
display_name = "fishing net"
description = "a placeable water net reserved for a future passive fishing system."
active = true
category = 1
stackable = false
max_stack = 1
usable = false
equippable = false
hotbar_allowed = false

View file

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=40 format=3]
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=41 format=3]
[ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"]
[ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"]
@ -39,7 +39,8 @@
[ext_resource type="Resource" path="res://items/catalog/magnet.tres" id="38_magnet"]
[ext_resource type="Resource" path="res://items/catalog/crab_net.tres" id="39_crab_net"]
[ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"]
[ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"]
[resource]
script = ExtResource("1_script")
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel")]
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net")]

View file

@ -14,6 +14,9 @@ enum Kind {
REACH_LEVEL,
DISCOVER_SPECIES,
MASTER_QUALITIES,
CATCH_CREATURE_GROUP,
DISCOVER_CREATURE_GROUP,
MASTER_CREATURE_GROUP,
}
const DAILY_JOB_COUNT: int = 4
@ -111,6 +114,28 @@ static func generate_daily_jobs(
50,
{"fish_id": String(selected.id)},
))
if _has_creature_group(candidates, FishDataType.CreatureGroup.INSECT):
optional.append(_job(
"insect_roundup",
"bug hunt",
"catch 3 insects",
Kind.CATCH_CREATURE_GROUP,
3,
40,
50,
{"creature_group": int(FishDataType.CreatureGroup.INSECT)},
))
if _has_creature_group(candidates, FishDataType.CreatureGroup.SHELLFISH):
optional.append(_job(
"shellfish_search",
"shore patrol",
"catch 3 shellfish",
Kind.CATCH_CREATURE_GROUP,
3,
45,
50,
{"creature_group": int(FishDataType.CreatureGroup.SHELLFISH)},
))
_shuffle(optional, rng)
while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty():
jobs.append(optional.pop_back())
@ -172,6 +197,8 @@ static func generate_weather_schedule(
static func lifetime_chains(
registered_species_count: int,
registered_insect_count: int = 0,
registered_shellfish_count: int = 0,
) -> Array[Dictionary]:
var chains: Array[Dictionary] = [
_chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [
@ -197,15 +224,33 @@ static func lifetime_chains(
[registered_species_count],
[[1500, 2000]],
))
_append_creature_lifetime_chains(
chains,
"insect",
FishDataType.CreatureGroup.INSECT,
registered_insect_count,
)
_append_creature_lifetime_chains(
chains,
"shellfish",
FishDataType.CreatureGroup.SHELLFISH,
registered_shellfish_count,
)
return chains
static func visible_lifetime_jobs(
registered_species_count: int,
claimed_ids: Array[String],
registered_insect_count: int = 0,
registered_shellfish_count: int = 0,
) -> Array[Dictionary]:
var visible: Array[Dictionary] = []
for chain: Dictionary in lifetime_chains(registered_species_count):
for chain: Dictionary in lifetime_chains(
registered_species_count,
registered_insect_count,
registered_shellfish_count,
):
var targets: Array = chain.get("targets", [])
var rewards: Array = chain.get("rewards", [])
for tier_index: int in targets.size():
@ -216,6 +261,9 @@ static func visible_lifetime_jobs(
if job_id in claimed_ids:
continue
var reward: Array = rewards[tier_index]
var extra: Dictionary = {}
if chain.has("creature_group"):
extra["creature_group"] = int(chain["creature_group"])
visible.append(_job(
job_id,
_lifetime_title(str(chain.get("id", "")), int(targets[tier_index])),
@ -226,6 +274,7 @@ static func visible_lifetime_jobs(
int(targets[tier_index]),
int(reward[0]),
int(reward[1]),
extra,
))
break
return visible
@ -256,10 +305,10 @@ static func is_valid_job(value: Variant) -> bool:
and typeof(job.get("description")) == TYPE_STRING
and str(job["description"]).length() <= 160
and is_bounded_integer(
job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_QUALITIES
job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_CREATURE_GROUP
)
and kind >= Kind.CATCH_TOTAL
and kind <= Kind.MASTER_QUALITIES
and kind <= Kind.MASTER_CREATURE_GROUP
and is_bounded_integer(job.get("target"), 1, 1000000000)
and is_bounded_integer(
job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS
@ -318,6 +367,12 @@ static func is_valid_job(value: Variant) -> bool:
and not str(job.get("fish_id", "")).is_empty()
and str(job.get("fish_id", "")).length() <= 96
)
Kind.CATCH_CREATURE_GROUP, Kind.DISCOVER_CREATURE_GROUP, Kind.MASTER_CREATURE_GROUP:
return is_bounded_integer(
job.get("creature_group"),
FishDataType.CreatureGroup.FISH,
FishDataType.CreatureGroup.SHELLFISH,
)
return true
@ -401,13 +456,16 @@ static func _chain(
kind: Kind,
targets: Array[int],
rewards: Array,
extra: Dictionary = {},
) -> Dictionary:
return {
var result: Dictionary = {
"id": id,
"kind": int(kind),
"targets": targets,
"rewards": rewards,
}
result.merge(extra, true)
return result
static func _lifetime_title(chain_id: String, target: int) -> String:
@ -422,6 +480,18 @@ static func _lifetime_title(chain_id: String, target: int) -> String:
return "complete the catalog"
"master":
return "quality master"
"insect_catch":
return "seasoned bug catcher"
"insect_discover":
return "complete the insect catalog"
"insect_master":
return "insect quality master"
"shellfish_catch":
return "seasoned beachcomber"
"shellfish_discover":
return "complete the shellfish catalog"
"shellfish_master":
return "shellfish quality master"
return "long-term job"
@ -437,9 +507,67 @@ static func _lifetime_description(chain_id: String, target: int) -> String:
return "discover all %d cataloged fish" % target
"master":
return "collect every quality of all %d fish" % target
"insect_catch":
return "catch %d insects" % target
"insect_discover":
return "discover all %d cataloged insects" % target
"insect_master":
return "collect every quality of all %d insects" % target
"shellfish_catch":
return "catch %d shellfish" % target
"shellfish_discover":
return "discover all %d cataloged shellfish" % target
"shellfish_master":
return "collect every quality of all %d shellfish" % target
return "keep fishing"
static func _has_creature_group(
candidates: Array[FishDataType],
creature_group: FishDataType.CreatureGroup,
) -> bool:
for fish: FishDataType in candidates:
if (
fish != null
and fish.is_selectable()
and fish.get_creature_group() == creature_group
):
return true
return false
static func _append_creature_lifetime_chains(
chains: Array[Dictionary],
id_prefix: String,
creature_group: FishDataType.CreatureGroup,
registered_count: int,
) -> void:
if registered_count <= 0:
return
var group_data := {"creature_group": int(creature_group)}
chains.append(_chain(
"%s_catch" % id_prefix,
Kind.CATCH_CREATURE_GROUP,
[10, 50, 250],
[[75, 100], [250, 350], [900, 1100]],
group_data,
))
chains.append(_chain(
"%s_discover" % id_prefix,
Kind.DISCOVER_CREATURE_GROUP,
[registered_count],
[[250, 350]],
group_data,
))
chains.append(_chain(
"%s_master" % id_prefix,
Kind.MASTER_CREATURE_GROUP,
[registered_count],
[[750, 1000]],
group_data,
))
static func _shuffle(values: Array[Dictionary], rng: RandomNumberGenerator) -> void:
for index: int in range(values.size() - 1, 0, -1):
var swap_index: int = rng.randi_range(0, index)

View file

@ -25,6 +25,7 @@ var _session: NetworkSession
var _save_manager: PlayerSaveManager
var _network_fishing: NetworkFishingService
var _network_sale: NetworkSaleService
var _network_world_spawns: NetworkWorldSpawnService
var _progression_ready: bool = false
var _host_board: Dictionary = {}
@ -35,6 +36,8 @@ var _daily_completions: Dictionary[String, int] = {}
var _pending_rewards: Array[Dictionary] = []
var _lifetime_claimed: Array[String] = []
var _total_catches: int = 0
var _total_insects: int = 0
var _total_shellfish: int = 0
var _total_sold: int = 0
var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR
@ -65,9 +68,11 @@ func setup(
func bind_authoritative_services(
network_fishing: NetworkFishingService,
network_sale: NetworkSaleService,
network_world_spawns: NetworkWorldSpawnService,
) -> void:
_network_fishing = network_fishing
_network_sale = network_sale
_network_world_spawns = network_world_spawns
if not _network_fishing.local_catch_received.is_connected(
_on_authoritative_catch
):
@ -78,6 +83,12 @@ func bind_authoritative_services(
_network_sale.local_sale_finished.connect(
_on_authoritative_sale_finished
)
if not _network_world_spawns.local_capture_received.is_connected(
_on_authoritative_catch
):
_network_world_spawns.local_capture_received.connect(
_on_authoritative_catch
)
func set_save_manager(save_manager: PlayerSaveManager) -> void:
@ -123,7 +134,10 @@ func get_daily_jobs() -> Array[Dictionary]:
func get_lifetime_jobs() -> Array[Dictionary]:
var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs(
_registered_species_count(), _lifetime_claimed
_registered_species_count(),
_lifetime_claimed,
_registered_creature_count(FishDataType.CreatureGroup.INSECT),
_registered_creature_count(FishDataType.CreatureGroup.SHELLFISH),
)
for job: Dictionary in result:
var progress: int = _lifetime_progress(job)
@ -236,6 +250,8 @@ func to_save_data() -> Dictionary:
"lifetime_claimed": _lifetime_claimed.duplicate(),
"statistics": {
"fish_caught": _total_catches,
"insects_caught": _total_insects,
"shellfish_caught": _total_shellfish,
"fish_sold": _total_sold,
},
}
@ -294,6 +310,8 @@ func restore_from_save_data(data: Dictionary) -> bool:
_lifetime_claimed.append(str(value))
var statistics: Dictionary = data.get("statistics", {})
_total_catches = int(statistics.get("fish_caught", 0))
_total_insects = int(statistics.get("insects_caught", 0))
_total_shellfish = int(statistics.get("shellfish_caught", 0))
_total_sold = int(statistics.get("fish_sold", 0))
if _session != null and _session.is_host():
_active_board = _host_board.duplicate(true)
@ -318,6 +336,8 @@ func reset_to_defaults() -> void:
_pending_rewards.clear()
_lifetime_claimed.clear()
_total_catches = 0
_total_insects = 0
_total_shellfish = 0
_total_sold = 0
_daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR
changed.emit()
@ -333,7 +353,12 @@ static func default_save_data() -> Dictionary:
"daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR,
"pending_rewards": [],
"lifetime_claimed": [],
"statistics": {"fish_caught": 0, "fish_sold": 0},
"statistics": {
"fish_caught": 0,
"insects_caught": 0,
"shellfish_caught": 0,
"fish_sold": 0,
},
}
@ -456,6 +481,12 @@ static func validate_save_data(value: Variant) -> bool:
JobCatalog.is_bounded_integer(
statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE
)
and JobCatalog.is_bounded_integer(
statistics.get("insects_caught", 0), 0, MAX_PROGRESS_VALUE
)
and JobCatalog.is_bounded_integer(
statistics.get("shellfish_caught", 0), 0, MAX_PROGRESS_VALUE
)
and JobCatalog.is_bounded_integer(
statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE
)
@ -514,7 +545,7 @@ func _generate_host_board(cycle: int) -> void:
var candidates: Array[FishDataType] = []
if _catalog != null:
for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_fishable():
if fish != null and fish.is_selectable():
candidates.append(fish)
var schedule_anchor_index: int = _current_weather_segment()
var allow_weather_jobs: bool = (
@ -608,7 +639,13 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
func _on_authoritative_catch(fish_catch: FishCatchType) -> void:
if not _progression_ready or fish_catch == null or not fish_catch.is_valid():
return
_total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE)
match fish_catch.fish.get_creature_group():
FishDataType.CreatureGroup.FISH:
_total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE)
FishDataType.CreatureGroup.INSECT:
_total_insects = mini(_total_insects + 1, MAX_PROGRESS_VALUE)
FishDataType.CreatureGroup.SHELLFISH:
_total_shellfish = mini(_total_shellfish + 1, MAX_PROGRESS_VALUE)
_update_daily_for_catch(fish_catch)
changed.emit()
@ -630,23 +667,40 @@ func _on_authoritative_sale_finished(
func _update_daily_for_catch(fish_catch: FishCatchType) -> void:
var creature_group: int = int(fish_catch.fish.get_creature_group())
var is_fish: bool = creature_group == FishDataType.CreatureGroup.FISH
for job: Dictionary in get_daily_jobs():
var matches: bool = false
match int(job.get("kind", -1)):
JobCatalog.Kind.CATCH_TOTAL:
matches = true
matches = is_fish
JobCatalog.Kind.CATCH_WATER:
matches = fish_catch.fish.is_allowed_in_water(
matches = is_fish and fish_catch.fish.is_allowed_in_water(
int(job.get("water_type", WaterType.Type.OTHER)) as WaterType.Type
)
JobCatalog.Kind.CATCH_QUALITY:
matches = fish_catch.quality >= int(job.get("minimum_quality", 0))
matches = (
is_fish
and fish_catch.quality >= int(job.get("minimum_quality", 0))
)
JobCatalog.Kind.CATCH_PHASE:
matches = int(_world_time.get_phase()) == int(job.get("phase", -1))
matches = (
is_fish
and int(_world_time.get_phase()) == int(job.get("phase", -1))
)
JobCatalog.Kind.CATCH_WEATHER:
matches = int(_world_weather.get_weather()) == int(job.get("weather", -1))
matches = (
is_fish
and int(_world_weather.get_weather())
== int(job.get("weather", -1))
)
JobCatalog.Kind.CATCH_SPECIES:
matches = String(fish_catch.fish_id) == str(job.get("fish_id", ""))
matches = (
is_fish
and String(fish_catch.fish_id) == str(job.get("fish_id", ""))
)
JobCatalog.Kind.CATCH_CREATURE_GROUP:
matches = creature_group == int(job.get("creature_group", -1))
if matches:
_advance_daily(job, 1)
@ -741,26 +795,74 @@ func _lifetime_progress(job: Dictionary) -> int:
JobCatalog.Kind.REACH_LEVEL:
return _experience.get_level() if _experience != null else 0
JobCatalog.Kind.DISCOVER_SPECIES:
return _collection.get_discovered_ids().size() if _collection != null else 0
return _collection_progress(
FishDataType.CreatureGroup.FISH, false
)
JobCatalog.Kind.MASTER_QUALITIES:
var mastered: int = 0
if _collection != null and _catalog != null:
for fish: FishDataType in _catalog.candidates:
if fish != null and _collection.has_mastered(fish.id):
mastered += 1
return mastered
return _collection_progress(
FishDataType.CreatureGroup.FISH, true
)
JobCatalog.Kind.CATCH_CREATURE_GROUP:
match int(job.get("creature_group", -1)):
FishDataType.CreatureGroup.INSECT:
return _total_insects
FishDataType.CreatureGroup.SHELLFISH:
return _total_shellfish
JobCatalog.Kind.DISCOVER_CREATURE_GROUP:
return _collection_progress(
int(job.get("creature_group", -1)) as FishDataType.CreatureGroup,
false,
)
JobCatalog.Kind.MASTER_CREATURE_GROUP:
return _collection_progress(
int(job.get("creature_group", -1)) as FishDataType.CreatureGroup,
true,
)
return 0
func _registered_species_count() -> int:
return _registered_creature_count(FishDataType.CreatureGroup.FISH)
func _registered_creature_count(
creature_group: FishDataType.CreatureGroup,
) -> int:
var count: int = 0
if _catalog != null:
for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_fishable():
if (
fish != null
and fish.is_selectable()
and fish.get_creature_group() == creature_group
):
count += 1
return count
func _collection_progress(
creature_group: FishDataType.CreatureGroup,
require_mastery: bool,
) -> int:
var progress: int = 0
if _collection == null or _catalog == null:
return progress
for fish: FishDataType in _catalog.candidates:
if (
fish == null
or not fish.is_selectable()
or fish.get_creature_group() != creature_group
):
continue
if (
_collection.has_mastered(fish.id)
if require_mastery
else _collection.has_discovered(fish.id)
):
progress += 1
return progress
func _expire_incomplete_daily_jobs() -> void:
_daily_progress.clear()
_daily_completions.clear()
@ -795,7 +897,7 @@ func _matches_canonical_board(board: Dictionary) -> bool:
var candidates: Array[FishDataType] = []
if _catalog != null:
for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_fishable():
if fish != null and fish.is_selectable():
candidates.append(fish)
var anchor_index: int = int(board.get("schedule_anchor_index", -1))
var allow_weather_jobs: bool = (

View file

@ -6,6 +6,7 @@ const FishPoolType = preload("res://fish/fish_pool.gd")
const GameUIType = preload("res://ui/game_ui.gd")
const PlayerType = preload("res://player/player.gd")
const TestWorldType = preload("res://world/test_world.gd")
const WorldLayoutType = preload("res://world/world_layout.gd")
const WaterRecoveryControllerType = preload(
"res://world/water_recovery_controller.gd"
)
@ -124,6 +125,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
const TITLE_MUSIC_PATH: String = (
"res://audio/music/title/as_in_four_wolves.ogg"
)
const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav"
const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3"
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
const PLAYER_MENU_PATTERN_SCALE: float = 0.85
@ -138,6 +140,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
@export_category("Title Music")
@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0
@export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0
@export_range(-40.0, 0.0, 0.5) var new_game_music_volume_db: float = -6.0
@onready var _test_world: TestWorldType = $TestWorld
@onready var _player: PlayerType = %Player
@ -154,6 +157,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
)
@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
@onready var _title_music: AudioStreamPlayer = %TitleMusic
@onready var _new_game_music: AudioStreamPlayer = %NewGameMusic
@onready var _dusk_music: AudioStreamPlayer = %DuskMusic
@onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation
@onready var _pixelation_reset: PixelationResetOverlayType = (
@ -356,6 +360,7 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void:
func _configure_audio_performance_profile(light_profile: bool) -> void:
_title_music.stream = _load_optional_audio_stream(TITLE_MUSIC_PATH)
_new_game_music.stream = _load_optional_audio_stream(NEW_GAME_MUSIC_PATH)
_dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH)
if light_profile:
_shoreline_ambience.set_audio_enabled(false)
@ -409,10 +414,25 @@ func _start_dedicated_server() -> void:
):
_fail_dedicated_server("The server operator list is invalid.")
return
if not _apply_generated_world(config.world_seed, false):
if not _network_chat.configure_dedicated_history(
config.chat_logging,
config.chat_log_path,
):
_fail_dedicated_server(
"The dedicated-server chat privacy configuration is invalid."
)
return
if not _apply_world(
WorldLayoutType.GENERATED,
config.world_seed,
false,
):
_fail_dedicated_server("The configured world seed could not be generated.")
return
if not _network_session.set_host_world_seed(config.world_seed):
if not _network_session.set_host_world(
WorldLayoutType.GENERATED,
config.world_seed,
):
_fail_dedicated_server("The configured world seed is invalid.")
return
_configure_portable_stores()
@ -636,6 +656,7 @@ func _initialize_application(dedicated: bool) -> void:
_world_time,
_world_weather,
_player_jobs,
_player,
)
_player_jobs.set_save_manager(_save_manager)
_save_manager.set_autosave_enabled(false)
@ -747,7 +768,11 @@ func _initialize_application(dedicated: bool) -> void:
_asset_reservations,
_player.inventory_layout,
)
_player_jobs.bind_authoritative_services(_network_fishing, _network_sale)
_player_jobs.bind_authoritative_services(
_network_fishing,
_network_sale,
_network_world_spawns,
)
_network_shop.setup(
_network_session,
_player_spawn_service,
@ -824,6 +849,7 @@ func _initialize_application(dedicated: bool) -> void:
)
_game_ui.setup_data_and_identity(
_data_root,
_save_manager,
_identity_backups,
_player_identity,
_host_identity,
@ -1571,7 +1597,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void:
)
):
_dusk_music_played_for_natural_day = true
if _dusk_music.stream != null:
if (
_dusk_music.stream != null
and not _new_game_music.playing
):
_dusk_music.play(0.0)
@ -1672,26 +1701,34 @@ func _resize_native_overlays() -> void:
_shop_backdrop.size = Vector2(get_window().size)
func _on_new_game_requested(world_seed: int) -> void:
func _on_new_game_requested(
world_layout: StringName,
world_seed: int,
) -> void:
if _gameplay_started or _quit_in_progress:
return
if not _apply_generated_world(world_seed, true):
_start_new_game_music()
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not generate that world seed. Try rolling another world."
"Could not prepare that world. Try another seed or the starter island."
)
return
if not _prepare_host_world_seed(world_seed):
if not _prepare_host_world(world_layout, world_seed):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not prepare the generated world for hosting."
"Could not prepare that world for hosting."
)
return
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed)
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible."
)
@ -1707,13 +1744,14 @@ func _on_continue_game_requested() -> void:
"Failed to load save. The original was preserved."
)
return
var world_layout: StringName = _save_manager.get_world_layout()
var world_seed: int = _save_manager.get_world_seed()
if (
not _apply_generated_world(world_seed, true)
or not _prepare_host_world_seed(world_seed)
not _apply_world(world_layout, world_seed, true)
or not _prepare_host_world(world_layout, world_seed)
):
_game_ui.get_title_screen().report_network_error(
"The saved world could not be generated. The save was preserved."
"The saved world could not be loaded. The save was preserved."
)
return
if not _prepare_private_host():
@ -1740,13 +1778,16 @@ func _prepare_private_host() -> bool:
return true
func _prepare_host_world_seed(world_seed: int) -> bool:
func _prepare_host_world(
world_layout: StringName,
world_seed: int,
) -> bool:
if _network_session.state in [
NetworkSessionType.State.CONNECTION_FAILED,
NetworkSessionType.State.SERVER_LOST,
]:
_network_session.reset_failure()
return _network_session.set_host_world_seed(world_seed)
return _network_session.set_host_world(world_layout, world_seed)
func _enter_gameplay() -> void:
@ -1950,11 +1991,14 @@ func _on_reset_progress_requested() -> void:
pause_menu.report_reset_failure()
return
var world_seed: int = PlayerSaveManager.roll_world_seed()
if not _save_manager.initialize_new_game(world_seed):
if not _save_manager.initialize_new_game(
world_seed,
WorldLayoutType.GENERATED,
):
pause_menu.report_reset_failure()
return
_network_session.disconnect_session("Progress reset.")
if not _apply_generated_world(world_seed, true):
if not _apply_world(WorldLayoutType.GENERATED, world_seed, true):
pause_menu.report_reset_failure()
return
pause_menu.close_for_title_transition()
@ -1964,8 +2008,18 @@ func _on_reset_progress_requested() -> void:
func _apply_joined_world(server_metadata: Dictionary) -> bool:
var world_layout: StringName = WorldLayoutType.normalized(
server_metadata.get(
"world_layout",
String(WorldLayoutType.GENERATED),
),
&"",
)
var world_seed: int = int(server_metadata.get("world_seed", 0))
if _apply_generated_world(world_seed, true):
if (
not world_layout.is_empty()
and _apply_world(world_layout, world_seed, true)
):
return true
_network_session.disconnect_session("World generation failed.")
_join_requested_from_title = false
@ -1974,20 +2028,26 @@ func _apply_joined_world(server_metadata: Dictionary) -> bool:
var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.reopen_to_menu()
title_screen.report_network_error(
"The host's generated world could not be built locally."
"The host's world could not be built locally."
)
_show_title_music(true)
return false
func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool:
if not _test_world.generate_world(world_seed):
func _apply_world(
world_layout: StringName,
world_seed: int,
reposition_player: bool,
) -> bool:
if not _test_world.activate_world(world_layout, world_seed):
return false
var spawn_transform: Transform3D = _test_world.get_player_spawn_transform()
_player_spawn_service.set_spawn_transform(spawn_transform)
if reposition_player:
_player.global_transform = spawn_transform
_player.velocity = Vector3.ZERO
if _application_initialized:
_refresh_active_world_bindings()
if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context(
spawn_transform,
@ -2001,6 +2061,51 @@ func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool:
return true
func _refresh_active_world_bindings() -> void:
if is_instance_valid(_shop_interaction):
if _shop_interaction.local_player_range_changed.is_connected(
_on_shop_range_changed
):
_shop_interaction.local_player_range_changed.disconnect(
_on_shop_range_changed
)
if is_instance_valid(_storage_interaction):
if _storage_interaction.local_player_range_changed.is_connected(
_on_storage_range_changed
):
_storage_interaction.local_player_range_changed.disconnect(
_on_storage_range_changed
)
_shop_interaction = _test_world.get_fishing_shop()
_storage_interaction = _test_world.get_player_storage()
_network_sale.set_shop_interaction(_shop_interaction)
_network_shop.set_shop_interaction(_shop_interaction)
if _dedicated_runtime:
return
if _shop_interaction != null:
_shop_interaction.setup_local_player(_player)
_shop_interaction.local_player_range_changed.connect(
_on_shop_range_changed
)
if _storage_interaction != null:
_storage_interaction.setup_local_player(_player)
_storage_interaction.local_player_range_changed.connect(
_on_storage_range_changed
)
_game_ui.set_world_interactions(
_shop_interaction,
_storage_interaction,
)
_on_shop_range_changed(
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
)
_on_storage_range_changed(
_storage_interaction != null
and _storage_interaction.is_local_player_in_range()
)
func _on_water_recovery_starting() -> void:
_local_recovery_attempt_id = (
"recovery:%s"
@ -2120,6 +2225,7 @@ func _on_quit_requested() -> void:
func _show_title_music(restart_from_beginning: bool = false) -> void:
_new_game_music.stop()
_title_music_requested = true
_replace_title_music_transition()
if _title_music.stream == null:
@ -2131,6 +2237,19 @@ func _show_title_music(restart_from_beginning: bool = false) -> void:
_title_music.play(0.0)
func _start_new_game_music() -> void:
if _new_game_music.stream == null:
return
_title_music_requested = false
_replace_title_music_transition()
_title_music.stop()
_title_music.volume_db = title_music_volume_db
_dusk_music.stop()
_new_game_music.stop()
_new_game_music.volume_db = new_game_music_volume_db
_new_game_music.play(0.0)
func _fade_out_title_music(on_complete: Callable = Callable()) -> void:
_title_music_requested = false
var generation: int = _replace_title_music_transition()
@ -2196,6 +2315,9 @@ func _exit_tree() -> void:
if is_instance_valid(_title_music):
_title_music.stop()
_title_music.stream = null
if is_instance_valid(_new_game_music):
_new_game_music.stop()
_new_game_music.stream = null
if is_instance_valid(_dusk_music):
_dusk_music.stop()
_dusk_music.stream = null

View file

@ -361,6 +361,10 @@ unique_name_in_owner = true
volume_db = -80.0
bus = &"Music"
[node name="NewGameMusic" type="AudioStreamPlayer" parent="."]
unique_name_in_owner = true
bus = &"Music"
[node name="DuskMusic" type="AudioStreamPlayer" parent="." unique_id=992744241]
unique_name_in_owner = true
bus = &"Music"

View file

@ -9,6 +9,13 @@ const MAX_HISTORY: int = 100
const LATE_JOIN_HISTORY: int = 50
const MAX_ID_LENGTH: int = 96
static var _invisible_edge_pattern: RegEx = RegEx.create_from_string(
"^[\\s\\p{Z}\\p{Cf}\\x{2800}]+|[\\s\\p{Z}\\p{Cf}\\x{2800}]+$"
)
static var _visible_content_pattern: RegEx = RegEx.create_from_string(
"[^\\s\\p{Z}\\p{Cf}\\x{2800}]"
)
enum Kind { PLAYER, SYSTEM }
@ -16,8 +23,13 @@ static func sanitize_body(value: Variant) -> String:
if typeof(value) != TYPE_STRING:
return ""
var result: String = str(value).replace("\r", " ").replace("\n", " ")
result = result.replace("\t", " ").strip_edges()
if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS:
result = result.replace("\t", " ")
result = _invisible_edge_pattern.sub(result, "", true)
if (
result.is_empty()
or _visible_content_pattern.search(result) == null
or result.length() > MAX_VISIBLE_CHARACTERS
):
return ""
if result.to_utf8_buffer().size() > MAX_UTF8_BYTES:
return ""

View file

@ -4,7 +4,10 @@ extends Node
const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180
const CALL_COOLDOWN_MILLISECONDS: int = 90
const DEDICATED_CHAT_WARNING: String = (
"Privacy notice: This dedicated server records and retains chat messages."
)
const CALL_PITCH_VARIANTS: Array[float] = [
0.96,
1.03,
@ -38,6 +41,32 @@ var _call_variant_indices: Dictionary[int, int] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
var _dedicated_history_enabled: bool = false
var _chat_log_path: String = ""
func configure_dedicated_history(enabled: bool, log_path: String) -> bool:
_dedicated_history_enabled = false
_chat_log_path = ""
if not enabled:
return true
var normalized_path: String = log_path.strip_edges()
if normalized_path.is_empty() or not normalized_path.is_absolute_path():
return false
var directory: String = normalized_path.get_base_dir()
if DirAccess.make_dir_recursive_absolute(directory) != OK:
return false
var file: FileAccess
if FileAccess.file_exists(normalized_path):
file = FileAccess.open(normalized_path, FileAccess.READ_WRITE)
else:
file = FileAccess.open(normalized_path, FileAccess.WRITE)
if file == null:
return false
file.close()
_dedicated_history_enabled = true
_chat_log_path = normalized_path
return true
func setup(session: NetworkSession) -> void:
@ -288,7 +317,8 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
var request_id: String = data["request_id"]
var ledger: Dictionary = _request_ledgers.get(peer_id, {})
if ledger.has(request_id):
_send_message(peer_id, ledger[request_id])
if typeof(ledger[request_id]) == TYPE_DICTIONARY:
_send_message(peer_id, ledger[request_id])
return
if not _consume_rate(peer_id):
_send_rejection(peer_id, "Slow down.")
@ -308,7 +338,11 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
message["request_id"] = request_id
message["sender_fingerprint"] = data["sender_fingerprint"]
message["sender_signature"] = data["sender_signature"]
ledger[request_id] = message.duplicate(true)
ledger[request_id] = (
message.duplicate(true)
if _should_store_host_history()
else true
)
while ledger.size() > 64:
ledger.erase(ledger.keys().front())
_request_ledgers[peer_id] = ledger
@ -404,9 +438,12 @@ func _apply_message(
kind == NetworkChatProtocol.Kind.PLAYER
and is_sender_filtered(str(data.get("sender_fingerprint", "")))
)
_history.append(stored_message)
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
_history.pop_front()
if _should_store_local_history():
_history.append(stored_message)
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
_history.pop_front()
if _should_log_message(stored_message):
_append_chat_log(stored_message)
if emit_live_signals and _message_is_visible(stored_message):
message_received.emit(stored_message.duplicate(true))
if (
@ -433,8 +470,26 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
if not _session.is_host():
return
_peer_names[peer_id] = display_name
var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY)
receive_chat_history.rpc_id(peer_id, _history.slice(start))
var history: Array[Dictionary] = []
if _dedicated_history_enabled and _session.is_dedicated_host():
var start := maxi(
0,
_history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY,
)
history = _history.slice(start)
# An explicit empty replacement is intentional for player-hosted rooms and
# dedicated servers without logging. It destroys any stale client scrollback
# instead of replaying another player's live-session chat on reconnect.
receive_chat_history.rpc_id(peer_id, history)
if _dedicated_history_enabled and _session.is_dedicated_host():
# Deliver the disclosure as a live system message after scrollback. This
# raises the normal unread indicator without creating speech or audio.
_send_message(peer_id, _make_message(
NetworkChatProtocol.Kind.SYSTEM,
0,
"",
DEDICATED_CHAT_WARNING,
))
_broadcast(_make_message(
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name
))
@ -494,6 +549,44 @@ func _consume_rate(peer_id: int) -> bool:
return true
func _should_store_host_history() -> bool:
return (
_session == null
or not _session.is_dedicated_host()
or _dedicated_history_enabled
)
func _should_store_local_history() -> bool:
return _should_store_host_history()
func _should_log_message(message: Dictionary) -> bool:
return (
_dedicated_history_enabled
and not _chat_log_path.is_empty()
and _session != null
and _session.is_dedicated_host()
and int(message.get("kind", -1)) == NetworkChatProtocol.Kind.PLAYER
)
func _append_chat_log(message: Dictionary) -> void:
var file := FileAccess.open(_chat_log_path, FileAccess.READ_WRITE)
if file == null:
push_error("The configured dedicated-server chat log is unavailable.")
return
file.seek_end()
file.store_line(JSON.stringify({
"recorded_at_utc": (
Time.get_datetime_string_from_system(true, false) + "Z"
),
"sender_display_name": str(message.get("sender_display_name", "")),
"body": str(message.get("body", "")),
}))
file.close()
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state in [
NetworkSession.State.INACTIVE,

View file

@ -1,6 +1,8 @@
class_name NetworkProtocol
extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 9
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
@ -29,6 +31,7 @@ const JOBS_CAPABILITY: String = "jobs_v1"
const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1"
const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1"
const WORLD_GENERATION_CAPABILITY: String = "world_generation_v1"
const WORLD_LAYOUT_CAPABILITY: String = "world_layout_v1"
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
@ -182,6 +185,7 @@ static func make_client_hello(
APPEARANCE_PREVIEW_CAPABILITY,
BACKPACK_SHOP_CAPABILITY,
WORLD_GENERATION_CAPABILITY,
WORLD_LAYOUT_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -286,6 +290,7 @@ static func make_server_hello(
max_players: int,
server_display_name: String = "NETfishing",
world_seed: int = DEFAULT_WORLD_SEED,
world_layout: StringName = WorldLayoutType.GENERATED,
) -> Dictionary:
return {
"accepted": accepted,
@ -298,6 +303,7 @@ static func make_server_hello(
"player_count": player_count,
"max_players": max_players,
"world_seed": world_seed,
"world_layout": String(world_layout),
"capability_flags": PackedStringArray([
"movement_v1",
"fishing_v1",
@ -316,6 +322,7 @@ static func make_server_hello(
WORLD_SPAWN_CAPABILITY,
APPEARANCE_PREVIEW_CAPABILITY,
WORLD_GENERATION_CAPABILITY,
WORLD_LAYOUT_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -91,6 +91,10 @@ func setup(
_session.state_changed.connect(_on_session_state_changed)
func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_shop_interaction = interaction
func is_local_sale_pending() -> bool:
return not _pending_local_request_id.is_empty()

View file

@ -86,6 +86,7 @@ var _last_server_player_count: int = 0
var _last_server_display_name: String = ""
var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _last_server_world_layout: StringName = WorldLayout.GENERATED
var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
@ -113,6 +114,7 @@ var _session_operator_fingerprints: Dictionary[String, bool] = {}
var _operator_peer_ids: Dictionary[int, bool] = {}
var _local_operator: bool = false
var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _host_world_layout: StringName = WorldLayout.GENERATED
func _ready() -> void:
@ -275,6 +277,7 @@ func _register_player_host() -> void:
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -531,20 +534,35 @@ func get_last_server_metadata() -> Dictionary:
"player_count": _last_server_player_count,
"max_players": _last_server_max_players,
"world_seed": _last_server_world_seed,
"world_layout": String(_last_server_world_layout),
}
func set_host_world_seed(seed: int) -> bool:
return set_host_world(WorldLayout.GENERATED, seed)
func set_host_world(world_layout: StringName, seed: int) -> bool:
if (
state != State.INACTIVE
or not WorldLayout.is_valid(world_layout)
or seed <= 0
or seed > NetworkProtocol.MAX_WORLD_SEED
):
return false
_host_world_layout = world_layout
_host_world_seed = seed
return true
func get_authoritative_world_layout() -> StringName:
return (
_last_server_world_layout
if is_joined_client()
else _host_world_layout
)
func get_authoritative_world_seed() -> int:
return _last_server_world_seed if is_joined_client() else _host_world_seed
@ -583,6 +601,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -1190,6 +1209,15 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
if (
_host_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
if identity.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
@ -1278,6 +1306,7 @@ func submit_client_hello(data: Dictionary) -> void:
session_max_players,
_session_display_name,
_host_world_seed,
_host_world_layout,
)
)
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
@ -1332,6 +1361,9 @@ func receive_server_hello(data: Dictionary) -> void:
or typeof(data.get("game_version")) != TYPE_STRING
or typeof(data.get("rejection_code")) != TYPE_INT
or typeof(data.get("world_seed")) != TYPE_INT
or typeof(
data.get("world_layout", String(WorldLayout.GENERATED))
) not in [TYPE_STRING, TYPE_STRING_NAME]
):
_teardown_peer()
_fail("The server sent an invalid handshake response.")
@ -1362,6 +1394,14 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer()
_fail("The server sent an invalid world seed.")
return
var received_world_layout := WorldLayout.normalized(
data.get("world_layout", String(WorldLayout.GENERATED)),
&"",
)
if received_world_layout.is_empty():
_teardown_peer()
_fail("The server sent an invalid world layout.")
return
_session_id = str(data.get("session_id", ""))
_last_server_max_players = int(data.get(
"max_players",
@ -1373,6 +1413,7 @@ func receive_server_hello(data: Dictionary) -> void:
"protocol_version", NetworkProtocol.PROTOCOL_VERSION
))
_last_server_world_seed = received_world_seed
_last_server_world_layout = received_world_layout
_server_capabilities = PackedStringArray()
var advertised_capabilities: Variant = data.get(
"capability_flags", PackedStringArray()
@ -1391,6 +1432,13 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer()
_fail("This server does not support generated worlds.")
return
if (
received_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities
):
_teardown_peer()
_fail("This server does not support selectable world layouts.")
return
var local_peer_id: int = multiplayer.get_unique_id()
_registry.clear()
_registry.add_peer(
@ -1407,6 +1455,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)

View file

@ -83,6 +83,10 @@ func setup(
_session.state_changed.connect(_on_session_state_changed)
func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_interaction = interaction
func can_request_purchase() -> bool:
return (
_session != null

View file

@ -188,7 +188,7 @@ func activate_process_root(path: String) -> bool:
func path_for(store_owner: StringName) -> String:
var relative: String = {
&"player_save": "player/player_save.json",
&"player_save": "player/player_save.nfsave",
&"network_profile": "player/network_profile.json",
&"player_appearance": "player/player_appearance.json",
&"saved_servers": "social/saved_servers.json",
@ -208,6 +208,10 @@ func identity_backup_directory() -> String:
return root_path.path_join("identity-backups")
func progression_backup_directory() -> String:
return root_path.path_join("progression-backups")
func migration_backup_directory() -> String:
return root_path.path_join("backups/migrations")
@ -347,7 +351,7 @@ func _test_writable(path: String) -> bool:
func _create_layout(path: String, id: String) -> bool:
for relative: String in [
"player", "social", "backups/saves", "backups/migrations",
"backups/conflicts", "identity-backups",
"backups/conflicts", "identity-backups", "progression-backups",
]:
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
return false
@ -369,6 +373,7 @@ func _create_layout(path: String, id: String) -> bool:
+ "This folder is safe to synchronize with tools such as Syncthing.\n"
+ "Active private identity keys remain device-local.\n"
+ "Encrypted identity backups require their passphrase.\n"
+ "Progression exports do not contain identity keys or social data.\n"
+ "Chat and Session Mail are not stored here.\n"
+ "Do not play the same profile on two devices at the same time.\n"
+ "Conflicting edits are preserved under backups/conflicts; they are not merged.\n"

View file

@ -122,6 +122,7 @@ static func migrate_active_to(
_remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))}
for relative: String in [
"player/player_save.nfsave",
"player/player_save.json",
"player/network_profile.json",
"player/player_appearance.json",
@ -134,7 +135,11 @@ static func migrate_active_to(
var source: String = data_root.root_path.path_join(relative)
if not FileAccess.file_exists(source):
continue
if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
if not bool(
_copy_verified_owned_file(
source, staging.path_join(relative)
).get("ok", false)
):
_remove_tree(staging)
return {"ok": false, "message": "Migration validation failed for %s." % relative}
if DirAccess.dir_exists_absolute(normalized):
@ -248,6 +253,30 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona
}
static func _copy_verified_owned_file(
source: String,
destination: String,
) -> Dictionary:
if source.get_file() != "player_save.nfsave":
return _copy_verified_json(source, destination)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source)
if not bool(decoded.get("ok", false)):
return {"ok": false}
var save_data: Dictionary = decoded["data"]
var version: int = int(save_data.get("save_version", -1))
if version < 1 or version > PlayerSaveManager.SAVE_VERSION:
return {"ok": false}
var bytes: PackedByteArray = PortableFileGuard.read_bytes(source)
if bytes.is_empty() or not _write_bytes(destination, bytes):
return {"ok": false}
return {
"ok": (
PortableFileGuard.hash_file(destination)
== PortableFileGuard.hash_bytes(bytes)
)
}
static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
if filename == "player_save.json":
var version: int = int(data.get("save_version", -1))

View file

@ -11,7 +11,7 @@ config_version=5
[application]
config/name="NETFISHING"
config/version="0.13.1-alpha"
config/version="0.14.0-alpha"
run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://art/exported/system_icons/netfishing_256.png"
@ -107,6 +107,11 @@ fish_primary={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
reel_alternate={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":96,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
camera_drag={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)

View file

@ -31,9 +31,13 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd")
const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd"
)
const WorldLayoutType = preload("res://world/world_layout.gd")
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const PlayerType = preload("res://player/player.gd")
const SAVE_VERSION: int = 9
const SAVE_VERSION: int = 10
const LEGACY_SAVE_FILENAME := "player_save.json"
const ARCHIVE_EXTENSION := ".nfsave"
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
const MAX_SAFE_BALANCE: int = 1000000000000
const DEFAULT_WORLD_SEED: int = 13001
@ -49,6 +53,8 @@ class LoadSnapshot:
var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = []
var unlocked_bait_ids: Array[StringName] = []
var active_bait_id: StringName
var active_lure_id: StringName
var hotbar_slots: Array[StringName] = []
var fish_hotbar_slots: Array[StringName] = []
var selected_hotbar_slot: int = 0
@ -58,6 +64,7 @@ class LoadSnapshot:
var cooler_capacity_level: int = 0
var art_unlock_mask: int = 0
var total_experience: int = 0
var world_layout: StringName = WorldLayoutType.GENERATED
var world_seed: int = DEFAULT_WORLD_SEED
var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR
var has_world_weather_state: bool = false
@ -87,6 +94,7 @@ var _experience: PlayerExperienceType
var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType
var _player: PlayerType
var _autosave_timer: Timer
var _is_configured: bool = false
var _is_restoring: bool = false
@ -96,6 +104,7 @@ var _autosave_enabled: bool = false
var _save_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
var _world_layout: StringName = WorldLayoutType.GENERATED
var _world_seed: int = DEFAULT_WORLD_SEED
@ -112,6 +121,21 @@ func _backup_path() -> String:
return _save_path + ".backup"
func _codec_scratch_path() -> String:
return _save_path + ".codec.tmp"
func _legacy_save_path() -> String:
return _save_path.get_base_dir().path_join(LEGACY_SAVE_FILENAME)
func _read_path() -> String:
if FileAccess.file_exists(_save_path):
return _save_path
var legacy: String = _legacy_save_path()
return legacy if FileAccess.file_exists(legacy) else ""
func _ready() -> void:
_autosave_timer = Timer.new()
_autosave_timer.one_shot = true
@ -135,6 +159,7 @@ func setup(
world_time: WorldTimeServiceType,
world_weather: WorldWeatherServiceType,
jobs: PlayerJobServiceType,
player: PlayerType,
) -> void:
_inventory = inventory
_collection_log = collection_log
@ -151,6 +176,7 @@ func setup(
_world_time = world_time
_world_weather = world_weather
_jobs = jobs
_player = player
_is_configured = (
_inventory != null
and _collection_log != null
@ -167,6 +193,7 @@ func setup(
and _world_time != null
and _world_weather != null
and _jobs != null
and _player != null
)
if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.")
@ -209,6 +236,10 @@ func setup(
_experience.experience_changed.connect(_on_experience_changed)
if not _jobs.changed.is_connected(_mark_dirty):
_jobs.changed.connect(_mark_dirty)
if not _player.active_bait_changed.is_connected(_on_active_tackle_changed):
_player.active_bait_changed.connect(_on_active_tackle_changed)
if not _player.active_lure_changed.is_connected(_on_active_tackle_changed):
_player.active_lure_changed.connect(_on_active_tackle_changed)
func load_player_data() -> bool:
@ -216,24 +247,22 @@ func load_player_data() -> bool:
return false
_automatic_saving_blocked = false
_recover_interrupted_write()
if _save_path.is_empty() or not FileAccess.file_exists(_save_path):
var read_path: String = _read_path()
if _save_path.is_empty() or read_path.is_empty():
_is_dirty = false
return true
var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
_handle_corrupt_save("Unable to open player save.")
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
_handle_corrupt_save("Player save could not be decoded.", read_path)
return false
var json_text: String = save_file.get_as_text()
save_file.close()
_expected_hash = PortableFileGuard.hash_file(_save_path)
var json := JSON.new()
var parse_error: Error = json.parse(json_text)
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
_handle_corrupt_save("Player save contains malformed JSON.")
return false
var save_data: Dictionary = json.data
_expected_hash = (
PortableFileGuard.hash_file(_save_path)
if read_path == _save_path else ""
)
var save_data: Dictionary = decoded["data"]
var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION:
_automatic_saving_blocked = true
@ -250,13 +279,16 @@ func load_player_data() -> bool:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
_handle_corrupt_save(
"Player save version %d is unsupported." % version
"Player save version %d is unsupported." % version,
read_path,
)
return false
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null:
_handle_corrupt_save("Player save failed structural validation.")
_handle_corrupt_save(
"Player save failed structural validation.", read_path
)
return false
_is_restoring = true
@ -277,6 +309,7 @@ func load_player_data() -> bool:
_bag.replace_all_items(snapshot.bag_items)
and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids)
)
var tackle_restored: bool = _restore_tackle_selection(snapshot)
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
snapshot.reel_speed_level,
snapshot.barrier_power_level
@ -302,6 +335,7 @@ func load_player_data() -> bool:
var world_time_restored: bool = (
_world_time.restore_persistent_time_hours(snapshot.world_time_hours)
)
_world_layout = snapshot.world_layout
_world_seed = snapshot.world_seed
var world_weather_restored: bool = true
if snapshot.has_world_weather_state:
@ -316,6 +350,7 @@ func load_player_data() -> bool:
or not collection_restored
or not wallet_restored
or not bag_restored
or not tackle_restored
or not hotbar_restored
or not upgrades_restored
or not cooler_restored
@ -329,7 +364,7 @@ func load_player_data() -> bool:
push_error(
(
"Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s "
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s"
)
@ -338,6 +373,7 @@ func load_player_data() -> bool:
collection_restored,
wallet_restored,
bag_restored,
tackle_restored,
hotbar_restored,
upgrades_restored,
cooler_restored,
@ -352,6 +388,8 @@ func load_player_data() -> bool:
return false
_is_dirty = false
if read_path == _legacy_save_path():
_migrate_legacy_plaintext_save(save_data, read_path)
print(
"Loaded player save version %d with %d catches."
% [SAVE_VERSION, snapshot.catches.size()]
@ -362,24 +400,21 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new()
_recover_interrupted_write()
result.has_primary_file = FileAccess.file_exists(_save_path)
var read_path: String = _read_path()
result.has_primary_file = not read_path.is_empty()
if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING
result.message = "no save found."
return result
var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
result.status = SaveInspectionType.Status.IO_ERROR
result.message = "the save could not be read."
return result
var json := JSON.new()
var parse_error: Error = json.parse(save_file.get_as_text())
save_file.close()
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
result.status = SaveInspectionType.Status.MALFORMED
result.message = "the save is corrupt and was preserved."
return result
var save_data: Dictionary = json.data
var save_data: Dictionary = decoded["data"]
result.detected_version = _read_integer(save_data.get("save_version"), -1)
if result.detected_version > SAVE_VERSION:
result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION
@ -408,18 +443,112 @@ func inspect_save() -> SaveInspectionType:
return result
func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool:
func export_progression_archive(path: String) -> Dictionary:
if not _is_configured or path.is_empty():
return {"ok": false, "message": "progression export is unavailable."}
if _is_dirty and not save_if_dirty():
return {"ok": false, "message": "progression could not be saved first."}
var source_path: String = _read_path()
if source_path.is_empty():
return {"ok": false, "message": "there is no progression to export."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
source_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
return {"ok": false, "message": "the active progression could not be read."}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive(
prepared["data"],
path + ".codec.tmp",
)
if bytes.is_empty():
return {"ok": false, "message": "the progression archive could not be encoded."}
var result: Dictionary = PortableFileGuard.write_guarded(
path,
bytes,
PortableFileGuard.hash_file(path),
_data_root.conflict_directory(),
_data_root.device_id,
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be written."}
var verified: Dictionary = inspect_progression_archive(path)
if not bool(verified.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be verified."}
verified["message"] = "progression archive created."
return verified
func inspect_progression_archive(path: String) -> Dictionary:
var decoded: Dictionary = ProgressionSaveCodec.read_archive(path)
if not bool(decoded.get("ok", false)):
return {
"ok": false,
"message": str(decoded.get("message", "could not open progression archive.")),
}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
prepared["game_version"] = str(decoded.get("game_version", ""))
prepared["created_at_unix"] = int(decoded.get("created_at_unix", 0))
return prepared
func import_progression_archive(path: String) -> Dictionary:
if not _is_configured:
return {"ok": false, "message": "progression import is unavailable."}
var inspected: Dictionary = inspect_progression_archive(path)
if not bool(inspected.get("ok", false)):
return inspected
var current_path: String = _read_path()
if not current_path.is_empty() and not _archive_replaced_save(current_path):
return {"ok": false, "message": "the current progression could not be backed up."}
var expected_hash: String = PortableFileGuard.hash_file(_save_path)
var result: Dictionary = _write_current_save_data(
inspected["data"], expected_hash
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the imported progression could not be installed."}
_expected_hash = str(result.get("hash", ""))
_remove_legacy_save_files()
_is_dirty = false
_automatic_saving_blocked = false
return {
"ok": true,
"message": "progression imported.",
"catch_count": inspected["catch_count"],
"wallet_balance": inspected["wallet_balance"],
"discovered_species_count": inspected["discovered_species_count"],
}
func initialize_new_game(
world_seed: int = DEFAULT_WORLD_SEED,
world_layout: StringName = WorldLayoutType.GENERATED,
) -> bool:
if not _is_configured:
return false
if world_seed <= 0 or world_seed > MAX_WORLD_SEED:
if (
world_seed <= 0
or world_seed > MAX_WORLD_SEED
or not WorldLayoutType.is_valid(world_layout)
):
return false
_automatic_saving_blocked = false
_restore_defaults()
_world_layout = world_layout
_world_seed = world_seed
_is_dirty = true
return true
func get_world_layout() -> StringName:
return _world_layout
func get_world_seed() -> int:
return _world_seed
@ -431,9 +560,19 @@ static func roll_world_seed() -> int:
func delete_progression_save() -> bool:
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
return false
for path: String in [_temp_path(), _backup_path()]:
for primary_path: String in [_save_path, _legacy_save_path()]:
if (
FileAccess.file_exists(primary_path)
and not _remove_if_present(primary_path)
):
return false
for path: String in [
_temp_path(),
_backup_path(),
_codec_scratch_path(),
_legacy_save_path() + ".tmp",
_legacy_save_path() + ".backup",
]:
if FileAccess.file_exists(path) and not _remove_if_present(path):
push_warning("Unable to remove stale player-save auxiliary file.")
if _autosave_timer != null:
@ -485,16 +624,8 @@ func save_now() -> bool:
var save_data: Dictionary = _build_save_dictionary()
if save_data.is_empty():
return false
var json_text: String = JSON.stringify(save_data, "\t")
if json_text.is_empty():
return false
var result := PortableFileGuard.write_guarded(
_save_path,
json_text.to_utf8_buffer(),
_expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
var result: Dictionary = _write_current_save_data(
save_data, _expected_hash
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(
@ -574,6 +705,17 @@ func _build_save_dictionary() -> Dictionary:
)
):
return {}
var active_bait_id: StringName = _player.active_bait_id
var active_lure_id: StringName = _player.active_lure_id
if (
(not active_bait_id.is_empty() and not _valid_active_tackle(
active_bait_id, true
))
or (not active_lure_id.is_empty() and not _valid_active_tackle(
active_lure_id, false
))
):
return {}
return {
"save_version": SAVE_VERSION,
@ -592,6 +734,10 @@ func _build_save_dictionary() -> Dictionary:
"items": serialized_items,
"unlocked_bait_ids": serialized_unlocked_baits,
},
"tackle": {
"active_bait_id": String(active_bait_id),
"active_lure_id": String(active_lure_id),
},
"hotbar": {
"selected_slot": _hotbar.get_selected_slot(),
"slots": serialized_slots,
@ -603,6 +749,7 @@ func _build_save_dictionary() -> Dictionary:
"art": _art_unlocks.to_save_data(),
"experience": _experience.to_save_data(),
"world": {
"layout": String(_world_layout),
"seed": _world_seed,
"time_hours": _world_time.get_persistent_time_hours(),
"weather": int(_world_weather.get_persistent_weather()),
@ -620,6 +767,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
or typeof(save_data.get("collection")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
or typeof(save_data.get("tackle")) != TYPE_DICTIONARY
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
@ -630,6 +778,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var collection_data: Dictionary = save_data["collection"]
var inventory_data: Dictionary = save_data["inventory"]
var bag_data: Dictionary = save_data["bag"]
var tackle_data: Dictionary = save_data["tackle"]
var hotbar_data: Dictionary = save_data["hotbar"]
var inventory_layout_data: Dictionary = save_data["inventory_layout"]
var experience_data: Dictionary = save_data["experience"]
@ -665,6 +814,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY
)
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
or typeof(tackle_data.get("active_bait_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or typeof(tackle_data.get("active_lure_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or not hotbar_data.has("selected_slot")
or not experience_data.has("total_experience")
):
@ -684,6 +839,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
return null
var snapshot := LoadSnapshot.new()
if world_data.has("layout"):
if not WorldLayoutType.is_valid(world_data["layout"]):
return null
snapshot.world_layout = WorldLayoutType.normalized(
world_data["layout"]
)
if world_data.has("seed"):
snapshot.world_seed = _read_integer(
world_data["seed"],
@ -897,6 +1058,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
):
seen_unlocked_baits[owned.item_id] = true
snapshot.unlocked_bait_ids.append(owned.item_id)
snapshot.active_bait_id = _validated_tackle_id(
StringName(str(tackle_data.get("active_bait_id", ""))),
true,
seen_items,
)
snapshot.active_lure_id = _validated_tackle_id(
StringName(str(tackle_data.get("active_lure_id", ""))),
false,
seen_items,
)
var slot_values: Array = hotbar_data["slots"]
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
@ -995,6 +1166,8 @@ func _migrate_save(
migrated = _migrate_version_7_to_8(migrated)
8:
migrated = _migrate_version_8_to_9(migrated)
9:
migrated = _migrate_version_9_to_10(migrated)
_:
return {}
if migrated.is_empty():
@ -1242,6 +1415,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary:
return migrated
func _migrate_version_9_to_10(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
migrated["tackle"] = {
"active_bait_id": "",
"active_lure_id": "",
}
migrated["save_version"] = 10
return migrated
func _mark_dirty() -> void:
if (
_is_restoring
@ -1291,33 +1474,169 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void:
_mark_dirty()
func _on_active_tackle_changed(_item_id: StringName) -> void:
_mark_dirty()
func _on_autosave_timeout() -> void:
if _is_dirty:
save_now()
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(_save_path):
_remove_if_present(_temp_path())
_remove_if_present(_backup_path())
_remove_if_present(_codec_scratch_path())
_recover_path_write(_save_path)
_recover_path_write(_legacy_save_path())
func _recover_path_write(path: String) -> void:
var temporary: String = path + ".tmp"
var backup: String = path + ".backup"
if FileAccess.file_exists(path):
_remove_if_present(temporary)
_remove_if_present(backup)
return
if FileAccess.file_exists(_backup_path()):
_rename_file(_backup_path(), _save_path)
_remove_if_present(_temp_path())
if FileAccess.file_exists(backup):
_rename_file(backup, path)
_remove_if_present(temporary)
func _handle_corrupt_save(message: String) -> void:
func _write_current_save_data(
save_data: Dictionary,
expected_hash: String,
) -> Dictionary:
var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save(
save_data,
_codec_scratch_path(),
)
if bytes.is_empty():
return {"ok": false}
return PortableFileGuard.write_guarded(
_save_path,
bytes,
expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
)
func _prepare_external_save_data(value: Variant) -> Dictionary:
if typeof(value) != TYPE_DICTIONARY:
return {"ok": false, "message": "progression data is malformed."}
var save_data: Dictionary = (value as Dictionary).duplicate(true)
var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION:
return {
"ok": false,
"message": "this progression belongs to a newer game version.",
}
if version < 1:
return {"ok": false, "message": "the progression version is unsupported."}
if version != SAVE_VERSION:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
return {
"ok": false,
"message": "the progression version is unsupported.",
}
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null:
return {"ok": false, "message": "the progression is structurally invalid."}
return {
"ok": true,
"data": save_data,
"catch_count": snapshot.catches.size(),
"wallet_balance": snapshot.wallet_balance,
"discovered_species_count": snapshot.discovered_ids.size(),
"world_layout": String(snapshot.world_layout),
"world_seed": snapshot.world_seed,
}
func _migrate_legacy_plaintext_save(
save_data: Dictionary,
legacy_path: String,
) -> void:
var result: Dictionary = _write_current_save_data(save_data, "")
if not bool(result.get("ok", false)):
push_warning("Legacy progression loaded but could not be encrypted yet.")
return
_expected_hash = str(result.get("hash", ""))
if not _archive_replaced_save(legacy_path, "plaintext-migration"):
push_warning(
"Legacy progression was encrypted, but its migration backup failed."
)
return
_remove_legacy_save_files()
print("Upgraded legacy plaintext progression to the opaque save format.")
func _archive_replaced_save(
source_path: String,
reason: String = "before-import",
) -> bool:
if source_path.is_empty() or not FileAccess.file_exists(source_path):
return true
var timestamp: String = Time.get_datetime_string_from_system().replace(
":", "-"
)
var destination: String = _data_root.root_path.path_join(
"backups/saves/player-save-%s-%s%s"
% [reason, timestamp, ARCHIVE_EXTENSION]
)
if FileAccess.file_exists(destination):
destination = destination.trim_suffix(ARCHIVE_EXTENSION) + (
"-%s%s" % [Time.get_ticks_usec(), ARCHIVE_EXTENSION]
)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
source_path == _legacy_save_path(),
)
var bytes: PackedByteArray
if bool(decoded.get("ok", false)):
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if bool(prepared.get("ok", false)):
bytes = ProgressionSaveCodec.encode_archive(
prepared["data"], destination + ".codec.tmp"
)
if bytes.is_empty():
bytes = PortableFileGuard.read_bytes(source_path)
destination = (
destination.trim_suffix(ARCHIVE_EXTENSION) + ".preserved"
)
if bytes.is_empty():
return false
var result: Dictionary = PortableFileGuard.write_guarded(
destination,
bytes,
"",
_data_root.conflict_directory(),
_data_root.device_id,
)
return bool(result.get("ok", false))
func _remove_legacy_save_files() -> void:
for path: String in [
_legacy_save_path(),
_legacy_save_path() + ".tmp",
_legacy_save_path() + ".backup",
]:
_remove_if_present(path)
func _handle_corrupt_save(message: String, source_path: String) -> void:
push_warning(message)
_restore_defaults()
if not FileAccess.file_exists(_save_path):
if not FileAccess.file_exists(source_path):
return
var timestamp: int = int(Time.get_unix_time_from_system())
var corrupt_path: String = (
"%s.corrupt-%d" % [_save_path, timestamp]
"%s.corrupt-%d" % [source_path, timestamp]
)
if FileAccess.file_exists(corrupt_path):
corrupt_path += "-%d" % Time.get_ticks_usec()
if not _rename_file(_save_path, corrupt_path):
if not _rename_file(source_path, corrupt_path):
_automatic_saving_blocked = true
push_warning(
"Corrupt player save was left in place; automatic saving is "
@ -1350,6 +1669,8 @@ func _restore_defaults() -> void:
_bag.replace_all_items(default_items)
var default_unlocked_baits: Array[StringName] = []
_bag.replace_unlocked_bait_ids(default_unlocked_baits)
_player.unequip_bait()
_player.unequip_lure()
_fishing_upgrades.reset_to_defaults()
_cooler_capacity.reset_to_defaults()
_inventory_layout.reset_to_defaults()
@ -1360,12 +1681,60 @@ func _restore_defaults() -> void:
WorldTimeServiceType.DEFAULT_START_HOUR
)
_world_weather.reset_persistent_state()
_world_layout = WorldLayoutType.GENERATED
_world_seed = DEFAULT_WORLD_SEED
_jobs.reset_to_defaults()
_is_restoring = false
_is_dirty = false
func _valid_active_tackle(item_id: StringName, bait: bool) -> bool:
var item = _item_catalog.get_item_by_id(item_id)
return (
item != null
and item.is_available()
and (item.is_bait() if bait else item.is_lure())
and _bag.owns_item(item_id)
)
func _validated_tackle_id(
item_id: StringName,
bait: bool,
seen_items: Dictionary[StringName, bool],
) -> StringName:
if item_id.is_empty():
return StringName()
var item = _item_catalog.get_item_by_id(item_id)
if (
item == null
or not item.is_available()
or not seen_items.has(item_id)
or not (item.is_bait() if bait else item.is_lure())
):
push_warning("Skipped invalid saved active tackle '%s'." % String(item_id))
return StringName()
return item_id
func _restore_tackle_selection(snapshot: LoadSnapshot) -> bool:
_player.unequip_bait()
_player.unequip_lure()
var bait_restored: bool = (
snapshot.active_bait_id.is_empty()
or _player.equip_bait(
_item_catalog.get_item_by_id(snapshot.active_bait_id)
)
)
var lure_restored: bool = (
snapshot.active_lure_id.is_empty()
or _player.equip_lure(
_item_catalog.get_item_by_id(snapshot.active_lure_id)
)
)
return bait_restored and lure_restored
func _read_integer(
value: Variant,
invalid_value: int,

View file

@ -0,0 +1,179 @@
class_name ProgressionSaveCodec
extends RefCounted
const MAGIC := "NETFISHING_PROGRESSION_CONTAINER"
const FORMAT_VERSION := 1
const LOCAL_KIND := "local_save"
const ARCHIVE_KIND := "portable_archive"
const MAX_CONTAINER_BYTES := 16 * 1024 * 1024
# This key keeps routine progression files opaque to casual editing. Since the
# client is open source, it is intentionally not treated as a security secret.
const CONTAINER_PASSPHRASE := (
"NETfishing progression container v1 / not an identity credential"
)
static func encode_local_save(
save_data: Dictionary,
scratch_path: String,
) -> PackedByteArray:
return _encode_container(LOCAL_KIND, save_data, scratch_path)
static func encode_archive(
save_data: Dictionary,
scratch_path: String,
) -> PackedByteArray:
return _encode_container(ARCHIVE_KIND, save_data, scratch_path)
static func read_local_save(
path: String,
allow_legacy_plaintext: bool = false,
) -> Dictionary:
if allow_legacy_plaintext and not _looks_encrypted(path):
var legacy: Dictionary = _read_plaintext_dictionary(path)
if bool(legacy.get("ok", false)):
legacy["legacy_plaintext"] = true
return legacy
return _read_container(path, LOCAL_KIND)
static func read_archive(path: String) -> Dictionary:
if not _looks_encrypted(path):
return {"ok": false, "message": "progression archive is not recognized."}
return _read_container(path, ARCHIVE_KIND)
static func _encode_container(
kind: String,
save_data: Dictionary,
scratch_path: String,
) -> PackedByteArray:
if save_data.is_empty() or scratch_path.is_empty():
return PackedByteArray()
var payload_json: String = JSON.stringify(save_data)
if payload_json.is_empty():
return PackedByteArray()
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
var envelope: Dictionary = {
"magic": MAGIC,
"format_version": FORMAT_VERSION,
"kind": kind,
"game_version": str(
ProjectSettings.get_setting("application/config/version", "")
),
"created_at_unix": int(Time.get_unix_time_from_system()),
"payload_sha256": PortableFileGuard.hash_bytes(payload_bytes),
"payload_json": payload_json,
}
if DirAccess.make_dir_recursive_absolute(
scratch_path.get_base_dir()
) != OK:
return PackedByteArray()
_remove_if_present(scratch_path)
var file: FileAccess = FileAccess.open_encrypted_with_pass(
scratch_path,
FileAccess.WRITE,
CONTAINER_PASSPHRASE,
)
if file == null:
return PackedByteArray()
file.store_string(JSON.stringify(envelope))
file.flush()
var write_ok: bool = file.get_error() == OK
file.close()
if not write_ok:
_remove_if_present(scratch_path)
return PackedByteArray()
var encoded: PackedByteArray = PortableFileGuard.read_bytes(
scratch_path,
MAX_CONTAINER_BYTES,
)
_remove_if_present(scratch_path)
return encoded
static func _read_container(path: String, expected_kind: String) -> Dictionary:
if (
path.is_empty()
or not FileAccess.file_exists(path)
or FileAccess.get_size(path) > MAX_CONTAINER_BYTES
):
return {"ok": false, "message": "progression file is unavailable."}
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path,
FileAccess.READ,
CONTAINER_PASSPHRASE,
)
if file == null:
return {"ok": false, "message": "progression file could not be opened."}
var envelope_text: String = file.get_as_text()
file.close()
var envelope_json := JSON.new()
if (
envelope_json.parse(envelope_text) != OK
or typeof(envelope_json.data) != TYPE_DICTIONARY
):
return {"ok": false, "message": "progression container is malformed."}
var envelope: Dictionary = envelope_json.data
if (
envelope.get("magic") != MAGIC
or int(envelope.get("format_version", -1)) != FORMAT_VERSION
or str(envelope.get("kind", "")) != expected_kind
):
return {"ok": false, "message": "progression container is unsupported."}
var payload_json: String = str(envelope.get("payload_json", ""))
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
if (
payload_json.is_empty()
or str(envelope.get("payload_sha256", ""))
!= PortableFileGuard.hash_bytes(payload_bytes)
):
return {"ok": false, "message": "progression container failed validation."}
var payload_parser := JSON.new()
if (
payload_parser.parse(payload_json) != OK
or typeof(payload_parser.data) != TYPE_DICTIONARY
):
return {"ok": false, "message": "progression payload is malformed."}
return {
"ok": true,
"data": payload_parser.data,
"game_version": str(envelope.get("game_version", "")),
"created_at_unix": int(envelope.get("created_at_unix", 0)),
"legacy_plaintext": false,
}
static func _read_plaintext_dictionary(path: String) -> Dictionary:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(
path,
MAX_CONTAINER_BYTES,
)
if bytes.is_empty():
return {"ok": false}
var text: String = bytes.get_string_from_utf8()
if not text.strip_edges().begins_with("{"):
return {"ok": false}
var parser := JSON.new()
if parser.parse(text) != OK or typeof(parser.data) != TYPE_DICTIONARY:
return {"ok": false}
return {"ok": true, "data": parser.data}
static func _looks_encrypted(path: String) -> bool:
if not FileAccess.file_exists(path) or FileAccess.get_size(path) < 4:
return false
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return false
var header: PackedByteArray = file.get_buffer(4)
file.close()
return header == PackedByteArray([0x47, 0x44, 0x45, 0x43])
static func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)

View file

@ -0,0 +1 @@
uid://lad8mvedktww

View file

@ -4,14 +4,14 @@ set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.13.1-alpha"
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.14.0-alpha"
readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-linux-x86_64.zip"
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then

View file

@ -223,7 +223,7 @@ music while omitting rain and shoreline ambience in both profiles. See
## Controls
NETfishing reads the handheld controller directly. PortMaster's system
NETfishing reads the handheld controller directly. GPTOKEYB's device-specific
force-quit chord remains available; this is Start+Select on most devices.
EOF
@ -249,8 +249,11 @@ if unzip -Z1 "${ARCHIVE}" | grep -qx 'port.json'; then
echo "port.json must be inside netfishing/, not at the archive root." >&2
exit 1
fi
grep -q '^# PORTMASTER: netfishing.zip, NETfishing.sh$' \
"${STAGE_ROOT}/NETfishing.sh"
if grep -Eq '^[[:space:]]*#[[:space:]]*PORTMASTER:' \
"${STAGE_ROOT}/NETfishing.sh"; then
echo "The distributable launcher contains an installed PortMaster signature." >&2
exit 1
fi
grep -Fq 'PROFILE_PATH="$CONFDIR/performance_profile"' \
"${STAGE_ROOT}/NETfishing.sh"
grep -Fq 'NETFISHING_PERFORMANCE_PROFILE=light' \

View file

@ -1,5 +1,4 @@
#!/bin/bash
# PORTMASTER: netfishing.zip, NETfishing.sh
XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
@ -13,19 +12,11 @@ else
controlfolder="/roms/ports/PortMaster"
fi
if [ ! -f "$controlfolder/control.txt" ]; then
echo "PortMaster control.txt was not found at $controlfolder" >&2
exit 1
fi
source "$controlfolder/control.txt"
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
get_controls
GAMEDIR="/${directory}/ports/netfishing"
if [ ! -d "$GAMEDIR" ] && [ -d "/mnt/mmc/ports/netfishing" ]; then
GAMEDIR="/mnt/mmc/ports/netfishing"
fi
CONFDIR="$GAMEDIR/conf"
PORTS_ROOT="${GAMEDIR%/netfishing}"
@ -38,16 +29,13 @@ CONTROLLER_MAPPING_FILE="$CONFDIR/cache/controller_mapping.txt"
WESTON_DIR="/tmp/netfishing-weston"
WESTON_RUNTIME="weston_pkg_0.2"
HARBOURMASTER="$controlfolder/harbourmaster"
if [ ! -x "$HARBOURMASTER" ] && [ -x "/mnt/mmc/MUOS/PortMaster/harbourmaster" ]; then
HARBOURMASTER="/mnt/mmc/MUOS/PortMaster/harbourmaster"
fi
mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR"
chmod +x "$GAME_EXECUTABLE"
# New PortMaster installs use a predictable save directory beside the port
# instead of presenting a folder picker on a small screen. Preserve an
# established data-root choice when upgrading an existing installation.
# NETfishing uses a predictable save directory beside the port instead of
# presenting a folder picker on a small screen. Preserve an established
# data-root choice when upgrading an existing installation.
NETFISHING_DATA_ENVIRONMENT=()
if [ -f "$SAVEDIR/netfishing_data.json" ] || {
[ ! -f "$DATA_BOOTSTRAP_DIR/data_root_bootstrap.json" ] &&
@ -139,10 +127,9 @@ case "$PERFORMANCE_PROFILE" in
;;
esac
# Keep NETfishing's native controller input separate from PortMaster's exit
# handling. Legacy GPTOKEYB builds can load their default keyboard map when no
# config is supplied, so the explicit no-op map preserves only the
# device-specific force-quit chord.
# NETfishing reads gameplay input natively. The explicit no-op map prevents
# GPTOKEYB from injecting duplicate keyboard or mouse input while retaining
# its device-specific force-quit chord, including on ROCKNIX.
$GPTOKEYB "NETfishing.aarch64" -c "$GPTOKEYB_CONFIG" &
pm_platform_helper "$GAME_EXECUTABLE"

View file

@ -1,5 +1,6 @@
# NETfishing reads the controller directly. GPTOKEYB is retained only for the
# PortMaster force-quit chord, so every keyboard and mouse binding is disabled.
# NETfishing reads the controller directly. GPTOKEYB is retained only for its
# device-specific force-quit chord, so every keyboard and mouse binding is
# disabled to prevent duplicate input.
back = \"
guide = \"

View file

@ -15,6 +15,7 @@ readonly -a QUICK_TESTS=(
"tests/android_readiness_validation.gd"
"tests/camera_drag_validation.gd"
"tests/character_rig_validation.gd"
"tests/chat_validation.gd"
"tests/controller_focus_presentation_validation.gd"
"tests/controller_menu_accessibility_validation.gd"
"tests/controller_mapping_validation.gd"
@ -22,6 +23,7 @@ readonly -a QUICK_TESTS=(
"tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd"
"tests/digging_prototype_validation.gd"
"tests/equipment_catalog_validation.gd"
"tests/exported_decal_hotfix_validation.gd"
"tests/file_dialog_controller_navigation_validation.gd"
"tests/fish_catalog_content_validation.gd"
@ -50,6 +52,7 @@ readonly -a QUICK_TESTS=(
"tests/tree_gathering_prototype_validation.gd"
"tests/unified_inventory_validation.gd"
"tests/world_time_validation.gd"
"tests/world_layout_validation.gd"
"tests/world_spawn_protocol_validation.gd"
"tests/world_weather_validation.gd"
)
@ -72,10 +75,12 @@ readonly -a HOST_TESTS=(
"tests/gathering_showcase_validation.gd"
"tests/inventory_storage_persistence_validation.gd"
"tests/job_system_validation.gd"
"tests/progression_archive_validation.gd"
"tests/surface_drawing_runtime_validation.gd"
)
readonly -a NETWORK_TESTS=(
"tests/chat_privacy_multiplayer_validation.gd"
"tests/economy_regression_validation.gd"
"tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd"
@ -85,6 +90,7 @@ readonly -a NETWORK_TESTS=(
"tests/profile_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd"
"tests/world_layout_multiplayer_validation.gd"
"tests/world_spawn_multiplayer_validation.gd"
)
@ -140,7 +146,10 @@ wait_for_network_host() {
sleep 2
return 0
fi
for _attempt in {1..100}; do
# A cold generated-world startup can take longer than ten seconds before
# NetworkSession is ready to bind. Keep the client launch tied to the actual
# listener instead of treating that normal startup work as a host failure.
for _attempt in {1..600}; do
if ! kill -0 "${host_pid}" 2>/dev/null; then
return 1
fi
@ -226,7 +235,7 @@ run_session_switch_network_test() {
--headless --path "${PROJECT_ROOT}" --script "${script}" -- second_host \
>"${second_root}/output.log" 2>&1 &
second_pid=$!
for _attempt in {1..100}; do
for _attempt in {1..600}; do
if ! kill -0 "${first_pid}" 2>/dev/null \
|| ! kill -0 "${second_pid}" 2>/dev/null; then
break

View file

@ -17,6 +17,8 @@ var world_seed: int = DEFAULT_WORLD_SEED
var public_listing: bool = false
var discovery_url: String = ""
var data_directory: String = ""
var chat_logging: bool = false
var chat_log_path: String = ""
var operator_fingerprints: PackedStringArray = PackedStringArray()
var error_message: String = ""
@ -71,6 +73,12 @@ func _load_file(path: String) -> bool:
discovery_url = str(file.get_value(
"discovery", "url", discovery_url
))
chat_logging = bool(file.get_value(
"privacy", "chat_logging", chat_logging
))
chat_log_path = str(file.get_value(
"privacy", "chat_log_path", chat_log_path
))
operator_fingerprints = _parse_fingerprint_list(file.get_value(
"moderation", "operators", operator_fingerprints
))
@ -100,6 +108,12 @@ func _apply_environment() -> void:
data_directory = _environment_string(
"NETFISHING_DATA_DIR", data_directory
)
chat_logging = _environment_bool(
"NETFISHING_CHAT_LOGGING", chat_logging
)
chat_log_path = _environment_string(
"NETFISHING_CHAT_LOG_PATH", chat_log_path
)
if OS.has_environment("NETFISHING_SERVER_OPERATORS"):
operator_fingerprints = _parse_fingerprint_list(
OS.get_environment("NETFISHING_SERVER_OPERATORS")
@ -130,10 +144,16 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
operator_fingerprints = _parse_fingerprint_list(
argument.trim_prefix("--operators=")
)
elif argument.begins_with("--chat-log-path="):
chat_log_path = argument.trim_prefix("--chat-log-path=")
elif argument == "--public":
public_listing = true
elif argument == "--private":
public_listing = false
elif argument == "--chat-logging":
chat_logging = true
elif argument == "--no-chat-logging":
chat_logging = false
func _validate() -> void:
@ -141,6 +161,9 @@ func _validate() -> void:
bind_address = bind_address.strip_edges()
discovery_url = discovery_url.strip_edges().trim_suffix("/")
data_directory = data_directory.strip_edges()
chat_log_path = chat_log_path.strip_edges()
if chat_logging and chat_log_path.is_empty() and not data_directory.is_empty():
chat_log_path = data_directory.path_join("logs/chat.jsonl")
operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
if server_name.is_empty():
error_message = "Server name cannot be empty."
@ -156,6 +179,12 @@ func _validate() -> void:
error_message = "World seed must be between 1 and %d." % MAX_WORLD_SEED
elif not data_directory.is_empty() and not data_directory.is_absolute_path():
error_message = "Server data directory must be an absolute path."
elif chat_logging and (
chat_log_path.is_empty() or not chat_log_path.is_absolute_path()
):
error_message = (
"Chat logging requires an absolute chat log path."
)
elif public_listing and not (
discovery_url.begins_with("https://")
or discovery_url.begins_with("http://")

View file

@ -3,7 +3,8 @@ extends Node
signal mapping_changed
const FORMAT_VERSION: int = 1
const FORMAT_VERSION: int = 2
const LEGACY_FORMAT_VERSION: int = 1
const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json"
const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp"
const MAPPING_BACKUP_PATH: String = (
@ -21,6 +22,7 @@ const ROLE_SNEAK: StringName = &"sneak"
const ROLE_SLOW_WALK: StringName = &"slow_walk"
const ROLE_INTERACT: StringName = &"interact"
const ROLE_PRIMARY_ACTION: StringName = &"fish_primary"
const ROLE_ALTERNATE_REEL: StringName = &"reel_alternate"
const ROLE_CAMERA_DRAG: StringName = &"camera_drag"
const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in"
const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out"
@ -46,6 +48,7 @@ const ROLE_ORDER: Array[StringName] = [
ROLE_SLOW_WALK,
ROLE_INTERACT,
ROLE_PRIMARY_ACTION,
ROLE_ALTERNATE_REEL,
ROLE_CAMERA_DRAG,
ROLE_CAMERA_ZOOM_IN,
ROLE_CAMERA_ZOOM_OUT,
@ -81,6 +84,7 @@ const ROLE_LABELS: Dictionary = {
ROLE_SLOW_WALK: "slow walk",
ROLE_INTERACT: "interact",
ROLE_PRIMARY_ACTION: "primary action",
ROLE_ALTERNATE_REEL: "alternate reel",
ROLE_CAMERA_DRAG: "rotate camera",
ROLE_CAMERA_ZOOM_IN: "camera zoom in",
ROLE_CAMERA_ZOOM_OUT: "camera zoom out",
@ -138,19 +142,28 @@ func load_mapping() -> bool:
_bindings = {}
return false
var data := json.data as Dictionary
if int(data.get("format_version", -1)) != FORMAT_VERSION:
var format_version: int = int(data.get("format_version", -1))
if format_version not in [LEGACY_FORMAT_VERSION, FORMAT_VERSION]:
push_warning("Keyboard binding version is unsupported; using defaults.")
_bindings = {}
return false
var raw_bindings: Variant = data.get("bindings", {})
if (
typeof(raw_bindings) != TYPE_DICTIONARY
or not _validate_complete_bindings(raw_bindings as Dictionary)
):
push_warning("Keyboard bindings are incomplete; using defaults.")
_bindings = {}
return false
_bindings = (raw_bindings as Dictionary).duplicate(true)
var loaded_bindings := (raw_bindings as Dictionary).duplicate(true)
if format_version == LEGACY_FORMAT_VERSION:
loaded_bindings[str(ROLE_ALTERNATE_REEL)] = (
_default_bindings[str(ROLE_ALTERNATE_REEL)].duplicate(true)
)
if not _validate_complete_bindings(loaded_bindings):
push_warning("Keyboard bindings are incomplete; using defaults.")
_bindings = {}
return false
_bindings = loaded_bindings
return true

View file

@ -86,6 +86,11 @@ func _run() -> void:
var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
assert(player != null and service != null and toolbar != null)
assert(chat_ui != null)
assert(toolbar.get_parent() == chat_ui.get_parent())
assert(
toolbar.get_index() > chat_ui.get_index(),
"The art-kit toolbar must receive overlapping pointer input before Chat.",
)
assert(bool(game_ui.call("_can_start_virtual_mouse")))
var virtual_pointer_states: Array[bool] = []
game_ui.virtual_pointer_mode_changed.connect(
@ -317,6 +322,22 @@ func _run() -> void:
assert(not (
game_ui.get_node("%ExperiencePresentation") as Control
).visible)
var hud_fishing_spot := main.get_node("%FishingSpot") as FishingSpot
assert(hud_fishing_spot != null)
hud_fishing_spot.state = FishingSpot.FishingState.SHOWING_CATCH
game_ui.call("_refresh_gameplay_hud_visibility")
assert((
game_ui.get_node("%GameplayTransientHUD") as Control
).visible)
assert(not (
game_ui.get_node("%ExperiencePresentation") as Control
).visible)
assert(chat_ui.is_hud_hidden())
hud_fishing_spot.state = FishingSpot.FishingState.READY
game_ui.call("_refresh_gameplay_hud_visibility")
assert(not (
game_ui.get_node("%GameplayTransientHUD") as Control
).visible)
quick_menu.open_menu(true)
assert(quick_menu.visible and quick_menu.is_open())
quick_menu.close_menu()
@ -365,6 +386,12 @@ func _run() -> void:
await process_frame
assert(service.is_active() and not service.is_placement_mode())
assert(toolbar.visible)
chat_ui.open_chat()
assert(chat_ui.is_open())
assert(not service.is_active() and not toolbar.visible)
chat_ui.close_chat()
assert(not chat_ui.is_open())
assert(service.is_active() and toolbar.visible)
var ui_root := game_ui.get_node("%UIRoot") as Control
assert(toolbar.get_parent() == ui_root)
assert(is_zero_approx(toolbar.position.y))

View file

@ -0,0 +1,120 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18152
const PREJOIN_MESSAGE: String = "private prejoin message"
const LIVE_MESSAGE: String = "live message after join"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Chat privacy validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
assert(session.set_host_open(true))
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
assert(chat_service.send_local_message(PREJOIN_MESSAGE))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
await create_timer(1.0).timeout
assert(chat_service.send_local_message(LIVE_MESSAGE))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("Chat privacy multiplayer host validation: PASS")
await _session_cleanup(main, session)
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call("_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT)
var session := main.get_node("%NetworkSession") as NetworkSession
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
await create_timer(0.5).timeout
assert(not _history_contains(chat_service, PREJOIN_MESSAGE))
var live_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < live_deadline
and not _history_contains(chat_service, LIVE_MESSAGE)
):
await process_frame
assert(_history_contains(chat_service, LIVE_MESSAGE))
assert(not _history_contains(chat_service, PREJOIN_MESSAGE))
print("Chat privacy multiplayer client validation: PASS")
await _session_cleanup(main, session)
func _history_contains(service: NetworkChatService, body: String) -> bool:
return service.get_history().any(
func(message: Dictionary) -> bool:
return str(message.get("body", "")) == body
)
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0
func _session_cleanup(main: Node, session: NetworkSession) -> void:
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()

View file

@ -0,0 +1 @@
uid://ivtqrnppnmli

38
tests/chat_validation.gd Normal file
View file

@ -0,0 +1,38 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
assert(NetworkChatProtocol.sanitize_body(42).is_empty())
assert(NetworkChatProtocol.sanitize_body(" a ") == "a")
assert(NetworkChatProtocol.sanitize_body("a b") == "a b")
assert(NetworkChatProtocol.sanitize_body("a\tb\nc") == "a b c")
assert(NetworkChatProtocol.sanitize_body(" !hello!? ") == "!hello!?")
assert(NetworkChatProtocol.sanitize_body("!!!") == "!!!")
const em_space := "" # U+2003 EM SPACE
const zero_width_space := "" # U+200B ZERO WIDTH SPACE
const braille_blank := "" # U+2800 BRAILLE PATTERN BLANK
const invisible_padding := em_space + zero_width_space + braille_blank
assert(
NetworkChatProtocol.sanitize_body(invisible_padding + "a" + invisible_padding)
== "a"
)
assert(NetworkChatProtocol.sanitize_body(invisible_padding).is_empty())
assert(NetworkChatProtocol.sanitize_body(" \t\n" + invisible_padding).is_empty())
assert(
NetworkChatProtocol.sanitize_body("a".repeat(
NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
)).length() == NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
)
assert(
NetworkChatProtocol.sanitize_body("a".repeat(
NetworkChatProtocol.MAX_VISIBLE_CHARACTERS + 1
)).is_empty()
)
print("Chat validation: PASS")
quit(0)

View file

@ -0,0 +1 @@
uid://ohemhv2sf5g4

View file

@ -353,6 +353,8 @@ func _validate_data_settings_navigation() -> void:
"Open Data Folder left a controller-activatable dialog behind.",
)
var change_data_folder := panel.get_node("%ChangeDataFolder") as Button
var export_progression := panel.get_node("%ExportProgression") as Button
var import_progression := panel.get_node("%ImportProgression") as Button
var copy_fingerprint := panel.get_node("%CopyPlayerFingerprint") as Button
var export_player := panel.get_node("%ExportPlayerIdentity") as Button
var import_player := panel.get_node("%ImportPlayerIdentity") as Button
@ -362,6 +364,8 @@ func _validate_data_settings_navigation() -> void:
data_tab,
open_data_folder,
change_data_folder,
export_progression,
import_progression,
copy_fingerprint,
export_player,
import_player,
@ -385,7 +389,7 @@ func _validate_data_settings_navigation() -> void:
_assert_neighbor(
open_data_folder,
&"focus_neighbor_bottom",
copy_fingerprint,
export_progression,
)
_assert_neighbor(
change_data_folder,
@ -395,12 +399,42 @@ func _validate_data_settings_navigation() -> void:
_assert_neighbor(
change_data_folder,
&"focus_neighbor_bottom",
import_progression,
)
_assert_neighbor(
export_progression,
&"focus_neighbor_top",
open_data_folder,
)
_assert_neighbor(
export_progression,
&"focus_neighbor_right",
import_progression,
)
_assert_neighbor(
export_progression,
&"focus_neighbor_bottom",
copy_fingerprint,
)
_assert_neighbor(
import_progression,
&"focus_neighbor_top",
change_data_folder,
)
_assert_neighbor(
import_progression,
&"focus_neighbor_left",
export_progression,
)
_assert_neighbor(
import_progression,
&"focus_neighbor_bottom",
copy_fingerprint,
)
_assert_neighbor(
copy_fingerprint,
&"focus_neighbor_top",
open_data_folder,
export_progression,
)
_assert_neighbor(
copy_fingerprint,

View file

@ -19,6 +19,12 @@ func _run() -> void:
defaults.call("_validate")
assert(defaults.is_valid())
assert(not bool(defaults.get("public_listing")))
assert(not bool(defaults.get("chat_logging")))
assert(str(defaults.get("chat_log_path")).is_empty())
assert(NetworkChatService.BURST_COUNT == 3)
assert(NetworkChatService.WINDOW_COUNT == 5)
assert(is_equal_approx(NetworkChatService.WINDOW_SECONDS, 10.0))
assert(NetworkChatService.CALL_COOLDOWN_MILLISECONDS == 90)
var path: String = ProjectSettings.globalize_path(
"user://dedicated-server-validation.cfg"
@ -32,6 +38,12 @@ func _run() -> void:
file.set_value("server", "public", true)
file.set_value("server", "data_directory", "/tmp/configured-server")
file.set_value("discovery", "url", "https://discovery.netfishing.org/")
file.set_value("privacy", "chat_logging", true)
file.set_value(
"privacy",
"chat_log_path",
"/tmp/configured-server/logs/chat.jsonl",
)
file.set_value(
"moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
)
@ -48,6 +60,11 @@ func _run() -> void:
assert(int(configured.get("world_seed")) == 928374)
assert(bool(configured.get("public_listing")))
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org")
assert(bool(configured.get("chat_logging")))
assert(
str(configured.get("chat_log_path"))
== "/tmp/configured-server/logs/chat.jsonl"
)
assert(
configured.get("operator_fingerprints")
== PackedStringArray([OPERATOR_A, OPERATOR_B])
@ -89,6 +106,51 @@ func _run() -> void:
invalid_seed.call("_validate")
assert(not invalid_seed.is_valid())
var default_chat_path := ConfigType.new()
default_chat_path.set("data_directory", "/tmp/default-chat-path-server")
default_chat_path.set("chat_logging", true)
default_chat_path.call("_validate")
assert(default_chat_path.is_valid())
assert(
str(default_chat_path.get("chat_log_path"))
== "/tmp/default-chat-path-server/logs/chat.jsonl"
)
var invalid_chat_path := ConfigType.new()
invalid_chat_path.set("data_directory", "/tmp/invalid-chat-path-server")
invalid_chat_path.set("chat_logging", true)
invalid_chat_path.set("chat_log_path", "relative/chat.jsonl")
invalid_chat_path.call("_validate")
assert(not invalid_chat_path.is_valid())
var chat_log_validation_path: String = ProjectSettings.globalize_path(
"user://dedicated-chat-log-validation.jsonl"
)
var chat_service := NetworkChatService.new()
assert(not chat_service.configure_dedicated_history(
true,
"relative/chat.jsonl",
))
assert(chat_service.configure_dedicated_history(
true,
chat_log_validation_path,
))
assert(FileAccess.file_exists(chat_log_validation_path))
chat_service.call("_append_chat_log", {
"sender_display_name": "Privacy Tester",
"body": "explicitly retained message",
})
var logged_message: Variant = JSON.parse_string(
FileAccess.get_file_as_string(chat_log_validation_path).strip_edges()
)
assert(typeof(logged_message) == TYPE_DICTIONARY)
assert(str(logged_message.get("recorded_at_utc", "")).ends_with("Z"))
assert(logged_message.get("sender_display_name") == "Privacy Tester")
assert(logged_message.get("body") == "explicitly retained message")
assert(chat_service.configure_dedicated_history(false, ""))
assert(DirAccess.remove_absolute(chat_log_validation_path) == OK)
chat_service.free()
var discovery := DiscoveryClient.new()
assert(
str(discovery.call("_default_room_name", "River"))

View file

@ -54,6 +54,21 @@ func _initialize() -> void:
assert(FishingShopStockType.get_price(&"magnet") == 250)
assert(FishingShopStockType.get_stock_item_ids().has(&"magnet"))
assert(FishingShopStockType.is_permanent_unlock(&"magnet", magnet))
var fishing_net: ItemDataType = (
ItemCatalogResource.get_available_item_by_id(&"fishing_net")
)
assert(fishing_net != null)
assert(fishing_net.category == ItemDataType.Category.TOOL)
assert(fishing_net.icon == null)
assert(not fishing_net.usable)
assert(not fishing_net.equippable)
assert(not fishing_net.hotbar_allowed)
assert(FishingShopStockType.get_price(&"fishing_net") == 250)
assert(FishingShopStockType.get_stock_item_ids().has(&"fishing_net"))
assert(FishingShopStockType.is_permanent_unlock(
&"fishing_net", fishing_net
))
assert(not FishingShopStockType.is_gathering_tool(&"fishing_net"))
var crab_net: ItemDataType = ItemCatalogResource.get_available_item_by_id(
&"crab_net"
)

View file

@ -98,29 +98,27 @@ func _run() -> void:
assert(save_manager.save_now())
var save_path: String = str(save_manager.get("_save_path"))
var save_file := FileAccess.open(save_path, FileAccess.READ)
assert(save_file != null)
var parsed: Variant = JSON.parse_string(save_file.get_as_text())
save_file.close()
assert(typeof(parsed) == TYPE_DICTIONARY)
var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"]
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false)))
var parsed: Dictionary = decoded["data"]
var hotbar_data: Dictionary = parsed["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int((parsed as Dictionary)["save_version"]) == 9)
assert(int(parsed["save_version"]) == 10)
assert(
int((parsed as Dictionary)["experience"]["total_experience"])
int(parsed["experience"]["total_experience"])
== 125
)
assert(absf(
float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours
float(parsed["world"]["time_hours"]) - saved_time_hours
) < 0.01)
assert(
int((parsed as Dictionary)["world"]["weather"])
int(parsed["world"]["weather"])
== int(saved_weather)
)
assert(absf(
float(
(parsed as Dictionary)["world"][
parsed["world"][
"weather_seconds_remaining"
]
) - saved_weather_seconds

View file

@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void:
version_four,
4,
)
assert(int(migrated.get("save_version", -1)) == 9)
assert(int(migrated.get("save_version", -1)) == 10)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert(
is_equal_approx(

View file

@ -2,6 +2,7 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const TEST_SEED: int = 864209
func _initialize() -> void:
@ -22,9 +23,23 @@ func _run() -> void:
func _run_host() -> void:
var main: Node = await _create_initialized_main()
assert(bool(main.call(
"_apply_world",
WorldLayout.STARTER_ISLAND,
TEST_SEED,
true,
)))
assert(bool(main.call(
"_prepare_host_world",
WorldLayout.STARTER_ISLAND,
TEST_SEED,
)))
assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())
assert(save_manager.initialize_new_game(
TEST_SEED,
WorldLayout.STARTER_ISLAND,
))
main.call("_enter_gameplay")
var session := main.get_node("%NetworkSession") as NetworkSession
assert(session.set_host_open(true))
@ -217,7 +232,9 @@ func _run_client() -> void:
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.CASTING
)
for _frame: int in 30:
# Keep the transient pull-back pose visible long enough for the separate host
# process to observe it even when a cold validation run is CPU-bound.
for _frame: int in 120:
await physics_frame
player.set_fishing_visual(false)
player.call("_set_sitting", true, true)

View file

@ -32,6 +32,7 @@ func _run() -> void:
var fishing_spot := main.get_node("%FishingSpot") as FishingSpot
var catalog := main.get("fish_catalog") as FishPool
assert(player != null and fishing_spot != null and catalog != null)
fishing_spot.minimum_showcase_duration = 0.15
assert(player.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net"))
assert(player.hotbar.select_slot(0))
@ -60,13 +61,20 @@ func _run() -> void:
assert(not player.is_movement_enabled())
Input.action_release("fish_primary")
await process_frame
assert(bool(fishing_spot.get("_put_away_press_armed")))
assert(not bool(fishing_spot.get("_put_away_press_armed")))
var pocket_event := InputEventAction.new()
pocket_event.action = &"fish_primary"
pocket_event.pressed = true
Input.parse_input_event(pocket_event)
await process_frame
assert(fishing_spot.get("_pending_catch") == crab_catch)
Input.action_release("fish_primary")
await create_timer(0.2).timeout
await process_frame
assert(bool(fishing_spot.get("_put_away_press_armed")))
Input.parse_input_event(pocket_event)
await process_frame
assert(fishing_spot.get("_pending_catch") == null)
var pocket_deadline: int = Time.get_ticks_msec() + 6000

View file

@ -82,18 +82,42 @@ func _validate_generated_region(
generator: TerrainChunkGenerator,
) -> void:
var placements := generator.placement_keys()
assert(placements.size() == 49)
assert(placements[24].begins_with("chunk_spawn@"))
var expected_chunk_count := generator.grid_size.x * generator.grid_size.y
var center := Vector2i(
generator.grid_size.x / 2,
generator.grid_size.y / 2,
)
var center_index := center.y * generator.grid_size.x + center.x
assert(placements.size() == expected_chunk_count)
assert(placements[center_index].begins_with("chunk_spawn@"))
assert(_placement_count(placements, "chunk_spawn") == 1)
assert(_placement_count(placements, "chunk_0001") >= 1)
assert(_placement_count(placements, "chunk_0002") >= 1)
assert(_placement_count(placements, "chunk_0003") >= 1)
assert(_placement_count(placements, "chunk_0016") >= 1)
assert(_placement_count(placements, "chunk_0004") >= 1)
assert(_placement_count(placements, "chunk_0005") >= 1)
assert(_placement_count(placements, "chunk_0006") >= 1)
assert(_placement_count(placements, "chunk_0007") >= 1)
assert(generator.get_generated_chunks_root().get_child_count() == 49)
assert(generator.get_primary_terrain_meshes().size() == 49)
assert(_placement_count(placements, "chunk_0008") >= 1)
assert(_placement_count(placements, "chunk_0013") >= 1)
assert(_placement_count(placements, "chunk_0009") == 1)
assert(_placement_count(placements, "chunk_0010") == 4)
assert(
_placement_count(placements, "chunk_0011")
+ _placement_count(placements, "chunk_0012")
== 4
)
assert(_placement_count(placements, "chunk_0012") <= 1)
assert(
generator.get_generated_chunks_root().get_child_count()
== expected_chunk_count
)
assert(
generator.get_primary_terrain_meshes().size()
== expected_chunk_count
)
_validate_elevated_cliff_feature(generator)
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D
@ -111,6 +135,7 @@ func _validate_generated_region(
var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D
assert(ocean != null and fresh_root != null)
assert(ocean.water_type == WaterType.Type.SALT_WATER)
assert(is_equal_approx(ocean.position.y, GeneratedWorldRegion.WATER_HEIGHT))
var fresh_placement_count := 0
for record: Dictionary in generator.placement_records():
var tags: PackedStringArray = record.get("tags", PackedStringArray())
@ -123,6 +148,7 @@ func _validate_generated_region(
var fresh := child as WaterBodyAuthoring
assert(fresh != null)
assert(fresh.water_type == WaterType.Type.FRESH_WATER)
assert(is_equal_approx(fresh.position.y, GeneratedWorldRegion.WATER_HEIGHT))
assert(fresh.visible)
assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0)
assert(not fresh.visual_surface_enabled)
@ -155,13 +181,13 @@ func _validate_generated_region(
&"terrain_chunk_coordinate",
Vector2i.ZERO,
)
var center := Vector2i(
var spawn_coordinate := Vector2i(
generator.grid_size.x / 2,
generator.grid_size.y / 2,
)
var spawn_distance := (
absi(coordinate.x - center.x)
+ absi(coordinate.y - center.y)
absi(coordinate.x - spawn_coordinate.x)
+ absi(coordinate.y - spawn_coordinate.y)
)
assert(spawn_distance >= definition.minimum_spawn_chunk_distance)
_validate_decoration_transform(
@ -169,6 +195,13 @@ func _validate_generated_region(
child as Node3D,
definition,
)
if prop_id in [&"prop_tree_1", &"prop_tree_2", &"prop_tree_3"]:
assert(
_has_material_variant_override(
child.get_node_or_null("Visual"),
definition.material_variants,
)
)
assert(_decoration_group_count(region, &"grass_tree") > 0)
assert(_decoration_group_count(region, &"sand_tree") > 0)
assert(
@ -187,6 +220,69 @@ func _validate_generated_region(
)
func _validate_elevated_cliff_feature(
generator: TerrainChunkGenerator,
) -> void:
var elevated_coordinates: Dictionary[Vector2i, StringName] = {}
var top_coordinate := Vector2i(-1, -1)
for record: Dictionary in generator.placement_records():
var stable_id: StringName = record.get("stable_id", &"")
if stable_id not in [
&"chunk_0009",
&"chunk_0010",
&"chunk_0011",
&"chunk_0012",
]:
continue
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
assert(coordinate.x > 0 and coordinate.x < generator.grid_size.x - 1)
assert(coordinate.y > 0 and coordinate.y < generator.grid_size.y - 1)
elevated_coordinates[coordinate] = stable_id
if stable_id == &"chunk_0009":
top_coordinate = coordinate
assert(elevated_coordinates.size() == 9)
assert(top_coordinate != Vector2i(-1, -1))
for row_offset: int in range(-1, 2):
for column_offset: int in range(-1, 2):
assert(
elevated_coordinates.has(
top_coordinate + Vector2i(column_offset, row_offset)
)
)
var generated := generator.get_generated_chunks_root()
assert(generated != null)
var layered_count := 0
for chunk_root: Node in generated.get_children():
var stable_id := StringName(
chunk_root.get_meta(&"terrain_chunk_id", &"")
)
if stable_id not in [
&"chunk_0009",
&"chunk_0010",
&"chunk_0011",
&"chunk_0012",
]:
continue
layered_count += 1
var base_layer := chunk_root.get_node_or_null("TerrainBaseLayer")
var overlay := chunk_root.get_node_or_null("TerrainOverlay")
assert(base_layer != null and overlay != null)
var base_mesh := TerrainChunkAnalyzer.find_primary_mesh(
base_layer,
&"chunk_0000",
)
var overlay_mesh := TerrainChunkAnalyzer.find_primary_mesh(
overlay,
stable_id,
)
assert(base_mesh != null and base_mesh.mesh != null)
assert(overlay_mesh != null and overlay_mesh.mesh != null)
assert(base_mesh.has_node("TerrainBaseLayerCollision"))
assert(overlay_mesh.has_node("TerrainCollision"))
assert(layered_count == 9)
func _validate_ocean_facing_record(
record: Dictionary,
grid_size: Vector2i,
@ -224,6 +320,23 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void:
assert(instance.position.is_zero_approx())
assert(_find_mesh_instance(instance) != null)
instance.free()
var mushroom := catalog.definition_for_id(&"prop_mushroom")
assert(mushroom != null and mushroom.has_collision())
assert(mushroom.visual_offset.y < 0.0)
for tree_id: StringName in [
&"prop_tree_1",
&"prop_tree_2",
&"prop_tree_3",
]:
var tree := catalog.definition_for_id(tree_id)
assert(tree != null)
assert(tree.minimum_visual_scale < tree.maximum_visual_scale)
assert(tree.material_variants.size() >= 3)
assert(not tree.variant_material_slot_names.is_empty())
var pine := catalog.definition_for_id(&"prop_pine")
assert(pine != null)
assert(pine.minimum_visual_scale < pine.maximum_visual_scale)
assert(pine.material_variants.is_empty())
func _validate_biome_catalog(
@ -239,7 +352,9 @@ func _validate_biome_catalog(
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
var biome_id := region.get_biome_at(coordinate)
var tags: PackedStringArray = record.get("tags", PackedStringArray())
if "grass" in tags or "sand" in tags:
if "mixed_surface" in tags:
assert(biome_id == &"")
elif "grass" in tags or "sand" in tags:
assert(biome_id != &"")
var biome := catalog.definition_for_id(biome_id)
assert(biome != null and biome.supports_chunk_tags(tags))
@ -276,7 +391,16 @@ func _validate_decoration_transform(
var collision := prop.get_node_or_null(
"TrunkCollision/CollisionShape"
) as CollisionShape3D
assert(visual_root != null and visual_root.position.is_zero_approx())
assert(visual_root != null)
assert(visual_root.position.is_equal_approx(definition.visual_offset))
var visual_scale := float(
prop.get_meta(&"terrain_prop_visual_scale", 1.0)
)
assert(visual_scale >= definition.minimum_visual_scale - 0.001)
assert(visual_scale <= definition.maximum_visual_scale + 0.001)
assert(
visual_root.scale.is_equal_approx(Vector3.ONE * visual_scale)
)
assert(visual != null and visual.mesh != null)
if definition.has_collision():
assert(collision != null)
@ -319,16 +443,40 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D:
return null
func _has_material_variant_override(
root_node: Node,
variants: Array[Material],
) -> bool:
if root_node == null:
return false
var mesh_instance := root_node as MeshInstance3D
if mesh_instance != null and mesh_instance.mesh != null:
for surface_index: int in mesh_instance.mesh.get_surface_count():
var override := mesh_instance.get_surface_override_material(
surface_index
)
if override != null and override in variants:
return true
for child: Node in root_node.get_children():
if _has_material_variant_override(child, variants):
return true
return false
func _validate_authored_chunk_surfaces(
region: GeneratedWorldRegion,
generator: TerrainChunkGenerator,
) -> void:
var generated := generator.get_generated_chunks_root()
var found_beach := false
var found_stream := false
var found_alternate_stream := false
var found_pond := false
var found_grass_ocean_edge := false
var found_grass_beach_transition := false
var found_grass_ocean_corner := false
var found_beach_ocean_corner := false
var found_grass_sand_diagonal := false
for chunk_root: Node in generated.get_children():
if chunk_root.name.begins_with("chunk_0002r"):
var beach := chunk_root.find_child(
@ -338,6 +486,27 @@ func _validate_authored_chunk_surfaces(
var material := beach.get_active_material(0)
assert(material != null and material.resource_name == "sand")
found_beach = true
elif (
chunk_root.name.begins_with("chunk_0003r")
or chunk_root.name.begins_with("chunk_0016r")
):
var stream_id := (
&"chunk_0016"
if chunk_root.name.begins_with("chunk_0016r")
else &"chunk_0003"
)
var stream := chunk_root.find_child(
String(stream_id), true, false
) as MeshInstance3D
assert(stream != null and stream.mesh != null)
var stream_materials := _material_names(stream)
assert("dirt" in stream_materials)
assert("grass_lite" in stream_materials)
assert("dirt_wall" in stream_materials)
if stream_id == &"chunk_0016":
found_alternate_stream = true
else:
found_stream = true
elif chunk_root.name.begins_with("chunk_0004r"):
var pond := chunk_root.find_child(
"chunk_0004", true, false
@ -383,11 +552,32 @@ func _validate_authored_chunk_surfaces(
assert("grass_lite" in corner_materials)
assert("cliff_wall" in corner_materials)
found_grass_ocean_corner = true
elif chunk_root.name.begins_with("chunk_0008r"):
var beach_ocean_corner := chunk_root.find_child(
"chunk_0008", true, false
) as MeshInstance3D
assert(beach_ocean_corner != null and beach_ocean_corner.mesh != null)
var beach_corner_materials := _material_names(beach_ocean_corner)
assert("sand" in beach_corner_materials)
found_beach_ocean_corner = true
elif chunk_root.name.begins_with("chunk_0013r"):
var grass_sand_diagonal := chunk_root.find_child(
"chunk_0013", true, false
) as MeshInstance3D
assert(grass_sand_diagonal != null and grass_sand_diagonal.mesh != null)
var diagonal_materials := _material_names(grass_sand_diagonal)
assert("grass_lite" in diagonal_materials)
assert("sand" in diagonal_materials)
found_grass_sand_diagonal = true
assert(found_beach)
assert(found_stream)
assert(found_alternate_stream)
assert(found_pond)
assert(found_grass_ocean_edge)
assert(found_grass_beach_transition)
assert(found_grass_ocean_corner)
assert(found_beach_ocean_corner)
assert(found_grass_sand_diagonal)
func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray:

View file

@ -1,6 +1,9 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const ItemCatalogResource: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
func _initialize() -> void:
@ -23,33 +26,43 @@ func _run() -> void:
var player := main.get("_player") as Player
assert(save_manager != null and player != null)
const TEST_WORLD_SEED := 918273
assert(save_manager.initialize_new_game(TEST_WORLD_SEED))
assert(save_manager.initialize_new_game(
TEST_WORLD_SEED,
WorldLayout.STARTER_ISLAND,
))
save_manager.set_autosave_enabled(true)
assert(player.bag.add_item(&"art_kit"))
assert(player.bag.add_item(&"coffee"))
assert(player.bag.add_item(&"worms"))
assert(player.bag.add_item(&"the_standby"))
assert(player.equip_bait(ItemCatalogResource.get_item_by_id(&"worms")))
assert(player.equip_lure(ItemCatalogResource.get_item_by_id(&"the_standby")))
assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 14))
assert(player.bag.move_item_to_storage_slot(&"coffee", 12))
assert(save_manager.save_now())
var save_file := FileAccess.open(
str(save_manager.get("_save_path")),
FileAccess.READ,
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
str(save_manager.get("_save_path"))
)
assert(save_file != null)
var parsed: Variant = JSON.parse_string(save_file.get_as_text())
save_file.close()
assert(typeof(parsed) == TYPE_DICTIONARY)
var records: Array = (parsed as Dictionary)["bag"]["items"]
assert(int((parsed as Dictionary)["world"]["seed"]) == TEST_WORLD_SEED)
assert(bool(decoded.get("ok", false)))
var parsed: Dictionary = decoded["data"]
var records: Array = parsed["bag"]["items"]
assert(str(parsed["world"]["layout"]) == String(WorldLayout.STARTER_ISLAND))
assert(int(parsed["world"]["seed"]) == TEST_WORLD_SEED)
assert(_saved_slot(records, &"basic_fishing_rod") == 14)
assert(_saved_slot(records, &"coffee") == 12)
assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0))
assert(player.bag.move_item_to_storage_slot(&"coffee", 0))
player.unequip_bait()
player.unequip_lure()
assert(save_manager.load_player_data())
assert(save_manager.get_world_layout() == WorldLayout.STARTER_ISLAND)
assert(save_manager.get_world_seed() == TEST_WORLD_SEED)
assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14)
assert(player.bag.get_storage_slot(&"coffee") == 12)
assert(player.active_bait_id == &"worms")
assert(player.active_lure_id == &"the_standby")
main.queue_free()
for _frame: int in 4:

View file

@ -49,6 +49,7 @@ func _run() -> void:
_validate_weather_guarantees(board)
_validate_late_board_weather_fallback()
_validate_remote_tamper_rejection(jobs, board)
_validate_creature_jobs(jobs)
var sell_job: Dictionary = _find_job(
jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
@ -195,15 +196,12 @@ func _run() -> void:
assert(session.set_host_open(false))
assert(save_manager.save_now())
var save_file := FileAccess.open(
str(save_manager.get("_save_path")), FileAccess.READ
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
str(save_manager.get("_save_path"))
)
assert(save_file != null)
var parsed: Variant = JSON.parse_string(save_file.get_as_text())
save_file.close()
assert(typeof(parsed) == TYPE_DICTIONARY)
var save_data: Dictionary = parsed
assert(int(save_data.get("save_version", -1)) == 9)
assert(bool(decoded.get("ok", false)))
var save_data: Dictionary = decoded["data"]
assert(int(save_data.get("save_version", -1)) == 10)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
_validate_pause_session_switch(main, session)
@ -263,6 +261,100 @@ func _validate_unavailable_fishnet_layout() -> void:
await process_frame
func _validate_creature_jobs(jobs: PlayerJobService) -> void:
var fish_count: int = 0
var insect_count: int = 0
var shellfish_count: int = 0
var candidates: Array[FishData] = []
for fish: FishData in Catalog.candidates:
if fish == null or not fish.is_selectable():
continue
candidates.append(fish)
match fish.get_creature_group():
FishData.CreatureGroup.FISH:
fish_count += 1
FishData.CreatureGroup.INSECT:
insect_count += 1
FishData.CreatureGroup.SHELLFISH:
shellfish_count += 1
assert(fish_count > 0 and insect_count > 0 and shellfish_count > 0)
var lifetime: Array[Dictionary] = jobs.get_lifetime_jobs()
var fish_discovery: Dictionary = _find_job(
lifetime, JobCatalog.Kind.DISCOVER_SPECIES
)
assert(int(fish_discovery.get("target", -1)) == fish_count)
for creature_group: int in [
FishData.CreatureGroup.INSECT,
FishData.CreatureGroup.SHELLFISH,
]:
assert(not _find_creature_job(
lifetime,
JobCatalog.Kind.CATCH_CREATURE_GROUP,
creature_group,
).is_empty())
assert(not _find_creature_job(
lifetime,
JobCatalog.Kind.DISCOVER_CREATURE_GROUP,
creature_group,
).is_empty())
assert(not _find_creature_job(
lifetime,
JobCatalog.Kind.MASTER_CREATURE_GROUP,
creature_group,
).is_empty())
var daily_groups: Dictionary[int, bool] = {}
for seed_index: int in 64:
for job: Dictionary in JobCatalog.generate_daily_jobs(
"creature-job-validation-%d" % seed_index,
candidates,
true,
):
if int(job.get("kind", -1)) == JobCatalog.Kind.CATCH_CREATURE_GROUP:
daily_groups[int(job.get("creature_group", -1))] = true
assert(daily_groups.has(FishData.CreatureGroup.INSECT))
assert(daily_groups.has(FishData.CreatureGroup.SHELLFISH))
var beetle: FishData = Catalog.get_fish_by_id(&"beetle_stag_common")
assert(beetle != null)
var beetle_catch := _make_test_catch(beetle, &"job-beetle")
jobs.call("_on_authoritative_catch", beetle_catch)
var statistics: Dictionary = jobs.to_save_data().get("statistics", {})
assert(int(statistics.get("fish_caught", -1)) == 0)
assert(int(statistics.get("insects_caught", -1)) == 1)
func _find_creature_job(
jobs: Array[Dictionary],
kind: JobCatalog.Kind,
creature_group: int,
) -> Dictionary:
for job: Dictionary in jobs:
if (
int(job.get("kind", -1)) == kind
and int(job.get("creature_group", -1)) == creature_group
):
return job
return {}
func _make_test_catch(fish: FishData, catch_id: StringName) -> FishCatch:
var fish_catch := FishCatch.new()
fish_catch.fish = fish
fish_catch.fish_id = fish.id
fish_catch.catch_id = catch_id
fish_catch.catch_sequence = 1
fish_catch.weight_lb = fish.get_minimum_weight()
fish_catch.display_scale = fish.get_display_scale_for_weight(
fish_catch.weight_lb
)
fish_catch.quality = FishQuality.Tier.BORING
fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb)
assert(fish_catch.is_valid())
return fish_catch
func _validate_weather_guarantees(board: Dictionary) -> void:
var jobs: Array = board.get("jobs", [])
var schedule: Array = board.get("weather_schedule", [])

View file

@ -7,6 +7,12 @@ const KeyboardMouseMappingPanelType = preload(
"res://ui/keyboard_mouse_mapping_panel.gd"
)
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
const FishingSpotScene = preload("res://fishing/fishing_spot.tscn")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const CatchDifficultyProfileType = preload(
"res://fishing/catch_difficulty_profile.gd"
)
const PlayerType = preload("res://player/player.gd")
func _initialize() -> void:
@ -27,6 +33,13 @@ func _run() -> void:
str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION)
].get("kind", "")) == "mouse_button"
)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL)
].get("kind", "")) == "key"
)
assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT))
assert(_event_count(&"reel_alternate", true) == 0)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_CHAT)
@ -60,6 +73,15 @@ func _run() -> void:
))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1))
assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
var alternate_reel_key := InputEventKey.new()
alternate_reel_key.physical_keycode = KEY_G
alternate_reel_key.pressed = true
assert(manager.set_binding(
KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL,
manager.binding_from_event(alternate_reel_key),
))
assert(_has_physical_key(&"reel_alternate", KEY_G))
assert(_event_count(&"reel_alternate", true) == 0)
var joypad_event := InputEventJoypadButton.new()
joypad_event.button_index = JOY_BUTTON_A
@ -117,11 +139,64 @@ func _run() -> void:
))
assert(_has_physical_key(&"jump", KEY_SPACE))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT))
assert(_event_count(&"reel_alternate", true) == 0)
assert(_has_physical_key(&"open_chat", KEY_T))
assert(_event_count(&"jump", true) == joypad_events_before)
var legacy_bindings: Dictionary = manager.get_active_bindings()
legacy_bindings.erase(str(
KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL
))
legacy_bindings[str(KeyboardMouseMappingManagerType.ROLE_JUMP)] = (
manager.binding_from_event(rebind_key)
)
var legacy_file := FileAccess.open(
KeyboardMouseMappingManagerType.MAPPING_PATH,
FileAccess.WRITE,
)
assert(legacy_file != null)
legacy_file.store_string(JSON.stringify({
"format_version": KeyboardMouseMappingManagerType.LEGACY_FORMAT_VERSION,
"bindings": legacy_bindings,
}))
legacy_file.close()
var migrated_manager := KeyboardMouseMappingManagerType.new()
root.add_child(migrated_manager)
await process_frame
assert(migrated_manager.has_custom_mapping())
assert(_has_physical_key(&"jump", KEY_R))
assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT))
assert(migrated_manager.reset_mapping())
var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType
root.add_child(fishing_spot)
await process_frame
var local_player := PlayerType.new()
fishing_spot.set("_local_player", local_player)
fishing_spot.set("_gameplay_input_enabled", true)
fishing_spot.state = FishingSpotType.FishingState.FIGHTING
var catch_controller := fishing_spot.get_node("%CatchController")
var catch_profile := CatchDifficultyProfileType.new()
catch_profile.barrier_count_min = 0
catch_profile.barrier_count_max = 0
catch_controller.start_encounter(catch_profile, 1.0, 1)
var alternate_press := InputEventAction.new()
alternate_press.action = &"reel_alternate"
alternate_press.pressed = true
fishing_spot._unhandled_input(alternate_press)
assert(bool(catch_controller.get("_reel_input_held")))
var alternate_release := InputEventAction.new()
alternate_release.action = &"reel_alternate"
alternate_release.pressed = false
fishing_spot._unhandled_input(alternate_release)
assert(not bool(catch_controller.get("_reel_input_held")))
fishing_spot.free()
local_player.free()
settings_panel.queue_free()
panel.queue_free()
migrated_manager.queue_free()
manager.queue_free()
print("Keyboard and mouse mapping validation: PASS")
quit(0)

View file

@ -43,6 +43,7 @@ func _run() -> void:
await process_frame
assert(bool(normal_main.get("_application_initialized")))
_validate_normal_profile(normal_main)
_validate_new_game_music_transition(normal_main)
_stop_audio_players(normal_main)
normal_main.queue_free()
for _frame: int in 8:
@ -115,6 +116,7 @@ func _validate_main_profile(main: Node) -> void:
assert(player_menu != null)
assert(not player_menu.is_cooler_water_effect_enabled())
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null)
assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null)
assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null)
assert(
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream == null
@ -183,6 +185,7 @@ func _validate_normal_profile(main: Node) -> void:
assert(clouds.get_node_or_null("CloudField") is MultiMeshInstance3D)
assert(clouds.get_node_or_null("CloudCeiling") == null)
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null)
assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null)
assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null)
assert(
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream != null
@ -192,6 +195,18 @@ func _validate_normal_profile(main: Node) -> void:
)
func _validate_new_game_music_transition(main: Node) -> void:
var title_music := main.get_node("%TitleMusic") as AudioStreamPlayer
var new_game_music := main.get_node("%NewGameMusic") as AudioStreamPlayer
assert(title_music.playing)
main.call("_start_new_game_music")
assert(not title_music.playing)
assert(new_game_music.playing)
main.call("_show_title_music", true)
assert(not new_game_music.playing)
assert(title_music.playing)
func _first_fresh_water_visual(main: Node) -> MeshInstance3D:
var root := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"

View file

@ -119,6 +119,8 @@ func _make_generator() -> TerrainChunkGenerator:
"chunk_0005",
"chunk_0006",
"chunk_0007",
"chunk_0008",
]
)
generator.grass_sand_smoothing_enabled = true
return generator

View file

@ -21,12 +21,14 @@ shadow_enabled = true
[node name="TerrainChunkGenerator" type="Node3D" parent="."]
script = ExtResource("2_generator")
catalog = ExtResource("3_catalog")
grid_size = Vector2i(7, 7)
grid_size = Vector2i(11, 9)
generation_seed = 13001
build_collision = true
show_chunk_labels = false
force_center_chunk_id = &"chunk_spawn"
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007")
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007", "chunk_0008")
grass_sand_smoothing_enabled = true
elevated_cliff_feature_enabled = true
[node name="Player" parent="." instance=ExtResource("4_player")]
position = Vector3(0, 0.3, 0)

View file

@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
version_five,
5,
)
assert(int(migrated.get("save_version", -1)) == 9)
assert(int(migrated.get("save_version", -1)) == 10)
var experience_data: Dictionary = migrated.get("experience", {})
assert(int(experience_data.get("total_experience", -1)) == 0)
var world_data: Dictionary = migrated.get("world", {})

View file

@ -152,15 +152,10 @@ func _run_client() -> void:
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
var history_deadline: int = Time.get_ticks_msec() + 8000
while Time.get_ticks_msec() < history_deadline:
if chat_service.get_history().any(
func(message: Dictionary) -> bool:
return str(message.get("body", "")) == HOST_HISTORY_MESSAGE
):
break
await process_frame
assert(chat_service.get_history().any(
# Player-hosted rooms deliberately send no prior scrollback to joining or
# reconnecting clients. Live messages sent after authentication still work.
await create_timer(0.5).timeout
assert(not chat_service.get_history().any(
func(message: Dictionary) -> bool:
return str(message.get("body", "")) == HOST_HISTORY_MESSAGE
))

View file

@ -0,0 +1,135 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
var save_manager := main.get("_save_manager") as PlayerSaveManager
var player := main.get("_player") as Player
var data_root := main.get("_data_root") as PlayerDataRoot
assert(save_manager != null and player != null and data_root != null)
assert(save_manager.initialize_new_game(864209))
save_manager.set_autosave_enabled(true)
assert(player.wallet.restore_balance(4321))
assert(player.bag.add_item(&"coffee"))
assert(save_manager.save_now())
var save_path: String = str(save_manager.get("_save_path"))
assert(save_path.ends_with("player/player_save.nfsave"))
assert(FileAccess.file_exists(save_path))
assert(not FileAccess.file_exists(save_path.get_base_dir().path_join(
"player_save.json"
)))
_assert_opaque(save_path)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 10)
var archive_path: String = data_root.progression_backup_directory().path_join(
"progression-test.nfsave"
)
var exported: Dictionary = save_manager.export_progression_archive(
archive_path
)
assert(bool(exported.get("ok", false)))
assert(FileAccess.file_exists(archive_path))
_assert_opaque(archive_path)
var inspected: Dictionary = save_manager.inspect_progression_archive(
archive_path
)
assert(bool(inspected.get("ok", false)))
assert(int(inspected.get("wallet_balance", -1)) == 4321)
assert(int(inspected.get("world_seed", -1)) == 864209)
assert(player.wallet.restore_balance(7))
assert(save_manager.save_now())
var imported: Dictionary = save_manager.import_progression_archive(
archive_path
)
assert(bool(imported.get("ok", false)))
assert(save_manager.load_player_data())
assert(player.wallet.get_balance() == 4321)
assert(save_manager.get_world_seed() == 864209)
var legacy_data: Dictionary = (
ProgressionSaveCodec.read_local_save(save_path)["data"]
)
assert(save_manager.delete_progression_save())
var legacy_path: String = save_path.get_base_dir().path_join(
"player_save.json"
)
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
assert(legacy_file != null)
legacy_file.store_string(JSON.stringify(legacy_data, "\t"))
legacy_file.close()
assert(save_manager.load_player_data())
assert(FileAccess.file_exists(save_path))
assert(not FileAccess.file_exists(legacy_path))
_assert_opaque(save_path)
var settings_panels: Array[Node] = main.find_children(
"*", "SettingsPanel", true, false
)
assert(settings_panels.size() == 2)
for settings_panel: SettingsPanel in settings_panels:
var export_button := settings_panel.get_node(
"%ExportProgression"
) as Button
var import_button := settings_panel.get_node(
"%ImportProgression"
) as Button
assert(export_button != null and import_button != null)
assert(not export_button.disabled and not import_button.disabled)
assert(
export_button.get_node(export_button.focus_neighbor_right)
== import_button
)
assert(
import_button.get_node(import_button.focus_neighbor_left)
== export_button
)
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
"progression-migrated-data"
)
var data_migration: Dictionary = PortableDataMigration.migrate_active_to(
data_root, migrated_root
)
assert(bool(data_migration.get("ok", false)))
var migrated_save: String = migrated_root.path_join(
"player/player_save.nfsave"
)
assert(FileAccess.file_exists(migrated_save))
assert(bool(
ProgressionSaveCodec.read_local_save(migrated_save).get("ok", false)
))
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
print("Progression archive validation: PASS")
quit()
func _assert_opaque(path: String) -> void:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
assert(not bytes.is_empty())
var encoded_hex: String = bytes.hex_encode()
assert("save_version".to_utf8_buffer().hex_encode() not in encoded_hex)
assert("wallet".to_utf8_buffer().hex_encode() not in encoded_hex)

View file

@ -0,0 +1 @@
uid://ccl47ftc1cu5l

View file

@ -12,6 +12,15 @@ const EXPECTED_IDS: Array[String] = [
"chunk_0005",
"chunk_0006",
"chunk_0007",
"chunk_0008",
"chunk_0009",
"chunk_0010",
"chunk_0011",
"chunk_0012",
"chunk_0013",
"chunk_0014",
"chunk_0015",
"chunk_0016",
"chunk_spawn",
]
const REQUIRED_IDS: Array[String] = [
@ -23,10 +32,11 @@ const REQUIRED_IDS: Array[String] = [
"chunk_0005",
"chunk_0006",
"chunk_0007",
"chunk_0008",
]
const TEST_SEED := 13001
const CONNECTOR_STRESS_SEED := 287
const EXPECTED_SOLVER_VARIANT_COUNT := 27
const EXPECTED_SOLVER_VARIANT_COUNT := 35
const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
&"chunk_0000": 4,
&"chunk_0001": 4,
@ -36,6 +46,15 @@ const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
&"chunk_0005": 4,
&"chunk_0006": 4,
&"chunk_0007": 4,
&"chunk_0008": 4,
&"chunk_0009": 4,
&"chunk_0010": 4,
&"chunk_0011": 4,
&"chunk_0012": 4,
&"chunk_0013": 4,
&"chunk_0014": 4,
&"chunk_0015": 4,
&"chunk_0016": 4,
&"chunk_spawn": 1,
}
@ -49,6 +68,7 @@ func _initialize() -> void:
func _run() -> void:
_validate_catalog()
_validate_profiles()
_validate_second_tier_coastal_layout()
_validate_generation()
_finish()
@ -75,6 +95,15 @@ func _validate_catalog() -> void:
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008")
var grass_sand_diagonal := CATALOG.definition_for_id(&"chunk_0013")
var cliff_sea_edge := CATALOG.definition_for_id(&"chunk_0014")
var cliff_sea_corner := CATALOG.definition_for_id(&"chunk_0015")
var stream_variant := CATALOG.definition_for_id(&"chunk_0016")
var cliff_top := CATALOG.definition_for_id(&"chunk_0009")
var cliff_corner := CATALOG.definition_for_id(&"chunk_0010")
var cliff_edge := CATALOG.definition_for_id(&"chunk_0011")
var cliff_ramp := CATALOG.definition_for_id(&"chunk_0012")
var spawn := CATALOG.definition_for_id(&"chunk_spawn")
_check(
(
@ -84,6 +113,15 @@ func _validate_catalog() -> void:
and grass_ocean_edge != null
and grass_beach_transition != null
and grass_ocean_corner != null
and beach_ocean_corner != null
and grass_sand_diagonal != null
and cliff_sea_edge != null
and cliff_sea_corner != null
and stream_variant != null
and cliff_top != null
and cliff_corner != null
and cliff_edge != null
and cliff_ramp != null
and spawn != null
),
"The authored biome-rule definitions must be available.",
@ -95,6 +133,15 @@ func _validate_catalog() -> void:
and grass_ocean_edge != null
and grass_beach_transition != null
and grass_ocean_corner != null
and beach_ocean_corner != null
and grass_sand_diagonal != null
and cliff_sea_edge != null
and cliff_sea_corner != null
and stream_variant != null
and cliff_top != null
and cliff_corner != null
and cliff_edge != null
and cliff_ramp != null
and spawn != null
):
_check(
@ -193,6 +240,124 @@ func _validate_catalog() -> void:
),
"Both corner land seams must accept straight grass ocean edges.",
)
_check(
beach_ocean_corner.ocean_facing_edges
== (
(1 << int(TerrainChunkTopology.Edge.EAST))
| (1 << int(TerrainChunkTopology.Edge.SOUTH))
),
"The authored beach corner must expose east and south to ocean.",
)
_check(
beach_ocean_corner.minimum_required_neighbors == 2
and (
"beach_ocean_edge"
in beach_ocean_corner.required_neighbor_tags
),
"The beach corner must join two straight beach ocean edges.",
)
_check(
grass_sand_diagonal.must_be_interior
and not grass_sand_diagonal.participates_in_base_solver
and "mixed_surface" in grass_sand_diagonal.tags,
(
"The grass-sand diagonal must remain an inland post-process "
+ "mixed-surface chunk."
),
)
_check(
grass_sand_diagonal.surface_tags_for_edge(
TerrainChunkTopology.Edge.NORTH
) == PackedStringArray(["sand"])
and grass_sand_diagonal.surface_tags_for_edge(
TerrainChunkTopology.Edge.WEST
) == PackedStringArray(["sand"])
and grass_sand_diagonal.surface_tags_for_edge(
TerrainChunkTopology.Edge.EAST
) == PackedStringArray(["grass"])
and grass_sand_diagonal.surface_tags_for_edge(
TerrainChunkTopology.Edge.SOUTH
) == PackedStringArray(["grass"]),
"The diagonal's authored edges must retain two sand and two grass sides.",
)
var stream := CATALOG.definition_for_id(&"chunk_0003")
_check(
stream != null
and is_equal_approx(stream.selection_weight, 0.3)
and is_equal_approx(stream_variant.selection_weight, 0.3)
and stream_variant.tags == stream.tags
and stream_variant.allowed_neighbor_tags
== stream.allowed_neighbor_tags
and stream_variant.water_inlet_edges == stream.water_inlet_edges
and stream_variant.water_outlet_edges == stream.water_outlet_edges
and stream_variant.water_surface_size == stream.water_surface_size
and stream_variant.water_surface_offset
== stream.water_surface_offset,
(
"Both stream visuals must share one combined selection weight "
+ "and identical terrain/water behavior."
),
)
for cliff_definition: TerrainChunkDefinition in [
cliff_top,
cliff_corner,
cliff_edge,
cliff_ramp,
]:
_check(
cliff_definition.overlay_only
and cliff_definition.must_be_interior
and cliff_definition.base_layer_scene != null
and cliff_definition.base_layer_mesh_name == &"chunk_0000",
"Every second-tier piece must overlay level-one grass inland.",
)
for coastal_cliff: TerrainChunkDefinition in [
cliff_sea_edge,
cliff_sea_corner,
]:
_check(
coastal_cliff.overlay_only
and coastal_cliff.base_layer_scene == null
and not coastal_cliff.must_be_interior
and coastal_cliff.prefers_map_boundary
and "coast" in coastal_cliff.tags
and "elevation_2_sea_edge" in coastal_cliff.tags,
(
"Each second-tier sea piece must be a complete coastal "
+ "overlay without an inland grass base."
),
)
_check(
cliff_sea_edge.ocean_facing_edges
== (1 << int(TerrainChunkTopology.Edge.EAST)),
"The second-tier sea edge must face east before rotation.",
)
_check(
cliff_sea_corner.ocean_facing_edges
== (
(1 << int(TerrainChunkTopology.Edge.EAST))
| (1 << int(TerrainChunkTopology.Edge.SOUTH))
),
"The second-tier sea corner must face east and south before rotation.",
)
_check(
cliff_sea_corner.minimum_required_neighbors == 2
and (
"elevation_2_sea_edge"
in cliff_sea_corner.required_neighbor_tags
),
"The second-tier sea corner must join two straight sea edges.",
)
_check(
cliff_top.minimum_required_neighbors == 4
and "elevation_2" in cliff_top.required_neighbor_tags,
"The second-tier top must be enclosed on all four sides.",
)
_check(
"ramp" in cliff_ramp.tags
and cliff_ramp.maximum_placements > 0,
"The second-tier ramp must remain available without being required.",
)
func _validate_profiles() -> void:
@ -228,6 +393,91 @@ func _validate_profiles() -> void:
)
func _validate_second_tier_coastal_layout() -> void:
var generator := TerrainChunkGenerator.new()
generator.catalog = CATALOG
generator.grid_size = Vector2i(4, 4)
generator.generate_on_ready = false
generator.build_collision = false
generator.force_center_chunk_id = &""
root.add_child(generator)
var keys := PackedStringArray()
for row: int in generator.grid_size.y:
for column: int in generator.grid_size.x:
var at_north := row == 0
var at_east := column == generator.grid_size.x - 1
var at_south := row == generator.grid_size.y - 1
var at_west := column == 0
if at_north and at_west:
keys.append("chunk_0015@2")
elif at_north and at_east:
keys.append("chunk_0015@1")
elif at_south and at_east:
keys.append("chunk_0015@0")
elif at_south and at_west:
keys.append("chunk_0015@3")
elif at_north:
keys.append("chunk_0014@1")
elif at_east:
keys.append("chunk_0014@0")
elif at_south:
keys.append("chunk_0014@3")
elif at_west:
keys.append("chunk_0014@2")
else:
keys.append("chunk_0009@0")
_check(
generator.generate_from_placement_keys(keys),
(
"The second-tier sea edge and corner must form a complete "
+ "raised coastal perimeter."
),
)
if generator.placement_keys().size() == keys.size():
_check(
_all_neighbor_edges_match(generator),
"Every raised coastal-perimeter seam must match.",
)
_check(
_count_placements(generator.placement_keys(), "chunk_0014") == 8
and _count_placements(
generator.placement_keys(), "chunk_0015"
) == 4,
"The raised coastal perimeter must use its straight and corner pieces.",
)
for record: Dictionary in generator.placement_records():
var stable_id: StringName = record.get("stable_id", &"")
if stable_id not in [&"chunk_0014", &"chunk_0015"]:
continue
var coordinate: Vector2i = record.get(
"coordinate", Vector2i.ZERO
)
var variant := _find_variant(
generator,
stable_id,
int(record.get("rotation_quarters", 0)),
)
_check(
variant != null
and generator._variant_respects_ocean_boundary(
variant, coordinate
),
"Every second-tier sea opening must face outside the terrain grid.",
)
var generated := generator.get_generated_chunks_root()
if generated != null:
for child: Node in generated.get_children():
var stable_id := StringName(
child.get_meta(&"terrain_chunk_id", &"")
)
if stable_id in [&"chunk_0014", &"chunk_0015"]:
_check(
child.get_node_or_null("TerrainBaseLayer") == null,
"Second-tier sea pieces must not render inland grass over ocean.",
)
generator.free()
func _validate_generation() -> void:
var first := _make_generator()
root.add_child(first)
@ -243,6 +493,10 @@ func _validate_generation() -> void:
_placements_contain(first_keys, stable_id),
"Generated diagnostic must contain %s." % stable_id,
)
_check(
_placements_contain(first_keys, "chunk_0013"),
"Generated terrain must replace sharp grass-sand corners with diagonals.",
)
_check(
_all_neighbor_edges_match(first),
"Every generated neighboring edge must match.",
@ -351,8 +605,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
)
var stream := CATALOG.definition_for_id(&"chunk_0003")
_check(stream != null, "The stream definition must be available.")
if stream == null:
var alternate_stream := CATALOG.definition_for_id(&"chunk_0016")
_check(
stream != null and alternate_stream != null,
"Both stream visual definitions must be available.",
)
if stream == null or alternate_stream == null:
return
for quarter_turns: int in 4:
var stream_variant := _find_variant(
@ -364,7 +622,16 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
stream_variant != null,
"The stream must support rotation %d." % quarter_turns,
)
if stream_variant == null:
var alternate_variant := _find_variant(
generator,
&"chunk_0016",
quarter_turns,
)
_check(
alternate_variant != null,
"The alternate stream must support rotation %d." % quarter_turns,
)
if stream_variant == null or alternate_variant == null:
continue
var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge(
TerrainChunkTopology.Edge.NORTH,
@ -384,6 +651,25 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
== expected_outlet,
"The rotated stream outlet must follow its authored orientation.",
)
_check(
alternate_variant.rotated_edge_mask(
alternate_stream.water_inlet_edges
) == expected_inlet
and alternate_variant.rotated_edge_mask(
alternate_stream.water_outlet_edges
) == expected_outlet,
"The alternate stream connectors must rotate exactly like the original.",
)
for edge_value: int in TerrainChunkTopology.Edge.values():
var edge := edge_value as TerrainChunkTopology.Edge
_check(
stream_variant.profile(edge).signature(
TerrainChunkGenerator.CONSTRAINT_PROFILE_QUANTIZATION
) == alternate_variant.profile(edge).signature(
TerrainChunkGenerator.CONSTRAINT_PROFILE_QUANTIZATION
),
"Both stream visuals must expose identical terrain edge profiles.",
)
var beach := CATALOG.definition_for_id(&"chunk_0002")
_check(beach != null, "The beach definition must be available.")
@ -443,11 +729,13 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008")
_check(
(
grass_ocean_edge != null
and grass_beach_transition != null
and grass_ocean_corner != null
and beach_ocean_corner != null
),
"The authored coastline additions must be available.",
)
@ -455,6 +743,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
grass_ocean_edge == null
or grass_beach_transition == null
or grass_ocean_corner == null
or beach_ocean_corner == null
):
return
var beach_zero := _find_variant(generator, &"chunk_0002", 0)
@ -462,12 +751,14 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
var grass_edge_south := _find_variant(generator, &"chunk_0005", 3)
var transition_zero := _find_variant(generator, &"chunk_0006", 0)
var corner_zero := _find_variant(generator, &"chunk_0007", 0)
var beach_corner_zero := _find_variant(generator, &"chunk_0008", 0)
_check(
beach_zero != null
and grass_edge_zero != null
and grass_edge_south != null
and transition_zero != null
and corner_zero != null,
and corner_zero != null
and beach_corner_zero != null,
"The unrotated coastline variants must be available.",
)
if (
@ -476,6 +767,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
and grass_edge_south != null
and transition_zero != null
and corner_zero != null
and beach_corner_zero != null
):
_check(
grass_edge_zero.profile(
@ -522,6 +814,80 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
),
"The corner's west side must join a straight grass ocean edge.",
)
_check(
beach_corner_zero.profile(TerrainChunkTopology.Edge.NORTH).matches(
beach_zero.profile(TerrainChunkTopology.Edge.SOUTH),
generator.edge_match_tolerance,
),
"The beach corner's north side must join a straight beach.",
)
var beach_south := _find_variant(generator, &"chunk_0002", 3)
_check(
beach_south != null
and beach_corner_zero.profile(
TerrainChunkTopology.Edge.WEST
).matches(
beach_south.profile(TerrainChunkTopology.Edge.EAST),
generator.edge_match_tolerance,
),
"The beach corner's west side must join a rotated straight beach.",
)
var cliff_top_zero := _find_variant(generator, &"chunk_0009", 0)
var cliff_sea_edge_zero := _find_variant(generator, &"chunk_0014", 0)
var cliff_sea_edge_south := _find_variant(generator, &"chunk_0014", 3)
var cliff_sea_corner_zero := _find_variant(generator, &"chunk_0015", 0)
_check(
cliff_top_zero != null
and cliff_sea_edge_zero != null
and cliff_sea_edge_south != null
and cliff_sea_corner_zero != null,
"The second-tier sea edge variants must be available.",
)
if (
cliff_top_zero != null
and cliff_sea_edge_zero != null
and cliff_sea_edge_south != null
and cliff_sea_corner_zero != null
):
_check(
cliff_sea_edge_zero.profile(
TerrainChunkTopology.Edge.EAST
).points.is_empty(),
"The second-tier sea edge must remain open on its ocean side.",
)
_check(
cliff_sea_corner_zero.profile(
TerrainChunkTopology.Edge.EAST
).points.is_empty()
and cliff_sea_corner_zero.profile(
TerrainChunkTopology.Edge.SOUTH
).points.is_empty(),
"The second-tier sea corner must retain both ocean openings.",
)
_check(
generator._edges_are_compatible(
cliff_sea_edge_zero,
TerrainChunkTopology.Edge.WEST,
cliff_top_zero,
TerrainChunkTopology.Edge.EAST,
),
"The second-tier sea edge must join the raised grass top inland.",
)
_check(
generator._edges_are_compatible(
cliff_sea_corner_zero,
TerrainChunkTopology.Edge.NORTH,
cliff_sea_edge_zero,
TerrainChunkTopology.Edge.SOUTH,
)
and generator._edges_are_compatible(
cliff_sea_corner_zero,
TerrainChunkTopology.Edge.WEST,
cliff_sea_edge_south,
TerrainChunkTopology.Edge.EAST,
),
"The second-tier sea corner must join two straight sea edges.",
)
var flat_grass := _find_variant(generator, &"chunk_0000", 0)
var flat_sand := _find_variant(generator, &"chunk_0001", 0)
_check(
@ -560,7 +926,58 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
),
"No transition rotation may accept sand behind it.",
)
for stable_id: StringName in [&"chunk_0005", &"chunk_0006", &"chunk_0007"]:
for quarter_turns: int in 4:
var diagonal := _find_variant(
generator,
&"chunk_0013",
quarter_turns,
)
_check(
diagonal != null,
"The grass-sand diagonal must support rotation %d."
% quarter_turns,
)
if diagonal == null:
continue
for source_edge_value: int in TerrainChunkTopology.Edge.values():
var source_edge := source_edge_value as TerrainChunkTopology.Edge
var edge := TerrainChunkTopology.rotated_edge(
source_edge,
quarter_turns,
)
var neighbor_edge := TerrainChunkTopology.opposite_edge(edge)
var expects_sand := source_edge in [
TerrainChunkTopology.Edge.NORTH,
TerrainChunkTopology.Edge.WEST,
]
var expected := flat_sand if expects_sand else flat_grass
var rejected := flat_grass if expects_sand else flat_sand
_check(
generator._edges_are_compatible(
diagonal,
edge,
expected,
neighbor_edge,
),
"Each rotated diagonal edge must accept its authored surface.",
)
_check(
not generator._edges_are_compatible(
diagonal,
edge,
rejected,
neighbor_edge,
),
"No rotated diagonal edge may accept the opposite surface.",
)
for stable_id: StringName in [
&"chunk_0005",
&"chunk_0006",
&"chunk_0007",
&"chunk_0008",
&"chunk_0014",
&"chunk_0015",
]:
for quarter_turns: int in 4:
var coast_variant := _find_variant(
generator,
@ -580,11 +997,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
),
"%s must never place its ocean edge inside the map." % stable_id,
)
if stable_id != &"chunk_0007":
var coast_definition := CATALOG.definition_for_id(stable_id)
if coast_definition == null:
continue
var boundary_coordinate := Vector2i(3, 3)
var ocean_edges := coast_variant.rotated_edge_mask(
grass_ocean_corner.ocean_facing_edges
coast_definition.ocean_facing_edges
)
for edge_value: int in TerrainChunkTopology.Edge.values():
if (ocean_edges & (1 << edge_value)) == 0:
@ -603,15 +1021,15 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
coast_variant,
boundary_coordinate,
),
"Each grass corner rotation must fit its matching map corner.",
"Each coast rotation must fit its matching open map boundary.",
)
func _validate_generation_summary(generator: TerrainChunkGenerator) -> void:
var summary := generator._build_summary()
_check(
int(summary.get("variant_count", 0)) == 33,
"The current catalog must expose all 33 authored rotations.",
int(summary.get("variant_count", 0)) == 69,
"The current catalog must expose all 69 authored rotations.",
)
_check(
int(summary.get("solver_variant_count", 0))
@ -664,6 +1082,7 @@ func _make_generator() -> TerrainChunkGenerator:
generator.build_collision = false
generator.force_center_chunk_id = &"chunk_spawn"
generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS)
generator.grass_sand_smoothing_enabled = true
generator.maximum_backtracks = 100000
return generator
@ -811,6 +1230,27 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void:
straight_edge_neighbors == 2,
"Every grass corner must join two straight grass ocean edges.",
)
if placement.definition.stable_id == &"chunk_0008":
var straight_beach_neighbors := 0
for edge_value: int in TerrainChunkTopology.Edge.values():
var neighbor_coordinate := (
coordinate
+ TerrainChunkTopology.grid_offset(
edge_value as TerrainChunkTopology.Edge
)
)
if not generator._coordinate_is_inside_grid(neighbor_coordinate):
continue
var neighbor := generator._placements[
neighbor_coordinate.y * generator.grid_size.x
+ neighbor_coordinate.x
]
if neighbor.definition.stable_id == &"chunk_0002":
straight_beach_neighbors += 1
_check(
straight_beach_neighbors == 2,
"Every beach corner must join two straight beach ocean edges.",
)
if placement.definition.stable_id != &"chunk_0001":
continue
var touches_coast := false

View file

@ -0,0 +1,126 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18155
const TEST_SEED: int = 86421357
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("World layout multiplayer validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world",
WorldLayout.STARTER_ISLAND,
TEST_SEED,
true,
)))
assert(bool(main.call(
"_prepare_host_world",
WorldLayout.STARTER_ISLAND,
TEST_SEED,
)))
assert(save_manager.initialize_new_game(
TEST_SEED,
WorldLayout.STARTER_ISLAND,
))
assert(session.start_private_host(TEST_PORT))
main.call("_enter_gameplay")
assert(session.set_host_open(true))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
assert(session.peer_supports_capability(
remote_peer_id,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
))
var disconnect_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("World layout multiplayer host validation: PASS")
await _cleanup(main, session)
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
assert(session.supports_server_capability(
NetworkProtocol.WORLD_LAYOUT_CAPABILITY
))
var metadata: Dictionary = session.get_last_server_metadata()
assert(str(metadata.get("world_layout", "")) == String(
WorldLayout.STARTER_ISLAND
))
assert(int(metadata.get("world_seed", 0)) == TEST_SEED)
var world := main.get("_test_world") as TestWorld
assert(world.get_world_layout() == WorldLayout.STARTER_ISLAND)
assert(world.get_generation_seed() == TEST_SEED)
assert(world.get_node_or_null("Regions/StarterIslandRegion") != null)
assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null)
print("World layout multiplayer client validation: PASS")
await _cleanup(main, session)
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0
func _cleanup(main: Node, session: NetworkSession) -> void:
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()

View file

@ -0,0 +1 @@
uid://bg52pbgv5ykjc

View file

@ -0,0 +1,125 @@
extends SceneTree
const TestWorldScene: PackedScene = preload("res://world/test_world.tscn")
const SetupPageScene: PackedScene = preload(
"res://ui/new_game_setup_page.tscn"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
_validate_layout_values()
await _validate_setup_page()
await _validate_world_switching()
_validate_network_metadata()
print("World layout validation: PASS")
quit()
func _validate_layout_values() -> void:
assert(WorldLayout.is_valid(WorldLayout.GENERATED))
assert(WorldLayout.is_valid(String(WorldLayout.STARTER_ISLAND)))
assert(not WorldLayout.is_valid(&"unknown"))
assert(WorldLayout.normalized("unknown") == WorldLayout.GENERATED)
assert(WorldLayout.display_name(WorldLayout.STARTER_ISLAND) == "starter island")
func _validate_setup_page() -> void:
assert(NewGameSetupPage.parse_seed_text(" 24680 ") == 24680)
assert(NewGameSetupPage.parse_seed_text("0") == 0)
assert(NewGameSetupPage.parse_seed_text("2147483647") == 0)
assert(NewGameSetupPage.parse_seed_text("") == 0)
var text_seed: int = NewGameSetupPage.parse_seed_text("voyager island")
assert(text_seed > 0 and text_seed <= PlayerSaveManager.MAX_WORLD_SEED)
assert(NewGameSetupPage.parse_seed_text(" voyager island ") == text_seed)
assert(NewGameSetupPage.parse_seed_text("voyager island") == text_seed)
assert(NewGameSetupPage.parse_seed_text("Voyager Island") != text_seed)
var page := SetupPageScene.instantiate() as NewGameSetupPage
root.add_child(page)
await process_frame
page.open_page(true)
await process_frame
assert(page.visible)
var paper := page.get_node("%Paper") as PanelContainer
assert(paper.get_combined_minimum_size().x <= paper.size.x)
assert(paper.get_combined_minimum_size().y <= paper.size.y)
assert(page.get_selected_world_layout() == WorldLayout.GENERATED)
assert(page.get_selected_world_seed() > 0)
assert((page.get_node("%OverwriteWarning") as Label).visible)
var generated_button := page.get_node("%GeneratedButton") as Button
var starter_button := page.get_node("%StarterButton") as Button
var random_button := page.get_node("%RandomSeedButton") as Button
var custom_button := page.get_node("%CustomSeedButton") as Button
var seed_edit := page.get_node("%SeedEdit") as LineEdit
var start_button := page.get_node("%StartButton") as Button
var back_button := page.get_node("%BackButton") as Button
assert(generated_button.get_node(generated_button.focus_neighbor_right) == starter_button)
assert(generated_button.get_node(generated_button.focus_neighbor_bottom) == random_button)
assert(random_button.get_node(random_button.focus_neighbor_right) == custom_button)
assert(start_button.get_node(start_button.focus_neighbor_right) == back_button)
page.call("_choose_custom_seed")
assert(seed_edit.editable)
assert(custom_button.get_node(custom_button.focus_neighbor_bottom) == seed_edit)
assert(seed_edit.get_node(seed_edit.focus_neighbor_bottom) == start_button)
page.call("_set_world_layout", WorldLayout.STARTER_ISLAND)
assert(not (page.get_node("%SeedSection") as Control).visible)
var request: Dictionary = {}
page.start_requested.connect(func(layout: StringName, seed: int) -> void:
request["layout"] = layout
request["seed"] = seed
)
page.call("_request_start")
assert(request.get("layout") == WorldLayout.STARTER_ISLAND)
assert(int(request.get("seed", 0)) > 0)
page.queue_free()
await process_frame
func _validate_world_switching() -> void:
var world := TestWorldScene.instantiate() as TestWorld
root.add_child(world)
await process_frame
assert(world.get_world_layout() == WorldLayout.GENERATED)
assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null)
assert(world.activate_world(WorldLayout.STARTER_ISLAND, 13579))
assert(world.get_world_layout() == WorldLayout.STARTER_ISLAND)
assert(world.get_generation_seed() == 13579)
assert(world.get_node_or_null("Regions/StarterIslandRegion") != null)
assert(world.get_node_or_null("Regions/GeneratedWorldRegion") == null)
assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null)
assert(not world.get_fishable_water_regions().is_empty())
assert(world.activate_world(WorldLayout.GENERATED, 24680))
assert(world.get_world_layout() == WorldLayout.GENERATED)
assert(world.get_generation_seed() == 24680)
assert(world.get_node_or_null("Regions/GeneratedWorldRegion") != null)
assert(world.get_node_or_null("Regions/StarterIslandRegion") == null)
world.queue_free()
await process_frame
func _validate_network_metadata() -> void:
var hello: Dictionary = NetworkProtocol.make_server_hello(
true,
NetworkProtocol.RejectionCode.NONE,
"session",
2,
1,
16,
"room",
97531,
WorldLayout.STARTER_ISLAND,
)
assert(str(hello.get("world_layout", "")) == String(
WorldLayout.STARTER_ISLAND
))
assert(int(hello.get("world_seed", 0)) == 97531)
assert(
NetworkProtocol.WORLD_LAYOUT_CAPABILITY
in PackedStringArray(hello.get("capability_flags", []))
)

View file

@ -0,0 +1 @@
uid://bgxgyqq73sdpi

View file

@ -282,10 +282,22 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]:
# Coastline meshes may intentionally stop before the positive-X edge,
# leaving the surrounding ocean visible. Positive X is the canonical
# authored ocean direction; Godot supplies the quarter-turn variants.
allow_open_east_edge = "ocean_edge" in label
# Elevated inland cliff overlays also stop at their downhill edge,
# while second-tier sea pieces carry that edge down to ocean depth.
layered_cliff = "cliff_2" in label
allow_open_east_edge = (
"ocean_edge" in label
or "sea_edge" in label
or "sea_corner" in label
or layered_cliff
)
# Corner pieces additionally leave canonical negative Y open. Their
# two marked ocean edges rotate together at runtime.
allow_open_south_edge = "ocean_edge_corner" in label
# two marked outer edges rotate together at runtime.
allow_open_south_edge = (
"ocean_edge_corner" in label
or "sea_corner" in label
or (layered_cliff and "corner" in label)
)
bound_problems = _validate_bounds(
bounds,
tolerance,

View file

@ -893,12 +893,14 @@ func _on_entry_text_submitted(_value: String) -> void:
func _send() -> void:
if _send_pending:
return
var body := _entry.text
if body.strip_edges().is_empty():
var body := NetworkChatProtocol.sanitize_body(_entry.text)
if body.is_empty():
close_chat()
return
if _handle_editor_world_command(body):
return
_send_pending = true
_pending_send_body = NetworkChatProtocol.sanitize_body(body)
_pending_send_body = body
_entry.editable = false
if not _service.send_local_message(
body,
@ -912,6 +914,82 @@ func _send() -> void:
_set_status("Sending…")
func _handle_editor_world_command(body: String) -> bool:
# These commands are deliberately limited to sessions launched by the
# Godot editor. Exported builds do not have the editor feature tag, and a
# joined editor client must not be able to mutate its host's world.
if not OS.has_feature("editor"):
return false
var command_text: String = body.strip_edges()
if not (
command_text.begins_with("/time")
or command_text.begins_with("/weather")
):
return false
var parts: PackedStringArray = command_text.split(" ", false)
var command: String = String(parts[0]).trim_prefix("/").to_lower()
var result: String = ""
if _session == null or not _session.is_host():
result = "Editor world commands require the authoritative host."
elif command == "time":
result = _apply_editor_time_command(parts)
elif command == "weather":
result = _apply_editor_weather_command(parts)
else:
return false
_entry.clear()
_flush_draft()
_set_status(result)
close_chat(true)
return true
func _apply_editor_time_command(parts: PackedStringArray) -> String:
if parts.size() != 2 or _world_time == null:
return "Usage: /time [dawn, day, dusk, night]"
var phase_name: String = String(parts[1]).to_lower()
var target_hour: float = -1.0
match phase_name:
"dawn":
target_hour = WorldTimeServiceType.DAWN_START_HOUR
"day":
target_hour = WorldTimeServiceType.DAWN_END_HOUR
"dusk":
target_hour = WorldTimeServiceType.DUSK_START_HOUR
"night":
target_hour = WorldTimeServiceType.DUSK_END_HOUR
_:
return "Usage: /time [dawn, day, dusk, night]"
if not _world_time.set_authoritative_time(target_hour):
return "Editor world time could not be changed."
return "Editor time: %s (%s)." % [
phase_name,
_world_time.get_clock_text(),
]
func _apply_editor_weather_command(parts: PackedStringArray) -> String:
if parts.size() != 2 or _world_weather == null:
return "Usage: /weather [sunny, cloudy, rainy, foggy]"
var weather_name: String = String(parts[1]).to_lower()
var target_weather: WorldWeatherServiceType.Weather
match weather_name:
"clear", "sunny":
weather_name = "sunny"
target_weather = WorldWeatherServiceType.Weather.SUNNY
"cloudy":
target_weather = WorldWeatherServiceType.Weather.CLOUDY
"rainy":
target_weather = WorldWeatherServiceType.Weather.RAINY
"foggy":
target_weather = WorldWeatherServiceType.Weather.FOGGY
_:
return "Usage: /weather [sunny, cloudy, rainy, foggy]"
if not _world_weather.set_authoritative_weather(target_weather):
return "Editor world weather could not be changed."
return "Editor weather: %s." % weather_name
func _on_local_message_confirmed(message: Dictionary) -> void:
if (
not _send_pending

View file

@ -168,6 +168,12 @@ func _on_pressed() -> void:
func _on_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event != null and mouse_event.pressed:
# Pointer clicks give Buttons keyboard focus after their GUI callback.
# Release that pointer-created focus on the next frame so a tooltip does
# not remain pinned after the pointer leaves. Controller navigation still
# keeps focus normally because it does not arrive as a mouse event.
call_deferred("_release_pointer_focus")
if (
mouse_event != null
and mouse_event.button_index == MOUSE_BUTTON_RIGHT
@ -179,6 +185,11 @@ func _on_gui_input(event: InputEvent) -> void:
accept_event()
func _release_pointer_focus() -> void:
if has_focus():
release_focus()
func _set_context_hovered(active: bool) -> void:
_context_hovered = active
_update_context_presence()

View file

@ -627,6 +627,10 @@ func setup(
_refresh_all()
func set_world_interaction(interaction: ShopInteractionType) -> void:
_interaction = interaction
func open_shop() -> bool:
if (
visible

View file

@ -214,6 +214,7 @@ var _controller_text_entry_is_open: Callable
func _ready() -> void:
_prioritize_surface_drawing_pointer_input()
_bite_prompt_button.pressed.connect(_on_bite_prompt_pressed)
_apply_active_bait_indicator_style()
_refresh_active_bait_indicator()
@ -487,6 +488,37 @@ func setup(
)
func set_world_interactions(
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
) -> void:
_shop_interaction = shop_interaction
_storage_interaction = storage_interaction
_fishing_shop.set_world_interaction(shop_interaction)
_player_storage.set_world_interaction(storage_interaction)
func _prioritize_surface_drawing_pointer_input() -> void:
# Control input follows sibling order rather than CanvasItem.z_index. Chat is
# a full-screen Control with interactive mobile children, so the toolbar must
# follow it in the tree to own their overlap while the art kit is active.
# Opening Chat deactivates surface drawing and hides the toolbar, returning
# the same area to Chat without any special-case pointer forwarding.
if (
_surface_drawing_toolbar == null
or _chat_ui == null
or _surface_drawing_toolbar.get_parent() != _chat_ui.get_parent()
):
return
var ui_root: Node = _surface_drawing_toolbar.get_parent()
var toolbar_index: int = _surface_drawing_toolbar.get_index()
var chat_index: int = _chat_ui.get_index()
if toolbar_index > chat_index:
return
# Removing the earlier toolbar shifts Chat one position toward the start.
ui_root.move_child(_surface_drawing_toolbar, chat_index)
func _input(event: InputEvent) -> void:
# The on-screen keyboard owns controller input while it is open. Its
# overlay is processed before the UI beneath it and consumes the event.
@ -1264,6 +1296,7 @@ func _drawing_pointer_window_position() -> Vector2:
func setup_data_and_identity(
data_root: PlayerDataRoot,
progression_saves: PlayerSaveManager,
identity_backups: IdentityBackupService,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
@ -1275,6 +1308,7 @@ func setup_data_and_identity(
]:
panel.setup_data_and_identity(
data_root,
progression_saves,
identity_backups,
player_identity,
host_identity,
@ -1603,9 +1637,14 @@ func _can_toggle_gameplay_hud() -> bool:
func _refresh_gameplay_hud_visibility() -> void:
var fishing_override_active: bool = (
_gameplay_hud_hidden
and _fishing_spot != null
and _fishing_spot.is_fishing_sequence_active()
)
var show_world_hud: bool = (
_gameplay_ui_enabled
and not _gameplay_hud_hidden
and (not _gameplay_hud_hidden or fishing_override_active)
and not _system_menu_open
and not _player_menu_open
and not _shop_open
@ -1660,6 +1699,7 @@ func set_storage_prompt_visible(
_storage_prompt.visible = (
requested_visible
and _gameplay_ui_enabled
and not _gameplay_hud_hidden
and not _system_menu_open
and not _player_menu_open
and not _shop_open
@ -1697,6 +1737,7 @@ func set_shop_prompt_visible(
_shop_prompt.visible = (
requested_visible
and _gameplay_ui_enabled
and not _gameplay_hud_hidden
and not _system_menu_open
and not _player_menu_open
and not _shop_open
@ -1923,6 +1964,7 @@ func _refresh_fishing_panel_visibility() -> void:
_gameplay_ui_enabled
and has_content
)
_refresh_gameplay_hud_visibility()
func _on_bite_prompt_changed(prompt_visible: bool) -> void:

309
ui/new_game_setup_page.gd Normal file
View file

@ -0,0 +1,309 @@
class_name NewGameSetupPage
extends Control
signal start_requested(world_layout: StringName, world_seed: int)
signal back_requested
const WorldLayoutType = preload("res://world/world_layout.gd")
const SaveManagerType = preload("res://save/player_save_manager.gd")
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
enum SeedMode {
RANDOM,
CUSTOM,
}
@onready var _paper: PanelContainer = %Paper
@onready var _generated_button: Button = %GeneratedButton
@onready var _starter_button: Button = %StarterButton
@onready var _world_description: Label = %WorldDescription
@onready var _seed_section: VBoxContainer = %SeedSection
@onready var _random_seed_button: Button = %RandomSeedButton
@onready var _custom_seed_button: Button = %CustomSeedButton
@onready var _seed_edit: LineEdit = %SeedEdit
@onready var _seed_help: Label = %SeedHelp
@onready var _overwrite_warning: Label = %OverwriteWarning
@onready var _status: Label = %Status
@onready var _start_button: Button = %StartButton
@onready var _back_button: Button = %BackButton
var _world_layout: StringName = WorldLayoutType.GENERATED
var _seed_mode: SeedMode = SeedMode.RANDOM
var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED
func _ready() -> void:
UtilityPageStyle.apply_page(self)
_paper.add_theme_stylebox_override(
"panel", UtilityPageStyle.panel_style()
)
for button: Button in [
_generated_button,
_starter_button,
_random_seed_button,
_custom_seed_button,
_start_button,
_back_button,
]:
UtilityPageStyle.apply_ocean_button(button)
UtilityPageStyle.apply_ocean_line_edit(_seed_edit)
_generated_button.pressed.connect(
_set_world_layout.bind(WorldLayoutType.GENERATED)
)
_starter_button.pressed.connect(
_set_world_layout.bind(WorldLayoutType.STARTER_ISLAND)
)
_random_seed_button.pressed.connect(_choose_random_seed)
_custom_seed_button.pressed.connect(_choose_custom_seed)
_seed_edit.text_changed.connect(_on_seed_text_changed)
_seed_edit.text_submitted.connect(_on_seed_submitted)
_start_button.pressed.connect(_request_start)
_back_button.pressed.connect(back_requested.emit)
hide()
func open_page(has_existing_progression: bool) -> void:
_world_layout = WorldLayoutType.GENERATED
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_overwrite_warning.visible = has_existing_progression
_status.text = ""
_refresh_presentation()
show()
UtilityPageStyle.animate_in(self)
_generated_button.grab_focus.call_deferred()
func close_page() -> void:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and is_ancestor_of(focus_owner):
focus_owner.release_focus()
hide()
func request_back() -> void:
back_requested.emit()
func get_selected_world_layout() -> StringName:
return _world_layout
func get_selected_world_seed() -> int:
return (
_random_seed
if _seed_mode == SeedMode.RANDOM
else parse_seed_text(_seed_edit.text)
)
static func parse_seed_text(text: String) -> int:
var normalized: String = text.strip_edges()
if normalized.is_empty():
return 0
if normalized.is_valid_int():
var numeric_seed: int = int(normalized)
if (
numeric_seed <= 0
or numeric_seed > SaveManagerType.MAX_WORLD_SEED
):
return 0
return numeric_seed
var digest: PackedByteArray = normalized.sha256_buffer()
var hashed_seed: int = (
(int(digest[0]) << 24)
| (int(digest[1]) << 16)
| (int(digest[2]) << 8)
| int(digest[3])
) & 0x7fffffff
return (hashed_seed % SaveManagerType.MAX_WORLD_SEED) + 1
func _set_world_layout(layout: StringName) -> void:
if not WorldLayoutType.is_valid(layout):
return
_world_layout = layout
_status.text = ""
_refresh_presentation()
if layout == WorldLayoutType.GENERATED:
_random_seed_button.grab_focus.call_deferred()
else:
_start_button.grab_focus.call_deferred()
func _choose_random_seed() -> void:
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_status.text = ""
_refresh_presentation()
func _choose_custom_seed() -> void:
_seed_mode = SeedMode.CUSTOM
if parse_seed_text(_seed_edit.text) == _random_seed:
_seed_edit.text = ""
_status.text = ""
_refresh_presentation()
_seed_edit.grab_focus.call_deferred()
_seed_edit.select_all.call_deferred()
func _roll_random_seed() -> void:
_random_seed = SaveManagerType.roll_world_seed()
_seed_edit.text = str(_random_seed)
func _on_seed_text_changed(_text: String) -> void:
if _seed_mode != SeedMode.CUSTOM:
return
_status.text = ""
_refresh_start_state()
func _on_seed_submitted(_text: String) -> void:
_request_start()
func _request_start() -> void:
var seed: int = get_selected_world_seed()
if _world_layout == WorldLayoutType.GENERATED and seed == 0:
_status.text = (
"enter some text or a whole number from 1 to %d."
% SaveManagerType.MAX_WORLD_SEED
)
_seed_edit.grab_focus()
_seed_edit.select_all()
return
if seed == 0:
seed = SaveManagerType.roll_world_seed()
_start_button.disabled = true
start_requested.emit(_world_layout, seed)
func _refresh_presentation() -> void:
var generated: bool = _world_layout == WorldLayoutType.GENERATED
_generated_button.set_pressed_no_signal(generated)
_starter_button.set_pressed_no_signal(not generated)
_world_description.text = (
"build a new island from terrain chunks.\n"
+ "the same seed always builds the same world."
if generated
else "play on the starter island."
)
_seed_section.visible = generated
_random_seed_button.set_pressed_no_signal(
_seed_mode == SeedMode.RANDOM
)
_custom_seed_button.set_pressed_no_signal(
_seed_mode == SeedMode.CUSTOM
)
_seed_edit.editable = _seed_mode == SeedMode.CUSTOM
_seed_edit.focus_mode = (
Control.FOCUS_ALL
if _seed_mode == SeedMode.CUSTOM
else Control.FOCUS_NONE
)
_seed_help.text = (
"press random seed again to roll another world."
if _seed_mode == SeedMode.RANDOM
else "enter text or a number from 1 to %d. the same entry makes the same world."
% SaveManagerType.MAX_WORLD_SEED
)
_refresh_start_state()
_configure_controller_focus()
func _refresh_start_state() -> void:
_start_button.disabled = (
_world_layout == WorldLayoutType.GENERATED
and _seed_mode == SeedMode.CUSTOM
and parse_seed_text(_seed_edit.text) == 0
)
func _configure_controller_focus() -> void:
var generated: bool = _world_layout == WorldLayoutType.GENERATED
var custom: bool = generated and _seed_mode == SeedMode.CUSTOM
_set_neighbors(
_generated_button,
_generated_button,
_starter_button,
_generated_button,
_random_seed_button if generated else _start_button,
)
_set_neighbors(
_starter_button,
_generated_button,
_starter_button,
_starter_button,
_custom_seed_button if generated else _back_button,
)
_set_neighbors(
_random_seed_button,
_random_seed_button,
_custom_seed_button,
_generated_button,
_seed_edit if custom else _start_button,
)
_set_neighbors(
_custom_seed_button,
_random_seed_button,
_custom_seed_button,
_starter_button,
_seed_edit if custom else _back_button,
)
_set_neighbors(
_seed_edit,
_seed_edit,
_seed_edit,
_custom_seed_button,
_start_button,
)
var action_top: Control = (
_seed_edit
if custom
else _random_seed_button
if generated
else _generated_button
)
_set_neighbors(
_start_button,
_start_button,
_back_button,
action_top,
_start_button,
)
_set_neighbors(
_back_button,
_start_button,
_back_button,
action_top,
_back_button,
)
var traversal: Array[Control] = [
_generated_button,
_starter_button,
]
if generated:
traversal.append(_random_seed_button)
traversal.append(_custom_seed_button)
if custom:
traversal.append(_seed_edit)
traversal.append(_start_button)
traversal.append(_back_button)
ControllerFocusNavigationType.configure_traversal(traversal)
func _set_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)

View file

@ -0,0 +1 @@
uid://d3iueu4gj6bwo

170
ui/new_game_setup_page.tscn Normal file
View file

@ -0,0 +1,170 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/new_game_setup_page.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
[node name="NewGameSetupPage" type="Control"]
visible = false
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("2_theme")
script = ExtResource("1_script")
[node name="Paper" type="PanelContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -360.0
offset_top = -280.0
offset_right = 360.0
offset_bottom = 280.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="Paper"]
layout_mode = 2
theme_override_constants/margin_left = 46
theme_override_constants/margin_top = 30
theme_override_constants/margin_right = 46
theme_override_constants/margin_bottom = 30
[node name="Layout" type="VBoxContainer" parent="Paper/Margin"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="Title" type="Label" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 32
text = "new game"
horizontal_alignment = 1
[node name="Prompt" type="Label" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 17
text = "choose where you want to begin"
horizontal_alignment = 1
[node name="WorldButtons" type="HBoxContainer" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="GeneratedButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(230, 58)
layout_mode = 2
toggle_mode = true
text = "generate a world"
[node name="StarterButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(230, 58)
layout_mode = 2
toggle_mode = true
text = "starter island"
[node name="WorldDescription" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 16
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="SeedSection" type="VBoxContainer" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="SeedLabel" type="Label" parent="Paper/Margin/Layout/SeedSection"]
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 20
text = "world seed"
horizontal_alignment = 1
[node name="SeedModes" type="HBoxContainer" parent="Paper/Margin/Layout/SeedSection"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="RandomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 48)
layout_mode = 2
toggle_mode = true
text = "random seed"
[node name="CustomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 48)
layout_mode = 2
toggle_mode = true
text = "enter a seed"
[node name="SeedEdit" type="LineEdit" parent="Paper/Margin/Layout/SeedSection"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
theme_override_font_sizes/font_size = 20
placeholder_text = "number or words"
alignment = 1
max_length = 64
[node name="SeedHelp" type="Label" parent="Paper/Margin/Layout/SeedSection"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
[node name="OverwriteWarning" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.78, 0.62, 1)
theme_override_font_sizes/font_size = 15
text = "starting a new game replaces your existing progression."
horizontal_alignment = 1
[node name="Status" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.7, 0.65, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="Paper/Margin/Layout"]
custom_minimum_size = Vector2(0, 4)
layout_mode = 2
size_flags_vertical = 3
[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="StartButton" type="Button" parent="Paper/Margin/Layout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 54)
layout_mode = 2
text = "start new game"
[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(150, 54)
layout_mode = 2
text = "back"

View file

@ -430,7 +430,8 @@ func _show_native_keyboard_for(control: Control) -> void:
func _hide_native_keyboard() -> void:
DisplayServer.virtual_keyboard_hide()
if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD):
DisplayServer.virtual_keyboard_hide()
static func _line_edit_keyboard_type(

View file

@ -1693,6 +1693,7 @@ func close_menu(
_release_controller_ownership(false, true)
get_viewport().gui_cancel_drag()
_close_sale_confirmation()
_hide_inventory_context_tooltip()
if reason in [
CloseReason.BITE_STARTED,
CloseReason.WATER_RECOVERY,
@ -2497,6 +2498,8 @@ func _populate_tackle_column(
owned.quantity,
item.max_stack,
&"QuantityBadge",
UtilityPageStyle.SUPPLY_BADGE_EDGE_MARGIN,
0.0,
)
button.pressed.connect(_select_tackle_item.bind(owned.item_id))
button.gui_input.connect(
@ -2540,6 +2543,8 @@ func _on_tackle_button_gui_input(
button: Button,
) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event != null and mouse_event.pressed:
call_deferred("_release_pointer_focus", button)
if (
mouse_event == null
or mouse_event.button_index != MOUSE_BUTTON_RIGHT
@ -2555,6 +2560,11 @@ func _on_tackle_button_gui_input(
button.accept_event()
func _release_pointer_focus(control: Control) -> void:
if control != null and is_instance_valid(control) and control.has_focus():
control.release_focus()
func _configure_tackle_item_focus() -> void:
if not is_node_ready():
return

View file

@ -70,6 +70,10 @@ func setup(
_refresh()
func set_world_interaction(interaction: PlayerStorageInteraction) -> void:
_interaction = interaction
func open_storage() -> bool:
if (
visible

View file

@ -125,6 +125,7 @@ var _environment_volume: float = 1.0
var _network_profile: NetworkProfilePreferences
var _network_session: NetworkSession
var _data_root: PlayerDataRoot
var _progression_saves: PlayerSaveManager
var _identity_backups: IdentityBackupService
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
@ -134,6 +135,8 @@ var _controller_mapping_panel: ControllerMappingPanelType
var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType
var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType
var _data_folder_dialog: FileDialog
var _progression_import_dialog: FileDialog
var _progression_export_dialog: FileDialog
var _backup_file_dialog: FileDialog
var _export_file_dialog: FileDialog
var _passphrase_dialog: ConfirmationDialog
@ -143,6 +146,7 @@ var _pending_identity_operation := ""
var _pending_identity_type := ""
var _pending_identity_path := ""
var _pending_import_data: Dictionary = {}
var _pending_progression_path := ""
func _notification(what: int) -> void:
@ -244,6 +248,8 @@ func _connect_controls() -> void:
%KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping)
%OpenDataFolder.pressed.connect(_open_data_folder)
%ChangeDataFolder.pressed.connect(_choose_data_folder)
%ExportProgression.pressed.connect(_choose_progression_export)
%ImportProgression.pressed.connect(_choose_progression_import)
%ExportPlayerIdentity.pressed.connect(
_choose_identity_export.bind("player")
)
@ -337,6 +343,7 @@ func setup_keyboard_mouse_mapping(
func setup_data_and_identity(
data_root: PlayerDataRoot,
progression_saves: PlayerSaveManager,
identity_backups: IdentityBackupService,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
@ -344,6 +351,7 @@ func setup_data_and_identity(
interface_fonts: InterfaceFontController,
) -> void:
_data_root = data_root
_progression_saves = progression_saves
_identity_backups = identity_backups
_player_identity = player_identity
_host_identity = host_identity
@ -611,6 +619,11 @@ func _refresh_data_page() -> void:
_data_root.override_active
or (_network_session != null and _network_session.is_session_active())
)
%ExportProgression.disabled = _progression_saves == null
%ImportProgression.disabled = (
_progression_saves == null
or (_network_session != null and _network_session.is_session_active())
)
var fingerprint: String = (
_player_identity.fingerprint if _player_identity != null else ""
)
@ -661,6 +674,122 @@ func _copy_player_fingerprint() -> void:
_feedback.text = "full player fingerprint copied for server operator setup."
func _choose_progression_export() -> void:
if _progression_saves == null or _data_root == null:
_feedback.text = "progression export is unavailable."
return
var timestamp: String = Time.get_datetime_string_from_system().replace(
":", "-"
)
var suggested: String = _data_root.progression_backup_directory().path_join(
"NETfishing-progression-%s%s"
% [timestamp, PlayerSaveManager.ARCHIVE_EXTENSION]
)
if _progression_export_dialog == null:
_progression_export_dialog = FileDialog.new()
_progression_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_progression_export_dialog.access = FileDialog.ACCESS_FILESYSTEM
_progression_export_dialog.use_native_dialog = false
_progression_export_dialog.filters = PackedStringArray([
"*.nfsave ; NETfishing progression archive",
])
_progression_export_dialog.file_selected.connect(
_progression_export_file_selected
)
_interface_fonts.apply_utility_theme(_progression_export_dialog)
add_child(_progression_export_dialog)
_progression_export_dialog.current_dir = suggested.get_base_dir()
_progression_export_dialog.current_file = suggested.get_file()
_interface_fonts.popup_file_dialog(_progression_export_dialog)
func _progression_export_file_selected(path: String) -> void:
var destination: String = (
path
if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION)
else path + PlayerSaveManager.ARCHIVE_EXTENSION
)
var result: Dictionary = _progression_saves.export_progression_archive(
destination
)
_feedback.text = str(
result.get("message", "progression export failed.")
)
func _choose_progression_import() -> void:
if _progression_saves == null or _data_root == null:
_feedback.text = "progression import is unavailable."
return
if _network_session != null and _network_session.is_session_active():
_feedback.text = "return to title before importing progression."
return
if _progression_import_dialog == null:
_progression_import_dialog = FileDialog.new()
_progression_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_progression_import_dialog.access = FileDialog.ACCESS_FILESYSTEM
_progression_import_dialog.use_native_dialog = false
_progression_import_dialog.filters = PackedStringArray([
"*.nfsave ; NETfishing progression archive",
])
_progression_import_dialog.file_selected.connect(
_progression_import_file_selected
)
_interface_fonts.apply_utility_theme(_progression_import_dialog)
add_child(_progression_import_dialog)
_progression_import_dialog.current_dir = (
_data_root.progression_backup_directory()
)
_interface_fonts.popup_file_dialog(_progression_import_dialog)
func _progression_import_file_selected(path: String) -> void:
var inspected: Dictionary = (
_progression_saves.inspect_progression_archive(path)
)
if not bool(inspected.get("ok", false)):
_feedback.text = str(
inspected.get("message", "progression archive could not be opened.")
)
return
_pending_progression_path = path
var dialog := ConfirmationDialog.new()
dialog.title = "replace saved progression?"
dialog.ok_button_text = "import progression"
dialog.dialog_text = (
"this will replace the current progression after making a backup.\n\n"
+ "fish: %d\ndiscovered: %d\nworld: %s\nworld seed: %d\n\n"
+ "identities, settings, friends, bans, and trusted servers are unchanged."
) % [
int(inspected.get("catch_count", 0)),
int(inspected.get("discovered_species_count", 0)),
WorldLayout.display_name(inspected.get(
"world_layout",
String(WorldLayout.GENERATED),
)),
int(inspected.get("world_seed", 0)),
]
dialog.confirmed.connect(_confirm_progression_import.bind(dialog))
dialog.canceled.connect(dialog.queue_free)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(640, 390))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
func _confirm_progression_import(dialog: ConfirmationDialog) -> void:
var result: Dictionary = _progression_saves.import_progression_archive(
_pending_progression_path
)
_feedback.text = str(
result.get("message", "progression import failed.")
)
_pending_progression_path = ""
dialog.queue_free()
func _choose_data_folder() -> void:
if _data_root == null or _data_root.override_active:
_feedback.text = "the data folder is externally managed."

View file

@ -567,7 +567,7 @@ size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_right = NodePath("../ChangeDataFolder")
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression")
text = "open data folder"
[node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"]
@ -578,15 +578,41 @@ size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_left = NodePath("../OpenDataFolder")
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression")
text = "change data folder"
[node name="ProgressionRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="ExportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_right = NodePath("../ImportProgression")
focus_neighbor_top = NodePath("../../FolderRow/OpenDataFolder")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
text = "export progression"
[node name="ImportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_left = NodePath("../ExportProgression")
focus_neighbor_top = NodePath("../../FolderRow/ChangeDataFolder")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
text = "import progression"
[node name="CopyPlayerFingerprint" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
focus_mode = 2
focus_neighbor_top = NodePath("../FolderRow/OpenDataFolder")
focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression")
focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity")
text = "copy player fingerprint"

View file

@ -26,6 +26,7 @@ const TitleCreditsPageType = preload("res://ui/title_credits_page.gd")
const NetworkSessionType = preload("res://network/network_session.gd")
const SavedServerStoreType = preload("res://network/saved_server_store.gd")
const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd")
const CurrencyPresentationType = preload(
"res://ui/currency_presentation.gd"
)
@ -94,14 +95,13 @@ const INTRO_BRANDING_TRAVEL_DURATION: float = 2.0
const INTRO_BRANDING_START_DELAY: float = 0.35
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
signal new_game_requested(world_seed: int)
signal new_game_requested(world_layout: StringName, world_seed: int)
signal continue_game_requested
signal quit_requested
signal join_game_requested(endpoint: String)
enum ConfirmationAction {
NONE,
NEW_GAME,
DELETE_SAVE,
}
@ -142,12 +142,12 @@ enum ConfirmationAction {
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter
@onready var _start_prompt_label: Label = %StartPromptLabel
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
@onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage
@onready var _credits_page: TitleCreditsPageType = %CreditsPage
var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType
var _inspection: SaveInspectionType
var _pending_new_game_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE
var _action_in_progress: bool = false
var _decorative_rng := RandomNumberGenerator.new()
@ -210,6 +210,8 @@ func _ready() -> void:
)
_new_game_button.pressed.connect(_on_new_game_pressed)
_join_game_button.pressed.connect(_open_join_game)
_new_game_setup_page.start_requested.connect(_start_configured_new_game)
_new_game_setup_page.back_requested.connect(_close_new_game_setup)
_settings_button.pressed.connect(_open_settings)
_credits_button.pressed.connect(_open_credits)
_delete_button.pressed.connect(_on_delete_pressed)
@ -283,6 +285,7 @@ func reopen() -> void:
_reset_confirmation()
_settings_panel.hide()
_join_game_page.close_page()
_new_game_setup_page.close_page()
_credits_page.close_page()
_refresh_save_inspection()
show()
@ -311,6 +314,7 @@ func open_join_game_page(endpoint: String = "") -> void:
_settings_panel.hide()
_confirmation_page.hide_page()
_credits_page.close_page()
_new_game_setup_page.close_page()
_join_game_page.open_page(endpoint)
@ -326,6 +330,7 @@ func _open_join_game() -> void:
if (
_action_in_progress
or _is_confirmation_active()
or _new_game_setup_page.visible
or _credits_page.visible
):
return
@ -346,6 +351,7 @@ func _open_credits() -> void:
or _is_confirmation_active()
or _settings_panel.visible
or _join_game_page.visible
or _new_game_setup_page.visible
or _credits_page.visible
):
return
@ -583,6 +589,11 @@ func _input(event: InputEvent) -> void:
_join_game_page.request_back()
get_viewport().set_input_as_handled()
return
if _new_game_setup_page.visible:
if event.is_action_pressed("ui_cancel"):
_new_game_setup_page.request_back()
get_viewport().set_input_as_handled()
return
if (
_title_settings_transition_active
or _title_entry_transition_active
@ -629,6 +640,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
not _button_center.visible
or _is_confirmation_active()
or _settings_panel.visible
or _new_game_setup_page.visible
or _credits_page.visible
):
return false
@ -1031,19 +1043,59 @@ func _on_new_game_pressed() -> void:
"the existing save cannot be accessed safely."
)
return
_pending_new_game_seed = PlayerSaveManager.roll_world_seed()
var warning := "start a new world?"
if _inspection.status != SaveInspectionType.Status.MISSING:
warning += " existing progression will be deleted."
warning += "\nworld seed: %d" % _pending_new_game_seed
_open_confirmation(
ConfirmationAction.NEW_GAME,
warning,
"start\nnew game",
true
_open_new_game_setup(
_inspection.status != SaveInspectionType.Status.MISSING
)
func _open_new_game_setup(has_existing_progression: bool) -> void:
_modal_restore_navigation_focus = _navigation_focus_active
_set_title_bubbles_interactive(false)
_release_primary_menu_focus()
_presentation_center.hide()
_start_prompt_center.hide()
_join_game_page.close_page()
_credits_page.close_page()
_new_game_setup_page.open_page(has_existing_progression)
func _close_new_game_setup() -> void:
_new_game_setup_page.close_page()
_presentation_center.show()
_button_center.show()
_start_prompt_center.hide()
_set_title_bubbles_interactive(true)
var restore_navigation_focus: bool = _modal_restore_navigation_focus
_modal_restore_navigation_focus = false
if restore_navigation_focus:
_navigation_focus_active = true
_new_game_button.grab_focus()
else:
_navigation_focus_active = false
_release_title_focus()
_update_continue_stats_visibility()
func _start_configured_new_game(
world_layout: StringName,
world_seed: int,
) -> void:
if _action_in_progress or not _new_game_setup_page.visible:
return
_new_game_setup_page.close_page()
_action_in_progress = true
new_game_requested.emit(world_layout, world_seed)
_action_in_progress = false
# Signal delivery is synchronous. A successful start hides this title screen;
# if it remains visible, restore the menu so a generation error is recoverable.
if visible:
_presentation_center.show()
_button_center.show()
_set_title_bubbles_interactive(true)
_navigation_focus_active = true
_new_game_button.grab_focus()
func _on_delete_pressed() -> void:
if (
_action_in_progress
@ -1071,14 +1123,6 @@ func _on_confirmation_accepted() -> void:
var action: ConfirmationAction = _confirmation_action
_confirmation_page.lock_interaction()
_action_in_progress = true
if action == ConfirmationAction.NEW_GAME:
_confirmation_action = ConfirmationAction.NONE
_confirmation_transition_generation += 1
_cancel_confirmation_transition()
_confirmation_page.hide_page()
new_game_requested.emit(_pending_new_game_seed)
_action_in_progress = false
return
if not _save_manager.delete_progression_save():
_feedback_label.text = _center_feedback_text(
"failed to delete saved progression."
@ -1295,7 +1339,7 @@ func _on_settings_applied() -> void:
func _on_settings_closed() -> void:
pass
_refresh_save_inspection()
func _on_settings_closing() -> void:

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3]
[gd_scene load_steps=24 format=3]
[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
@ -19,6 +19,7 @@
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/new_game.png" id="17_new_game"]
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/credits.png" id="18_credits"]
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/delete_save.png" id="19_delete_save"]
[ext_resource type="PackedScene" path="res://ui/new_game_setup_page.tscn" id="20_new_game_setup"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
shader = ExtResource("4_water_shader")
@ -225,7 +226,7 @@ unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
theme_override_font_sizes/font_size = 22
text = "v0.13.1-alpha"
text = "v0.14.0-alpha"
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
@ -486,3 +487,13 @@ anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="NewGameSetupPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("20_new_game_setup")]
unique_name_in_owner = true
z_index = 220
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2

View file

@ -53,6 +53,7 @@ static func add_supply_quantity_badge(
total: int,
badge_name: StringName = &"SupplyQuantityBadge",
top_margin: float = SUPPLY_BADGE_EDGE_MARGIN,
x_offset: float = SUPPLY_BADGE_X_OFFSET,
) -> Panel:
var badge := Panel.new()
badge.name = badge_name
@ -68,7 +69,7 @@ static func add_supply_quantity_badge(
quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
badge.add_child(quantity_label)
configure_supply_quantity_badge(
badge, quantity_label, current, total, top_margin
badge, quantity_label, current, total, top_margin, x_offset
)
return badge
@ -79,6 +80,7 @@ static func configure_supply_quantity_badge(
current: int,
total: int,
top_margin: float = SUPPLY_BADGE_EDGE_MARGIN,
x_offset: float = SUPPLY_BADGE_X_OFFSET,
) -> void:
var supply_text := supply_quantity_text(current, total)
var text_width: float = TuffyFont.get_string_size(
@ -92,10 +94,10 @@ static func configure_supply_quantity_badge(
ceilf(text_width + SUPPLY_BADGE_HORIZONTAL_PADDING),
)
badge.offset_left = (
-badge_width - SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET
-badge_width - SUPPLY_BADGE_EDGE_MARGIN + x_offset
)
badge.offset_top = top_margin
badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET
badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + x_offset
badge.offset_bottom = top_margin + SUPPLY_BADGE_HEIGHT
badge.pivot_offset = Vector2(
badge_width * 0.5,

View file

@ -17,4 +17,5 @@ script = ExtResource("1_definition")
stable_id = &"biome_coast"
label = "coast"
required_chunk_tags = PackedStringArray("sand")
forbidden_chunk_tags = PackedStringArray("mixed_surface")
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_sand_tree")])

View file

@ -6,10 +6,11 @@
[sub_resource type="Resource" id="Rule_grass_tree"]
script = ExtResource("2_rule")
procedural_group = &"grass_tree"
placement_chance = 9000
adjacency_bonus = 1000
maximum_density = 10000
placement_chance = 8200
adjacency_bonus = 1200
maximum_density = 8500
minimum_placements = 2
placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3")
[sub_resource type="Resource" id="Rule_grass_detail"]
@ -26,6 +27,6 @@ script = ExtResource("1_definition")
stable_id = &"biome_forest"
label = "forest"
required_chunk_tags = PackedStringArray("grass")
forbidden_chunk_tags = PackedStringArray("spawn")
forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface")
region_weight = 1.4
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")])

View file

@ -6,10 +6,11 @@
[sub_resource type="Resource" id="Rule_grass_tree"]
script = ExtResource("2_rule")
procedural_group = &"grass_tree"
placement_chance = 9000
adjacency_bonus = 1000
maximum_density = 10000
placement_chance = 8200
adjacency_bonus = 1200
maximum_density = 8500
minimum_placements = 2
placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_pine")
[sub_resource type="Resource" id="Rule_grass_detail"]
@ -26,6 +27,6 @@ script = ExtResource("1_definition")
stable_id = &"biome_pine_forest"
label = "pine forest"
required_chunk_tags = PackedStringArray("grass")
forbidden_chunk_tags = PackedStringArray("spawn")
forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface")
region_weight = 1.2
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")])

View file

@ -24,5 +24,6 @@ script = ExtResource("1_definition")
stable_id = &"biome_plains"
label = "plains"
required_chunk_tags = PackedStringArray("grass")
forbidden_chunk_tags = PackedStringArray("mixed_surface")
region_weight = 1.0
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")])

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

View file

@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bm1teitx5ijxl"
path="res://.godot/imported/chunk_0008.glb-0b0a6712e5384c3f72c65e6ef1fc12c4.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0008.glb"
dest_files=["res://.godot/imported/chunk_0008.glb-0b0a6712e5384c3f72c65e6ef1fc12c4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj24j2d1u8712"
path.s3tc="res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "adfa66b8fe9da7f3450d3ddfd432b86a"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0008_sand.png"
dest_files=["res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.s3tc.ctex", "res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://hasempwso5gi"
path="res://.godot/imported/chunk_0009.glb-397f642bee3da14146c56fddf8e21369.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0009.glb"
dest_files=["res://.godot/imported/chunk_0009.glb-397f642bee3da14146c56fddf8e21369.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djjyaoih5vcnd"
path.s3tc="res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0009_grass_lite.png"
dest_files=["res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.s3tc.ctex", "res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Some files were not shown because too many files have changed in this diff Show more