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 - `port.json` uses schema version 4, names `netfishing.zip`, and marks the
self-contained package ready to run. 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 executable is `netfishing/NETfishing.aarch64`.
- The package declares AArch64, two analog sticks, GLIBC 2.28, and - The package declares AArch64, two analog sticks, GLIBC 2.28, and
`weston_pkg_0.2.squashfs`. `weston_pkg_0.2.squashfs`.
- The launcher starts GPTOKEYB with the explicit `netfishing.gptk` no-op map. - 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 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 - The launcher calls `pm_platform_helper` for the game executable after
starting GPTOKEYB and calls `pm_finish` after the game exits. starting GPTOKEYB and calls `pm_finish` after the game exits.
- New installations automatically create player data under - NETfishing automatically creates player data for new installations under
`ports/saves/netfishing/` and do not present the in-game folder picker. `ports/saves/netfishing/` and does not present the in-game folder picker.
- Existing installations with a selected data root keep that location when - Existing installations with a selected data root keep that location when
upgraded. Device-local data, configuration, and cache remain under upgraded. Device-local data, configuration, and cache remain under
`netfishing/conf/data`, `netfishing/conf/config`, and `netfishing/conf/data`, `netfishing/conf/config`, and
@ -103,15 +106,15 @@ Before upgrading an existing installation, preserve
device. After installation: device. After installation:
1. Confirm the installed executable and PCK hashes match the staged release. 1. Confirm the installed executable and PCK hashes match the staged release.
2. Confirm the installed launcher contains the canonical PortMaster header, 2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to
starts GPTOKEYB with `netfishing.gptk`, and calls the installed launcher, then confirm the launcher starts GPTOKEYB with
`pm_platform_helper` for the game executable. `netfishing.gptk` and calls `pm_platform_helper` for the game executable.
3. Confirm `conf/` and the external save directory were not replaced or 3. Confirm `conf/` and the external save directory were not replaced or
removed. removed.
4. Launch the installed port through the normal muOS menu. 4. Launch the installed port through the normal muOS menu.
5. Verify the displayed game version and controller face-button mapping. 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 6. Verify GPTOKEYB's device-specific force-quit chord exits the game. This is
most devices. Start+Select on most devices.
Installing the public PortMaster catalog entry can still install an older Installing the public PortMaster catalog entry can still install an older
catalog build until the upstream `netfishing.zip` is updated. A local release 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 reconstruction. Do not move the mapping back to either side of
`westonwrap.sh`. `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 Always pass `netfishing.gptk`, which explicitly unassigns every direct and
hotkey binding with GPTOKEYB's `\"` value. Do not add gameplay mappings to this hotkey binding with GPTOKEYB's `\"` value. This keeps GPTOKEYB from injecting
file. The Start+Select kill chord remains independent of the no-op bindings. 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 Do not call `Input.add_joy_mapping(..., true)` for a recognized connected muOS
controller. Updating this virtual controller after connection can stop Godot controller. Updating this virtual controller after connection can stop Godot

View file

@ -1,6 +1,6 @@
NETfishing NETfishing
v0.13.1-alpha v0.14.0-alpha
Alpha 0.13.1 Alpha 0.14.0
Thank you for trying this early private playtest. 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 BATTERIES_ID: StringName = &"batteries"
const CRAB_NET_ID: StringName = &"crab_net" const CRAB_NET_ID: StringName = &"crab_net"
const STANDARD_SHOVEL_ID: StringName = &"standard_shovel" const STANDARD_SHOVEL_ID: StringName = &"standard_shovel"
const FISHING_NET_ID: StringName = &"fishing_net"
const ITEM_PRICES: Dictionary[StringName, int] = { const ITEM_PRICES: Dictionary[StringName, int] = {
&"worms": 1, &"worms": 1,
@ -30,6 +31,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = {
BATTERIES_ID: 15, BATTERIES_ID: 15,
CRAB_NET_ID: 50, CRAB_NET_ID: 50,
STANDARD_SHOVEL_ID: 75, STANDARD_SHOVEL_ID: 75,
FISHING_NET_ID: 250,
} }
const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = {
&"snails": 400, &"snails": 400,
@ -52,6 +54,7 @@ const ITEM_ORDER: Array[StringName] = [
MAGNET_ID, MAGNET_ID,
CRAB_NET_ID, CRAB_NET_ID,
STANDARD_SHOVEL_ID, STANDARD_SHOVEL_ID,
FISHING_NET_ID,
&"coffee", &"coffee",
&"energy_drink", &"energy_drink",
&"snack", &"snack",
@ -120,6 +123,7 @@ static func is_permanent_unlock(
or item_id == MAGNET_ID or item_id == MAGNET_ID
or item_id == CRAB_NET_ID or item_id == CRAB_NET_ID
or item_id == STANDARD_SHOVEL_ID or item_id == STANDARD_SHOVEL_ID
or item_id == FISHING_NET_ID
or item is FishingRodDataType or item is FishingRodDataType
) )
) )

View file

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

View file

@ -36,6 +36,12 @@ enum LogbookSection {
SHELLFISH, SHELLFISH,
} }
enum CreatureGroup {
FISH,
INSECT,
SHELLFISH,
}
@export var id: StringName @export var id: StringName
@export var display_name: String @export var display_name: String
@export_category("Developer Catalog") @export_category("Developer Catalog")
@ -94,6 +100,14 @@ func is_fishable() -> bool:
return is_selectable() and collection_method == CollectionMethod.FISHING 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: func get_habitat_label() -> String:
if not habitat_label.strip_edges().is_empty(): if not habitat_label.strip_edges().is_empty():
return habitat_label.strip_edges().to_lower() 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_TYPICAL_PROBABILITY: float = 0.65
const BITE_LONG_PROBABILITY: float = 0.04 const BITE_LONG_PROBABILITY: float = 0.04
const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 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, 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_category("Selection")
@export_range(0.0, 10.0, 0.05) var undiscovered_weight_multiplier: float = 1.5 @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 _pending_catch: FishCatchType
var _showcase_ready: bool = false var _showcase_ready: bool = false
var _put_away_press_armed: 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_restore_generation: int = 0
var _showcase_outcome_completed: bool = false var _showcase_outcome_completed: bool = false
var _network_auto_click_accumulator: float = 0.0 var _network_auto_click_accumulator: float = 0.0
@ -444,6 +447,7 @@ func _secure_showcase_catch_for_recovery() -> void:
_pending_catch = null _pending_catch = null
_showcase_ready = false _showcase_ready = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false) showcase_changed.emit("", "", 0.0, 0, false)
if _active_player != null: if _active_player != null:
_active_player.end_catch_showcase(Callable(), true) _active_player.end_catch_showcase(Callable(), true)
@ -473,14 +477,21 @@ func _exit_tree() -> void:
_selected_fish = null _selected_fish = null
_showcase_ready = false _showcase_ready = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
func _process(delta: float) -> void: func _process(delta: float) -> void:
if state == FishingState.READY and not Input.is_action_pressed("fish_primary"): if state == FishingState.READY and not Input.is_action_pressed("fish_primary"):
_new_cast_press_armed = true _new_cast_press_armed = true
if state == FishingState.SHOWING_CATCH:
_showcase_input_lock_remaining = maxf(
_showcase_input_lock_remaining - delta,
0.0,
)
if ( if (
state == FishingState.SHOWING_CATCH state == FishingState.SHOWING_CATCH
and _showcase_ready and _showcase_ready
and _showcase_input_lock_remaining <= 0.0
and not Input.is_action_pressed("fish_primary") and not Input.is_action_pressed("fish_primary")
): ):
_put_away_press_armed = true _put_away_press_armed = true
@ -501,7 +512,7 @@ func _process(delta: float) -> void:
_get_effective_reel_speed(), _get_effective_reel_speed(),
_get_effective_barrier_damage() _get_effective_barrier_damage()
) )
if not Input.is_action_pressed("fish_primary"): if not _is_reel_input_pressed():
_catch_controller.set_reel_input(false) _catch_controller.set_reel_input(false)
else: else:
_resend_network_input_state(delta) _resend_network_input_state(delta)
@ -521,7 +532,11 @@ func _unhandled_input(event: InputEvent) -> void:
return return
if _local_player == null or not _local_player.is_local_control_enabled(): if _local_player == null or not _local_player.is_local_control_enabled():
return 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 return
var selected_item: ItemDataType = _get_active_item() var selected_item: ItemDataType = _get_active_item()
if ( if (
@ -582,7 +597,11 @@ func _unhandled_input(event: InputEvent) -> void:
FishingPresentationType.LineMode.TAUT FishingPresentationType.LineMode.TAUT
) )
FishingState.SHOWING_CATCH: 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() _put_away_catch()
else: else:
return return
@ -619,15 +638,23 @@ func _unhandled_input(event: InputEvent) -> void:
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif state == FishingState.FIGHTING: elif state == FishingState.FIGHTING:
var reel_input_held: bool = _is_reel_input_pressed()
if _network_fishing != null: if _network_fishing != null:
_network_primary_input_held = false _network_primary_input_held = reel_input_held
_network_input_resend_elapsed = 0.0 _network_input_resend_elapsed = 0.0
_network_fishing.submit_local_input(false, false) _network_fishing.submit_local_input(reel_input_held, false)
else: else:
_catch_controller.set_reel_input(false) _catch_controller.set_reel_input(reel_input_held)
get_viewport().set_input_as_handled() 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: func _begin_aiming(player: PlayerType) -> void:
if state != FishingState.READY or _active_player != null: if state != FishingState.READY or _active_player != null:
return return
@ -756,6 +783,17 @@ func is_returning() -> bool:
return state == FishingState.RETURNING 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: func refresh_active_item_status() -> void:
if state == FishingState.READY and has_active_fishing_rod(): if state == FishingState.READY and has_active_fishing_rod():
status_changed.emit("") status_changed.emit("")
@ -780,6 +818,7 @@ func present_external_catch(fish_catch: FishCatchType) -> bool:
_showcase_ready = true _showcase_ready = true
_showcase_outcome_completed = true _showcase_outcome_completed = true
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = minimum_showcase_duration
_active_player.begin_catch_showcase(fish_catch) _active_player.begin_catch_showcase(fish_catch)
showcase_changed.emit( showcase_changed.emit(
fish_catch.fish.display_name, fish_catch.fish.display_name,
@ -1112,7 +1151,7 @@ func _activate_bite(confirmation_override: bool = false) -> void:
_pending_catch.quality, _pending_catch.quality,
) )
_catch_controller.set_reel_input( _catch_controller.set_reel_input(
Input.is_action_pressed("fish_primary") _is_reel_input_pressed()
) )
_presentation.show_bite() _presentation.show_bite()
@ -1197,6 +1236,7 @@ func _on_catch_completed() -> void:
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
_catch_controller.reset() _catch_controller.reset()
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.play_outcome(&"catch") _presentation.play_outcome(&"catch")
@ -1232,6 +1272,7 @@ func _on_outcome_completed(outcome: StringName) -> void:
return return
_showcase_outcome_completed = true _showcase_outcome_completed = true
_showcase_ready = true _showcase_ready = true
_showcase_input_lock_remaining = minimum_showcase_duration
_active_player.begin_catch_showcase(_pending_catch) _active_player.begin_catch_showcase(_pending_catch)
showcase_changed.emit( showcase_changed.emit(
_pending_catch.fish.display_name, _pending_catch.fish.display_name,
@ -1265,6 +1306,7 @@ func _put_away_catch() -> void:
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false) showcase_changed.emit("", "", 0.0, 0, false)
_showcase_restore_generation += 1 _showcase_restore_generation += 1
var restore_generation: int = _showcase_restore_generation var restore_generation: int = _showcase_restore_generation
@ -1338,6 +1380,7 @@ func _cleanup_attempt(
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false) showcase_changed.emit("", "", 0.0, 0, false)
_catch_controller.reset() _catch_controller.reset()
state = FishingState.RETURNING state = FishingState.RETURNING
@ -1387,6 +1430,7 @@ func _finalize_attempt_cleanup(
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
showcase_changed.emit("", "", 0.0, 0, false) showcase_changed.emit("", "", 0.0, 0, false)
_catch_controller.reset() _catch_controller.reset()
_presentation.cleanup() _presentation.cleanup()
@ -1724,7 +1768,7 @@ func _on_network_bite_started(_attempt_id: String) -> void:
if _active_player != null: if _active_player != null:
_active_player.set_fighting_visual(true) _active_player.set_fighting_visual(true)
_withdrawal_input_held = false _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_input_resend_elapsed = 0.0
_network_auto_click_accumulator = 0.0 _network_auto_click_accumulator = 0.0
_network_active_barrier_index = -1 _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: func _update_network_auto_click(delta: float) -> void:
if ( if (
not _catch_controller.auto_click_enabled 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 or _network_active_barrier_index < 0
): ):
_network_auto_click_accumulator = 0.0 _network_auto_click_accumulator = 0.0
@ -1823,6 +1867,7 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void:
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
_showcase_input_lock_remaining = 0.0
catch_display_changed.emit( catch_display_changed.emit(
0.0, 0.0, PackedFloat32Array(), PackedInt32Array(), 0.0, 0.0, PackedFloat32Array(), PackedInt32Array(),
PackedInt32Array(), -1, false 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="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"] [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/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/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/standard_shovel.tres" id="40_shovel"]
[ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"]
[resource] [resource]
script = ExtResource("1_script") 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, REACH_LEVEL,
DISCOVER_SPECIES, DISCOVER_SPECIES,
MASTER_QUALITIES, MASTER_QUALITIES,
CATCH_CREATURE_GROUP,
DISCOVER_CREATURE_GROUP,
MASTER_CREATURE_GROUP,
} }
const DAILY_JOB_COUNT: int = 4 const DAILY_JOB_COUNT: int = 4
@ -111,6 +114,28 @@ static func generate_daily_jobs(
50, 50,
{"fish_id": String(selected.id)}, {"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) _shuffle(optional, rng)
while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty(): while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty():
jobs.append(optional.pop_back()) jobs.append(optional.pop_back())
@ -172,6 +197,8 @@ static func generate_weather_schedule(
static func lifetime_chains( static func lifetime_chains(
registered_species_count: int, registered_species_count: int,
registered_insect_count: int = 0,
registered_shellfish_count: int = 0,
) -> Array[Dictionary]: ) -> Array[Dictionary]:
var chains: Array[Dictionary] = [ var chains: Array[Dictionary] = [
_chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [ _chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [
@ -197,15 +224,33 @@ static func lifetime_chains(
[registered_species_count], [registered_species_count],
[[1500, 2000]], [[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 return chains
static func visible_lifetime_jobs( static func visible_lifetime_jobs(
registered_species_count: int, registered_species_count: int,
claimed_ids: Array[String], claimed_ids: Array[String],
registered_insect_count: int = 0,
registered_shellfish_count: int = 0,
) -> Array[Dictionary]: ) -> Array[Dictionary]:
var visible: 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 targets: Array = chain.get("targets", [])
var rewards: Array = chain.get("rewards", []) var rewards: Array = chain.get("rewards", [])
for tier_index: int in targets.size(): for tier_index: int in targets.size():
@ -216,6 +261,9 @@ static func visible_lifetime_jobs(
if job_id in claimed_ids: if job_id in claimed_ids:
continue continue
var reward: Array = rewards[tier_index] var reward: Array = rewards[tier_index]
var extra: Dictionary = {}
if chain.has("creature_group"):
extra["creature_group"] = int(chain["creature_group"])
visible.append(_job( visible.append(_job(
job_id, job_id,
_lifetime_title(str(chain.get("id", "")), int(targets[tier_index])), _lifetime_title(str(chain.get("id", "")), int(targets[tier_index])),
@ -226,6 +274,7 @@ static func visible_lifetime_jobs(
int(targets[tier_index]), int(targets[tier_index]),
int(reward[0]), int(reward[0]),
int(reward[1]), int(reward[1]),
extra,
)) ))
break break
return visible return visible
@ -256,10 +305,10 @@ static func is_valid_job(value: Variant) -> bool:
and typeof(job.get("description")) == TYPE_STRING and typeof(job.get("description")) == TYPE_STRING
and str(job["description"]).length() <= 160 and str(job["description"]).length() <= 160
and is_bounded_integer( 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.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("target"), 1, 1000000000)
and is_bounded_integer( and is_bounded_integer(
job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS 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 not str(job.get("fish_id", "")).is_empty()
and str(job.get("fish_id", "")).length() <= 96 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 return true
@ -401,13 +456,16 @@ static func _chain(
kind: Kind, kind: Kind,
targets: Array[int], targets: Array[int],
rewards: Array, rewards: Array,
extra: Dictionary = {},
) -> Dictionary: ) -> Dictionary:
return { var result: Dictionary = {
"id": id, "id": id,
"kind": int(kind), "kind": int(kind),
"targets": targets, "targets": targets,
"rewards": rewards, "rewards": rewards,
} }
result.merge(extra, true)
return result
static func _lifetime_title(chain_id: String, target: int) -> String: 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" return "complete the catalog"
"master": "master":
return "quality 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" 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 return "discover all %d cataloged fish" % target
"master": "master":
return "collect every quality of all %d fish" % target 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" 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: static func _shuffle(values: Array[Dictionary], rng: RandomNumberGenerator) -> void:
for index: int in range(values.size() - 1, 0, -1): for index: int in range(values.size() - 1, 0, -1):
var swap_index: int = rng.randi_range(0, index) var swap_index: int = rng.randi_range(0, index)

View file

@ -25,6 +25,7 @@ var _session: NetworkSession
var _save_manager: PlayerSaveManager var _save_manager: PlayerSaveManager
var _network_fishing: NetworkFishingService var _network_fishing: NetworkFishingService
var _network_sale: NetworkSaleService var _network_sale: NetworkSaleService
var _network_world_spawns: NetworkWorldSpawnService
var _progression_ready: bool = false var _progression_ready: bool = false
var _host_board: Dictionary = {} var _host_board: Dictionary = {}
@ -35,6 +36,8 @@ var _daily_completions: Dictionary[String, int] = {}
var _pending_rewards: Array[Dictionary] = [] var _pending_rewards: Array[Dictionary] = []
var _lifetime_claimed: Array[String] = [] var _lifetime_claimed: Array[String] = []
var _total_catches: int = 0 var _total_catches: int = 0
var _total_insects: int = 0
var _total_shellfish: int = 0
var _total_sold: int = 0 var _total_sold: int = 0
var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR
@ -65,9 +68,11 @@ func setup(
func bind_authoritative_services( func bind_authoritative_services(
network_fishing: NetworkFishingService, network_fishing: NetworkFishingService,
network_sale: NetworkSaleService, network_sale: NetworkSaleService,
network_world_spawns: NetworkWorldSpawnService,
) -> void: ) -> void:
_network_fishing = network_fishing _network_fishing = network_fishing
_network_sale = network_sale _network_sale = network_sale
_network_world_spawns = network_world_spawns
if not _network_fishing.local_catch_received.is_connected( if not _network_fishing.local_catch_received.is_connected(
_on_authoritative_catch _on_authoritative_catch
): ):
@ -78,6 +83,12 @@ func bind_authoritative_services(
_network_sale.local_sale_finished.connect( _network_sale.local_sale_finished.connect(
_on_authoritative_sale_finished _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: func set_save_manager(save_manager: PlayerSaveManager) -> void:
@ -123,7 +134,10 @@ func get_daily_jobs() -> Array[Dictionary]:
func get_lifetime_jobs() -> Array[Dictionary]: func get_lifetime_jobs() -> Array[Dictionary]:
var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs( 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: for job: Dictionary in result:
var progress: int = _lifetime_progress(job) var progress: int = _lifetime_progress(job)
@ -236,6 +250,8 @@ func to_save_data() -> Dictionary:
"lifetime_claimed": _lifetime_claimed.duplicate(), "lifetime_claimed": _lifetime_claimed.duplicate(),
"statistics": { "statistics": {
"fish_caught": _total_catches, "fish_caught": _total_catches,
"insects_caught": _total_insects,
"shellfish_caught": _total_shellfish,
"fish_sold": _total_sold, "fish_sold": _total_sold,
}, },
} }
@ -294,6 +310,8 @@ func restore_from_save_data(data: Dictionary) -> bool:
_lifetime_claimed.append(str(value)) _lifetime_claimed.append(str(value))
var statistics: Dictionary = data.get("statistics", {}) var statistics: Dictionary = data.get("statistics", {})
_total_catches = int(statistics.get("fish_caught", 0)) _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)) _total_sold = int(statistics.get("fish_sold", 0))
if _session != null and _session.is_host(): if _session != null and _session.is_host():
_active_board = _host_board.duplicate(true) _active_board = _host_board.duplicate(true)
@ -318,6 +336,8 @@ func reset_to_defaults() -> void:
_pending_rewards.clear() _pending_rewards.clear()
_lifetime_claimed.clear() _lifetime_claimed.clear()
_total_catches = 0 _total_catches = 0
_total_insects = 0
_total_shellfish = 0
_total_sold = 0 _total_sold = 0
_daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR _daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR
changed.emit() changed.emit()
@ -333,7 +353,12 @@ static func default_save_data() -> Dictionary:
"daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR, "daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR,
"pending_rewards": [], "pending_rewards": [],
"lifetime_claimed": [], "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( JobCatalog.is_bounded_integer(
statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE 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( and JobCatalog.is_bounded_integer(
statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE
) )
@ -514,7 +545,7 @@ func _generate_host_board(cycle: int) -> void:
var candidates: Array[FishDataType] = [] var candidates: Array[FishDataType] = []
if _catalog != null: if _catalog != null:
for fish: FishDataType in _catalog.candidates: for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_fishable(): if fish != null and fish.is_selectable():
candidates.append(fish) candidates.append(fish)
var schedule_anchor_index: int = _current_weather_segment() var schedule_anchor_index: int = _current_weather_segment()
var allow_weather_jobs: bool = ( 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: func _on_authoritative_catch(fish_catch: FishCatchType) -> void:
if not _progression_ready or fish_catch == null or not fish_catch.is_valid(): if not _progression_ready or fish_catch == null or not fish_catch.is_valid():
return 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) _update_daily_for_catch(fish_catch)
changed.emit() changed.emit()
@ -630,23 +667,40 @@ func _on_authoritative_sale_finished(
func _update_daily_for_catch(fish_catch: FishCatchType) -> void: 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(): for job: Dictionary in get_daily_jobs():
var matches: bool = false var matches: bool = false
match int(job.get("kind", -1)): match int(job.get("kind", -1)):
JobCatalog.Kind.CATCH_TOTAL: JobCatalog.Kind.CATCH_TOTAL:
matches = true matches = is_fish
JobCatalog.Kind.CATCH_WATER: 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 int(job.get("water_type", WaterType.Type.OTHER)) as WaterType.Type
) )
JobCatalog.Kind.CATCH_QUALITY: 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: 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: 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: 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: if matches:
_advance_daily(job, 1) _advance_daily(job, 1)
@ -741,26 +795,74 @@ func _lifetime_progress(job: Dictionary) -> int:
JobCatalog.Kind.REACH_LEVEL: JobCatalog.Kind.REACH_LEVEL:
return _experience.get_level() if _experience != null else 0 return _experience.get_level() if _experience != null else 0
JobCatalog.Kind.DISCOVER_SPECIES: 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: JobCatalog.Kind.MASTER_QUALITIES:
var mastered: int = 0 return _collection_progress(
if _collection != null and _catalog != null: FishDataType.CreatureGroup.FISH, true
for fish: FishDataType in _catalog.candidates: )
if fish != null and _collection.has_mastered(fish.id): JobCatalog.Kind.CATCH_CREATURE_GROUP:
mastered += 1 match int(job.get("creature_group", -1)):
return mastered 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 return 0
func _registered_species_count() -> int: 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 var count: int = 0
if _catalog != null: if _catalog != null:
for fish: FishDataType in _catalog.candidates: 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 count += 1
return count 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: func _expire_incomplete_daily_jobs() -> void:
_daily_progress.clear() _daily_progress.clear()
_daily_completions.clear() _daily_completions.clear()
@ -795,7 +897,7 @@ func _matches_canonical_board(board: Dictionary) -> bool:
var candidates: Array[FishDataType] = [] var candidates: Array[FishDataType] = []
if _catalog != null: if _catalog != null:
for fish: FishDataType in _catalog.candidates: for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_fishable(): if fish != null and fish.is_selectable():
candidates.append(fish) candidates.append(fish)
var anchor_index: int = int(board.get("schedule_anchor_index", -1)) var anchor_index: int = int(board.get("schedule_anchor_index", -1))
var allow_weather_jobs: bool = ( 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 GameUIType = preload("res://ui/game_ui.gd")
const PlayerType = preload("res://player/player.gd") const PlayerType = preload("res://player/player.gd")
const TestWorldType = preload("res://world/test_world.gd") const TestWorldType = preload("res://world/test_world.gd")
const WorldLayoutType = preload("res://world/world_layout.gd")
const WaterRecoveryControllerType = preload( const WaterRecoveryControllerType = preload(
"res://world/water_recovery_controller.gd" "res://world/water_recovery_controller.gd"
) )
@ -124,6 +125,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
const TITLE_MUSIC_PATH: String = ( const TITLE_MUSIC_PATH: String = (
"res://audio/music/title/as_in_four_wolves.ogg" "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 DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3"
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001 const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
const PLAYER_MENU_PATTERN_SCALE: float = 0.85 const PLAYER_MENU_PATTERN_SCALE: float = 0.85
@ -138,6 +140,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
@export_category("Title Music") @export_category("Title Music")
@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0 @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(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 _test_world: TestWorldType = $TestWorld
@onready var _player: PlayerType = %Player @onready var _player: PlayerType = %Player
@ -154,6 +157,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
) )
@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController @onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
@onready var _title_music: AudioStreamPlayer = %TitleMusic @onready var _title_music: AudioStreamPlayer = %TitleMusic
@onready var _new_game_music: AudioStreamPlayer = %NewGameMusic
@onready var _dusk_music: AudioStreamPlayer = %DuskMusic @onready var _dusk_music: AudioStreamPlayer = %DuskMusic
@onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation @onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation
@onready var _pixelation_reset: PixelationResetOverlayType = ( @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: func _configure_audio_performance_profile(light_profile: bool) -> void:
_title_music.stream = _load_optional_audio_stream(TITLE_MUSIC_PATH) _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) _dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH)
if light_profile: if light_profile:
_shoreline_ambience.set_audio_enabled(false) _shoreline_ambience.set_audio_enabled(false)
@ -409,10 +414,25 @@ func _start_dedicated_server() -> void:
): ):
_fail_dedicated_server("The server operator list is invalid.") _fail_dedicated_server("The server operator list is invalid.")
return 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.") _fail_dedicated_server("The configured world seed could not be generated.")
return 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.") _fail_dedicated_server("The configured world seed is invalid.")
return return
_configure_portable_stores() _configure_portable_stores()
@ -636,6 +656,7 @@ func _initialize_application(dedicated: bool) -> void:
_world_time, _world_time,
_world_weather, _world_weather,
_player_jobs, _player_jobs,
_player,
) )
_player_jobs.set_save_manager(_save_manager) _player_jobs.set_save_manager(_save_manager)
_save_manager.set_autosave_enabled(false) _save_manager.set_autosave_enabled(false)
@ -747,7 +768,11 @@ func _initialize_application(dedicated: bool) -> void:
_asset_reservations, _asset_reservations,
_player.inventory_layout, _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_shop.setup(
_network_session, _network_session,
_player_spawn_service, _player_spawn_service,
@ -824,6 +849,7 @@ func _initialize_application(dedicated: bool) -> void:
) )
_game_ui.setup_data_and_identity( _game_ui.setup_data_and_identity(
_data_root, _data_root,
_save_manager,
_identity_backups, _identity_backups,
_player_identity, _player_identity,
_host_identity, _host_identity,
@ -1571,7 +1597,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void:
) )
): ):
_dusk_music_played_for_natural_day = true _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) _dusk_music.play(0.0)
@ -1672,26 +1701,34 @@ func _resize_native_overlays() -> void:
_shop_backdrop.size = Vector2(get_window().size) _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: if _gameplay_started or _quit_in_progress:
return 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( _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 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( _game_ui.get_title_screen().report_network_error(
"Could not prepare the generated world for hosting." "Could not prepare that world for hosting."
) )
return return
if not _prepare_private_host(): if not _prepare_private_host():
_show_title_music(true)
return return
if ( if (
not _save_manager.delete_progression_save() 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.") _network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error( _game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible." "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." "Failed to load save. The original was preserved."
) )
return return
var world_layout: StringName = _save_manager.get_world_layout()
var world_seed: int = _save_manager.get_world_seed() var world_seed: int = _save_manager.get_world_seed()
if ( if (
not _apply_generated_world(world_seed, true) not _apply_world(world_layout, world_seed, true)
or not _prepare_host_world_seed(world_seed) or not _prepare_host_world(world_layout, world_seed)
): ):
_game_ui.get_title_screen().report_network_error( _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 return
if not _prepare_private_host(): if not _prepare_private_host():
@ -1740,13 +1778,16 @@ func _prepare_private_host() -> bool:
return true 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 [ if _network_session.state in [
NetworkSessionType.State.CONNECTION_FAILED, NetworkSessionType.State.CONNECTION_FAILED,
NetworkSessionType.State.SERVER_LOST, NetworkSessionType.State.SERVER_LOST,
]: ]:
_network_session.reset_failure() _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: func _enter_gameplay() -> void:
@ -1950,11 +1991,14 @@ func _on_reset_progress_requested() -> void:
pause_menu.report_reset_failure() pause_menu.report_reset_failure()
return return
var world_seed: int = PlayerSaveManager.roll_world_seed() 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() pause_menu.report_reset_failure()
return return
_network_session.disconnect_session("Progress reset.") _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() pause_menu.report_reset_failure()
return return
pause_menu.close_for_title_transition() pause_menu.close_for_title_transition()
@ -1964,8 +2008,18 @@ func _on_reset_progress_requested() -> void:
func _apply_joined_world(server_metadata: Dictionary) -> bool: 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)) 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 return true
_network_session.disconnect_session("World generation failed.") _network_session.disconnect_session("World generation failed.")
_join_requested_from_title = false _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() var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.reopen_to_menu() title_screen.reopen_to_menu()
title_screen.report_network_error( 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) _show_title_music(true)
return false return false
func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool: func _apply_world(
if not _test_world.generate_world(world_seed): world_layout: StringName,
world_seed: int,
reposition_player: bool,
) -> bool:
if not _test_world.activate_world(world_layout, world_seed):
return false return false
var spawn_transform: Transform3D = _test_world.get_player_spawn_transform() var spawn_transform: Transform3D = _test_world.get_player_spawn_transform()
_player_spawn_service.set_spawn_transform(spawn_transform) _player_spawn_service.set_spawn_transform(spawn_transform)
if reposition_player: if reposition_player:
_player.global_transform = spawn_transform _player.global_transform = spawn_transform
_player.velocity = Vector3.ZERO _player.velocity = Vector3.ZERO
if _application_initialized:
_refresh_active_world_bindings()
if _application_initialized and not _dedicated_runtime: if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context( _water_recovery.update_world_context(
spawn_transform, spawn_transform,
@ -2001,6 +2061,51 @@ func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool:
return true 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: func _on_water_recovery_starting() -> void:
_local_recovery_attempt_id = ( _local_recovery_attempt_id = (
"recovery:%s" "recovery:%s"
@ -2120,6 +2225,7 @@ func _on_quit_requested() -> void:
func _show_title_music(restart_from_beginning: bool = false) -> void: func _show_title_music(restart_from_beginning: bool = false) -> void:
_new_game_music.stop()
_title_music_requested = true _title_music_requested = true
_replace_title_music_transition() _replace_title_music_transition()
if _title_music.stream == null: if _title_music.stream == null:
@ -2131,6 +2237,19 @@ func _show_title_music(restart_from_beginning: bool = false) -> void:
_title_music.play(0.0) _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: func _fade_out_title_music(on_complete: Callable = Callable()) -> void:
_title_music_requested = false _title_music_requested = false
var generation: int = _replace_title_music_transition() var generation: int = _replace_title_music_transition()
@ -2196,6 +2315,9 @@ func _exit_tree() -> void:
if is_instance_valid(_title_music): if is_instance_valid(_title_music):
_title_music.stop() _title_music.stop()
_title_music.stream = null _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): if is_instance_valid(_dusk_music):
_dusk_music.stop() _dusk_music.stop()
_dusk_music.stream = null _dusk_music.stream = null

View file

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

View file

@ -9,6 +9,13 @@ const MAX_HISTORY: int = 100
const LATE_JOIN_HISTORY: int = 50 const LATE_JOIN_HISTORY: int = 50
const MAX_ID_LENGTH: int = 96 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 } enum Kind { PLAYER, SYSTEM }
@ -16,8 +23,13 @@ static func sanitize_body(value: Variant) -> String:
if typeof(value) != TYPE_STRING: if typeof(value) != TYPE_STRING:
return "" return ""
var result: String = str(value).replace("\r", " ").replace("\n", " ") var result: String = str(value).replace("\r", " ").replace("\n", " ")
result = result.replace("\t", " ").strip_edges() result = result.replace("\t", " ")
if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS: 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 "" return ""
if result.to_utf8_buffer().size() > MAX_UTF8_BYTES: if result.to_utf8_buffer().size() > MAX_UTF8_BYTES:
return "" return ""

View file

@ -4,7 +4,10 @@ extends Node
const BURST_COUNT: int = 3 const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5 const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0 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] = [ const CALL_PITCH_VARIANTS: Array[float] = [
0.96, 0.96,
1.03, 1.03,
@ -38,6 +41,32 @@ var _call_variant_indices: Dictionary[int, int] = {}
var _sequence: int = 0 var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {} var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore 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: 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 request_id: String = data["request_id"]
var ledger: Dictionary = _request_ledgers.get(peer_id, {}) var ledger: Dictionary = _request_ledgers.get(peer_id, {})
if ledger.has(request_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 return
if not _consume_rate(peer_id): if not _consume_rate(peer_id):
_send_rejection(peer_id, "Slow down.") _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["request_id"] = request_id
message["sender_fingerprint"] = data["sender_fingerprint"] message["sender_fingerprint"] = data["sender_fingerprint"]
message["sender_signature"] = data["sender_signature"] 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: while ledger.size() > 64:
ledger.erase(ledger.keys().front()) ledger.erase(ledger.keys().front())
_request_ledgers[peer_id] = ledger _request_ledgers[peer_id] = ledger
@ -404,9 +438,12 @@ func _apply_message(
kind == NetworkChatProtocol.Kind.PLAYER kind == NetworkChatProtocol.Kind.PLAYER
and is_sender_filtered(str(data.get("sender_fingerprint", ""))) and is_sender_filtered(str(data.get("sender_fingerprint", "")))
) )
_history.append(stored_message) if _should_store_local_history():
while _history.size() > NetworkChatProtocol.MAX_HISTORY: _history.append(stored_message)
_history.pop_front() 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): if emit_live_signals and _message_is_visible(stored_message):
message_received.emit(stored_message.duplicate(true)) message_received.emit(stored_message.duplicate(true))
if ( if (
@ -433,8 +470,26 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
if not _session.is_host(): if not _session.is_host():
return return
_peer_names[peer_id] = display_name _peer_names[peer_id] = display_name
var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY) var history: Array[Dictionary] = []
receive_chat_history.rpc_id(peer_id, _history.slice(start)) 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( _broadcast(_make_message(
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name
)) ))
@ -494,6 +549,44 @@ func _consume_rate(peer_id: int) -> bool:
return true 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: func _on_session_state_changed(state: NetworkSession.State) -> void:
if state in [ if state in [
NetworkSession.State.INACTIVE, NetworkSession.State.INACTIVE,

View file

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

View file

@ -91,6 +91,10 @@ func setup(
_session.state_changed.connect(_on_session_state_changed) _session.state_changed.connect(_on_session_state_changed)
func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_shop_interaction = interaction
func is_local_sale_pending() -> bool: func is_local_sale_pending() -> bool:
return not _pending_local_request_id.is_empty() 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_display_name: String = ""
var _last_server_protocol_version: int = 0 var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _last_server_world_layout: StringName = WorldLayout.GENERATED
var _server_capabilities: PackedStringArray = PackedStringArray() var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore var _player_identity: PlayerIdentityStore
@ -113,6 +114,7 @@ var _session_operator_fingerprints: Dictionary[String, bool] = {}
var _operator_peer_ids: Dictionary[int, bool] = {} var _operator_peer_ids: Dictionary[int, bool] = {}
var _local_operator: bool = false var _local_operator: bool = false
var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _host_world_layout: StringName = WorldLayout.GENERATED
func _ready() -> void: func _ready() -> void:
@ -275,6 +277,7 @@ func _register_player_host() -> void:
NetworkProtocol.WORLD_SPAWN_CAPABILITY, NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY, NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
]), ]),
) )
_registry.update_appearance(1, _local_appearance_snapshot) _registry.update_appearance(1, _local_appearance_snapshot)
@ -531,20 +534,35 @@ func get_last_server_metadata() -> Dictionary:
"player_count": _last_server_player_count, "player_count": _last_server_player_count,
"max_players": _last_server_max_players, "max_players": _last_server_max_players,
"world_seed": _last_server_world_seed, "world_seed": _last_server_world_seed,
"world_layout": String(_last_server_world_layout),
} }
func set_host_world_seed(seed: int) -> bool: 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 ( if (
state != State.INACTIVE state != State.INACTIVE
or not WorldLayout.is_valid(world_layout)
or seed <= 0 or seed <= 0
or seed > NetworkProtocol.MAX_WORLD_SEED or seed > NetworkProtocol.MAX_WORLD_SEED
): ):
return false return false
_host_world_layout = world_layout
_host_world_seed = seed _host_world_seed = seed
return true 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: func get_authoritative_world_seed() -> int:
return _last_server_world_seed if is_joined_client() else _host_world_seed 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.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",
@ -1190,6 +1209,15 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
) )
return 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, {}) var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
if identity.is_empty(): if identity.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF) _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
@ -1278,6 +1306,7 @@ func submit_client_hello(data: Dictionary) -> void:
session_max_players, session_max_players,
_session_display_name, _session_display_name,
_host_world_seed, _host_world_seed,
_host_world_layout,
) )
) )
receive_spawn_list.rpc_id(sender_id, _build_spawn_list()) 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("game_version")) != TYPE_STRING
or typeof(data.get("rejection_code")) != TYPE_INT or typeof(data.get("rejection_code")) != TYPE_INT
or typeof(data.get("world_seed")) != 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() _teardown_peer()
_fail("The server sent an invalid handshake response.") _fail("The server sent an invalid handshake response.")
@ -1362,6 +1394,14 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer() _teardown_peer()
_fail("The server sent an invalid world seed.") _fail("The server sent an invalid world seed.")
return 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", "")) _session_id = str(data.get("session_id", ""))
_last_server_max_players = int(data.get( _last_server_max_players = int(data.get(
"max_players", "max_players",
@ -1373,6 +1413,7 @@ func receive_server_hello(data: Dictionary) -> void:
"protocol_version", NetworkProtocol.PROTOCOL_VERSION "protocol_version", NetworkProtocol.PROTOCOL_VERSION
)) ))
_last_server_world_seed = received_world_seed _last_server_world_seed = received_world_seed
_last_server_world_layout = received_world_layout
_server_capabilities = PackedStringArray() _server_capabilities = PackedStringArray()
var advertised_capabilities: Variant = data.get( var advertised_capabilities: Variant = data.get(
"capability_flags", PackedStringArray() "capability_flags", PackedStringArray()
@ -1391,6 +1432,13 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer() _teardown_peer()
_fail("This server does not support generated worlds.") _fail("This server does not support generated worlds.")
return 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() var local_peer_id: int = multiplayer.get_unique_id()
_registry.clear() _registry.clear()
_registry.add_peer( _registry.add_peer(
@ -1407,6 +1455,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY, NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
]), ]),
) )
_registry.update_appearance(local_peer_id, _local_appearance_snapshot) _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) _session.state_changed.connect(_on_session_state_changed)
func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_interaction = interaction
func can_request_purchase() -> bool: func can_request_purchase() -> bool:
return ( return (
_session != null _session != null

View file

@ -188,7 +188,7 @@ func activate_process_root(path: String) -> bool:
func path_for(store_owner: StringName) -> String: func path_for(store_owner: StringName) -> String:
var relative: String = { var relative: String = {
&"player_save": "player/player_save.json", &"player_save": "player/player_save.nfsave",
&"network_profile": "player/network_profile.json", &"network_profile": "player/network_profile.json",
&"player_appearance": "player/player_appearance.json", &"player_appearance": "player/player_appearance.json",
&"saved_servers": "social/saved_servers.json", &"saved_servers": "social/saved_servers.json",
@ -208,6 +208,10 @@ func identity_backup_directory() -> String:
return root_path.path_join("identity-backups") return root_path.path_join("identity-backups")
func progression_backup_directory() -> String:
return root_path.path_join("progression-backups")
func migration_backup_directory() -> String: func migration_backup_directory() -> String:
return root_path.path_join("backups/migrations") 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: func _create_layout(path: String, id: String) -> bool:
for relative: String in [ for relative: String in [
"player", "social", "backups/saves", "backups/migrations", "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: if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
return false 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" + "This folder is safe to synchronize with tools such as Syncthing.\n"
+ "Active private identity keys remain device-local.\n" + "Active private identity keys remain device-local.\n"
+ "Encrypted identity backups require their passphrase.\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" + "Chat and Session Mail are not stored here.\n"
+ "Do not play the same profile on two devices at the same time.\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" + "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) _remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))} return {"ok": false, "message": str(created.get("message", ""))}
for relative: String in [ for relative: String in [
"player/player_save.nfsave",
"player/player_save.json", "player/player_save.json",
"player/network_profile.json", "player/network_profile.json",
"player/player_appearance.json", "player/player_appearance.json",
@ -134,7 +135,11 @@ static func migrate_active_to(
var source: String = data_root.root_path.path_join(relative) var source: String = data_root.root_path.path_join(relative)
if not FileAccess.file_exists(source): if not FileAccess.file_exists(source):
continue 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) _remove_tree(staging)
return {"ok": false, "message": "Migration validation failed for %s." % relative} return {"ok": false, "message": "Migration validation failed for %s." % relative}
if DirAccess.dir_exists_absolute(normalized): 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: static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
if filename == "player_save.json": if filename == "player_save.json":
var version: int = int(data.get("save_version", -1)) var version: int = int(data.get("save_version", -1))

View file

@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="NETFISHING" config/name="NETFISHING"
config/version="0.13.1-alpha" config/version="0.14.0-alpha"
run/main_scene="res://main/main.tscn" run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://art/exported/system_icons/netfishing_256.png" 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) , 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={ camera_drag={
"deadzone": 0.2, "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) "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( const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd" "res://world/world_weather_service.gd"
) )
const WorldLayoutType = preload("res://world/world_layout.gd")
const PlayerJobServiceType = preload("res://jobs/player_job_service.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 BASIC_ROD_ID: StringName = &"basic_fishing_rod"
const MAX_SAFE_BALANCE: int = 1000000000000 const MAX_SAFE_BALANCE: int = 1000000000000
const DEFAULT_WORLD_SEED: int = 13001 const DEFAULT_WORLD_SEED: int = 13001
@ -49,6 +53,8 @@ class LoadSnapshot:
var next_catch_sequence: int = 1 var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = [] var bag_items: Array[OwnedItemType] = []
var unlocked_bait_ids: Array[StringName] = [] var unlocked_bait_ids: Array[StringName] = []
var active_bait_id: StringName
var active_lure_id: StringName
var hotbar_slots: Array[StringName] = [] var hotbar_slots: Array[StringName] = []
var fish_hotbar_slots: Array[StringName] = [] var fish_hotbar_slots: Array[StringName] = []
var selected_hotbar_slot: int = 0 var selected_hotbar_slot: int = 0
@ -58,6 +64,7 @@ class LoadSnapshot:
var cooler_capacity_level: int = 0 var cooler_capacity_level: int = 0
var art_unlock_mask: int = 0 var art_unlock_mask: int = 0
var total_experience: int = 0 var total_experience: int = 0
var world_layout: StringName = WorldLayoutType.GENERATED
var world_seed: int = DEFAULT_WORLD_SEED var world_seed: int = DEFAULT_WORLD_SEED
var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR
var has_world_weather_state: bool = false var has_world_weather_state: bool = false
@ -87,6 +94,7 @@ var _experience: PlayerExperienceType
var _world_time: WorldTimeServiceType var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType var _jobs: PlayerJobServiceType
var _player: PlayerType
var _autosave_timer: Timer var _autosave_timer: Timer
var _is_configured: bool = false var _is_configured: bool = false
var _is_restoring: bool = false var _is_restoring: bool = false
@ -96,6 +104,7 @@ var _autosave_enabled: bool = false
var _save_path := "" var _save_path := ""
var _expected_hash := "" var _expected_hash := ""
var _data_root: PlayerDataRoot var _data_root: PlayerDataRoot
var _world_layout: StringName = WorldLayoutType.GENERATED
var _world_seed: int = DEFAULT_WORLD_SEED var _world_seed: int = DEFAULT_WORLD_SEED
@ -112,6 +121,21 @@ func _backup_path() -> String:
return _save_path + ".backup" 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: func _ready() -> void:
_autosave_timer = Timer.new() _autosave_timer = Timer.new()
_autosave_timer.one_shot = true _autosave_timer.one_shot = true
@ -135,6 +159,7 @@ func setup(
world_time: WorldTimeServiceType, world_time: WorldTimeServiceType,
world_weather: WorldWeatherServiceType, world_weather: WorldWeatherServiceType,
jobs: PlayerJobServiceType, jobs: PlayerJobServiceType,
player: PlayerType,
) -> void: ) -> void:
_inventory = inventory _inventory = inventory
_collection_log = collection_log _collection_log = collection_log
@ -151,6 +176,7 @@ func setup(
_world_time = world_time _world_time = world_time
_world_weather = world_weather _world_weather = world_weather
_jobs = jobs _jobs = jobs
_player = player
_is_configured = ( _is_configured = (
_inventory != null _inventory != null
and _collection_log != null and _collection_log != null
@ -167,6 +193,7 @@ func setup(
and _world_time != null and _world_time != null
and _world_weather != null and _world_weather != null
and _jobs != null and _jobs != null
and _player != null
) )
if not _is_configured: if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.") push_error("PlayerSaveManager setup is missing required references.")
@ -209,6 +236,10 @@ func setup(
_experience.experience_changed.connect(_on_experience_changed) _experience.experience_changed.connect(_on_experience_changed)
if not _jobs.changed.is_connected(_mark_dirty): if not _jobs.changed.is_connected(_mark_dirty):
_jobs.changed.connect(_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: func load_player_data() -> bool:
@ -216,24 +247,22 @@ func load_player_data() -> bool:
return false return false
_automatic_saving_blocked = false _automatic_saving_blocked = false
_recover_interrupted_write() _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 _is_dirty = false
return true return true
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
var save_file := FileAccess.open(_save_path, FileAccess.READ) read_path,
if save_file == null: read_path == _legacy_save_path(),
_handle_corrupt_save("Unable to open player save.") )
if not bool(decoded.get("ok", false)):
_handle_corrupt_save("Player save could not be decoded.", read_path)
return false return false
var json_text: String = save_file.get_as_text() _expected_hash = (
save_file.close() PortableFileGuard.hash_file(_save_path)
_expected_hash = PortableFileGuard.hash_file(_save_path) if read_path == _save_path else ""
)
var json := JSON.new() var save_data: Dictionary = decoded["data"]
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
var version: int = _read_integer(save_data.get("save_version"), -1) var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION: if version > SAVE_VERSION:
_automatic_saving_blocked = true _automatic_saving_blocked = true
@ -250,13 +279,16 @@ func load_player_data() -> bool:
save_data = _migrate_save(save_data, version) save_data = _migrate_save(save_data, version)
if save_data.is_empty(): if save_data.is_empty():
_handle_corrupt_save( _handle_corrupt_save(
"Player save version %d is unsupported." % version "Player save version %d is unsupported." % version,
read_path,
) )
return false return false
var snapshot: LoadSnapshot = _build_load_snapshot(save_data) var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null: if snapshot == null:
_handle_corrupt_save("Player save failed structural validation.") _handle_corrupt_save(
"Player save failed structural validation.", read_path
)
return false return false
_is_restoring = true _is_restoring = true
@ -277,6 +309,7 @@ func load_player_data() -> bool:
_bag.replace_all_items(snapshot.bag_items) _bag.replace_all_items(snapshot.bag_items)
and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids) 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( var upgrades_restored: bool = _fishing_upgrades.restore_levels(
snapshot.reel_speed_level, snapshot.reel_speed_level,
snapshot.barrier_power_level snapshot.barrier_power_level
@ -302,6 +335,7 @@ func load_player_data() -> bool:
var world_time_restored: bool = ( var world_time_restored: bool = (
_world_time.restore_persistent_time_hours(snapshot.world_time_hours) _world_time.restore_persistent_time_hours(snapshot.world_time_hours)
) )
_world_layout = snapshot.world_layout
_world_seed = snapshot.world_seed _world_seed = snapshot.world_seed
var world_weather_restored: bool = true var world_weather_restored: bool = true
if snapshot.has_world_weather_state: if snapshot.has_world_weather_state:
@ -316,6 +350,7 @@ func load_player_data() -> bool:
or not collection_restored or not collection_restored
or not wallet_restored or not wallet_restored
or not bag_restored or not bag_restored
or not tackle_restored
or not hotbar_restored or not hotbar_restored
or not upgrades_restored or not upgrades_restored
or not cooler_restored or not cooler_restored
@ -329,7 +364,7 @@ func load_player_data() -> bool:
push_error( push_error(
( (
"Validated player save could not be restored: " "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 " + "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s" + "time=%s weather=%s jobs=%s"
) )
@ -338,6 +373,7 @@ func load_player_data() -> bool:
collection_restored, collection_restored,
wallet_restored, wallet_restored,
bag_restored, bag_restored,
tackle_restored,
hotbar_restored, hotbar_restored,
upgrades_restored, upgrades_restored,
cooler_restored, cooler_restored,
@ -352,6 +388,8 @@ func load_player_data() -> bool:
return false return false
_is_dirty = false _is_dirty = false
if read_path == _legacy_save_path():
_migrate_legacy_plaintext_save(save_data, read_path)
print( print(
"Loaded player save version %d with %d catches." "Loaded player save version %d with %d catches."
% [SAVE_VERSION, snapshot.catches.size()] % [SAVE_VERSION, snapshot.catches.size()]
@ -362,24 +400,21 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType: func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new() var result := SaveInspectionType.new()
_recover_interrupted_write() _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: if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING result.status = SaveInspectionType.Status.MISSING
result.message = "no save found." result.message = "no save found."
return result return result
var save_file := FileAccess.open(_save_path, FileAccess.READ) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
if save_file == null: read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
result.status = SaveInspectionType.Status.IO_ERROR result.status = SaveInspectionType.Status.IO_ERROR
result.message = "the save could not be read." result.message = "the save could not be read."
return result return result
var json := JSON.new() var save_data: Dictionary = decoded["data"]
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
result.detected_version = _read_integer(save_data.get("save_version"), -1) result.detected_version = _read_integer(save_data.get("save_version"), -1)
if result.detected_version > SAVE_VERSION: if result.detected_version > SAVE_VERSION:
result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION
@ -408,18 +443,112 @@ func inspect_save() -> SaveInspectionType:
return result 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: if not _is_configured:
return false 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 return false
_automatic_saving_blocked = false _automatic_saving_blocked = false
_restore_defaults() _restore_defaults()
_world_layout = world_layout
_world_seed = world_seed _world_seed = world_seed
_is_dirty = true _is_dirty = true
return true return true
func get_world_layout() -> StringName:
return _world_layout
func get_world_seed() -> int: func get_world_seed() -> int:
return _world_seed return _world_seed
@ -431,9 +560,19 @@ static func roll_world_seed() -> int:
func delete_progression_save() -> bool: func delete_progression_save() -> bool:
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path): for primary_path: String in [_save_path, _legacy_save_path()]:
return false if (
for path: String in [_temp_path(), _backup_path()]: 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): if FileAccess.file_exists(path) and not _remove_if_present(path):
push_warning("Unable to remove stale player-save auxiliary file.") push_warning("Unable to remove stale player-save auxiliary file.")
if _autosave_timer != null: if _autosave_timer != null:
@ -485,16 +624,8 @@ func save_now() -> bool:
var save_data: Dictionary = _build_save_dictionary() var save_data: Dictionary = _build_save_dictionary()
if save_data.is_empty(): if save_data.is_empty():
return false return false
var json_text: String = JSON.stringify(save_data, "\t") var result: Dictionary = _write_current_save_data(
if json_text.is_empty(): save_data, _expected_hash
return false
var result := PortableFileGuard.write_guarded(
_save_path,
json_text.to_utf8_buffer(),
_expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
) )
if bool(result.get("conflict", false)): if bool(result.get("conflict", false)):
_data_root.report_conflict( _data_root.report_conflict(
@ -574,6 +705,17 @@ func _build_save_dictionary() -> Dictionary:
) )
): ):
return {} 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 { return {
"save_version": SAVE_VERSION, "save_version": SAVE_VERSION,
@ -592,6 +734,10 @@ func _build_save_dictionary() -> Dictionary:
"items": serialized_items, "items": serialized_items,
"unlocked_bait_ids": serialized_unlocked_baits, "unlocked_bait_ids": serialized_unlocked_baits,
}, },
"tackle": {
"active_bait_id": String(active_bait_id),
"active_lure_id": String(active_lure_id),
},
"hotbar": { "hotbar": {
"selected_slot": _hotbar.get_selected_slot(), "selected_slot": _hotbar.get_selected_slot(),
"slots": serialized_slots, "slots": serialized_slots,
@ -603,6 +749,7 @@ func _build_save_dictionary() -> Dictionary:
"art": _art_unlocks.to_save_data(), "art": _art_unlocks.to_save_data(),
"experience": _experience.to_save_data(), "experience": _experience.to_save_data(),
"world": { "world": {
"layout": String(_world_layout),
"seed": _world_seed, "seed": _world_seed,
"time_hours": _world_time.get_persistent_time_hours(), "time_hours": _world_time.get_persistent_time_hours(),
"weather": int(_world_weather.get_persistent_weather()), "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("collection")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
or typeof(save_data.get("bag")) != 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("hotbar")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
or typeof(save_data.get("experience")) != 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 collection_data: Dictionary = save_data["collection"]
var inventory_data: Dictionary = save_data["inventory"] var inventory_data: Dictionary = save_data["inventory"]
var bag_data: Dictionary = save_data["bag"] var bag_data: Dictionary = save_data["bag"]
var tackle_data: Dictionary = save_data["tackle"]
var hotbar_data: Dictionary = save_data["hotbar"] var hotbar_data: Dictionary = save_data["hotbar"]
var inventory_layout_data: Dictionary = save_data["inventory_layout"] var inventory_layout_data: Dictionary = save_data["inventory_layout"]
var experience_data: Dictionary = save_data["experience"] 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 and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY
) )
or typeof(hotbar_data.get("slots")) != 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 hotbar_data.has("selected_slot")
or not experience_data.has("total_experience") or not experience_data.has("total_experience")
): ):
@ -684,6 +839,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
return null return null
var snapshot := LoadSnapshot.new() 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"): if world_data.has("seed"):
snapshot.world_seed = _read_integer( snapshot.world_seed = _read_integer(
world_data["seed"], world_data["seed"],
@ -897,6 +1058,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
): ):
seen_unlocked_baits[owned.item_id] = true seen_unlocked_baits[owned.item_id] = true
snapshot.unlocked_bait_ids.append(owned.item_id) 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"] var slot_values: Array = hotbar_data["slots"]
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
@ -995,6 +1166,8 @@ func _migrate_save(
migrated = _migrate_version_7_to_8(migrated) migrated = _migrate_version_7_to_8(migrated)
8: 8:
migrated = _migrate_version_8_to_9(migrated) migrated = _migrate_version_8_to_9(migrated)
9:
migrated = _migrate_version_9_to_10(migrated)
_: _:
return {} return {}
if migrated.is_empty(): if migrated.is_empty():
@ -1242,6 +1415,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary:
return migrated 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: func _mark_dirty() -> void:
if ( if (
_is_restoring _is_restoring
@ -1291,33 +1474,169 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void:
_mark_dirty() _mark_dirty()
func _on_active_tackle_changed(_item_id: StringName) -> void:
_mark_dirty()
func _on_autosave_timeout() -> void: func _on_autosave_timeout() -> void:
if _is_dirty: if _is_dirty:
save_now() save_now()
func _recover_interrupted_write() -> void: func _recover_interrupted_write() -> void:
if FileAccess.file_exists(_save_path): _remove_if_present(_codec_scratch_path())
_remove_if_present(_temp_path()) _recover_path_write(_save_path)
_remove_if_present(_backup_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 return
if FileAccess.file_exists(_backup_path()): if FileAccess.file_exists(backup):
_rename_file(_backup_path(), _save_path) _rename_file(backup, path)
_remove_if_present(_temp_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) push_warning(message)
_restore_defaults() _restore_defaults()
if not FileAccess.file_exists(_save_path): if not FileAccess.file_exists(source_path):
return return
var timestamp: int = int(Time.get_unix_time_from_system()) var timestamp: int = int(Time.get_unix_time_from_system())
var corrupt_path: String = ( var corrupt_path: String = (
"%s.corrupt-%d" % [_save_path, timestamp] "%s.corrupt-%d" % [source_path, timestamp]
) )
if FileAccess.file_exists(corrupt_path): if FileAccess.file_exists(corrupt_path):
corrupt_path += "-%d" % Time.get_ticks_usec() 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 _automatic_saving_blocked = true
push_warning( push_warning(
"Corrupt player save was left in place; automatic saving is " "Corrupt player save was left in place; automatic saving is "
@ -1350,6 +1669,8 @@ func _restore_defaults() -> void:
_bag.replace_all_items(default_items) _bag.replace_all_items(default_items)
var default_unlocked_baits: Array[StringName] = [] var default_unlocked_baits: Array[StringName] = []
_bag.replace_unlocked_bait_ids(default_unlocked_baits) _bag.replace_unlocked_bait_ids(default_unlocked_baits)
_player.unequip_bait()
_player.unequip_lure()
_fishing_upgrades.reset_to_defaults() _fishing_upgrades.reset_to_defaults()
_cooler_capacity.reset_to_defaults() _cooler_capacity.reset_to_defaults()
_inventory_layout.reset_to_defaults() _inventory_layout.reset_to_defaults()
@ -1360,12 +1681,60 @@ func _restore_defaults() -> void:
WorldTimeServiceType.DEFAULT_START_HOUR WorldTimeServiceType.DEFAULT_START_HOUR
) )
_world_weather.reset_persistent_state() _world_weather.reset_persistent_state()
_world_layout = WorldLayoutType.GENERATED
_world_seed = DEFAULT_WORLD_SEED _world_seed = DEFAULT_WORLD_SEED
_jobs.reset_to_defaults() _jobs.reset_to_defaults()
_is_restoring = false _is_restoring = false
_is_dirty = 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( func _read_integer(
value: Variant, value: Variant,
invalid_value: int, 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 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && 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 WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-windows-x86_64.zip" readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-linux-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}" readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then 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 ## 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. force-quit chord remains available; this is Start+Select on most devices.
EOF 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 echo "port.json must be inside netfishing/, not at the archive root." >&2
exit 1 exit 1
fi fi
grep -q '^# PORTMASTER: netfishing.zip, NETfishing.sh$' \ if grep -Eq '^[[:space:]]*#[[:space:]]*PORTMASTER:' \
"${STAGE_ROOT}/NETfishing.sh" "${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"' \ grep -Fq 'PROFILE_PATH="$CONFDIR/performance_profile"' \
"${STAGE_ROOT}/NETfishing.sh" "${STAGE_ROOT}/NETfishing.sh"
grep -Fq 'NETFISHING_PERFORMANCE_PROFILE=light' \ grep -Fq 'NETFISHING_PERFORMANCE_PROFILE=light' \

View file

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

View file

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

View file

@ -15,6 +15,7 @@ readonly -a QUICK_TESTS=(
"tests/android_readiness_validation.gd" "tests/android_readiness_validation.gd"
"tests/camera_drag_validation.gd" "tests/camera_drag_validation.gd"
"tests/character_rig_validation.gd" "tests/character_rig_validation.gd"
"tests/chat_validation.gd"
"tests/controller_focus_presentation_validation.gd" "tests/controller_focus_presentation_validation.gd"
"tests/controller_menu_accessibility_validation.gd" "tests/controller_menu_accessibility_validation.gd"
"tests/controller_mapping_validation.gd" "tests/controller_mapping_validation.gd"
@ -22,6 +23,7 @@ readonly -a QUICK_TESTS=(
"tests/controller_ui_navigation_validation.gd" "tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd" "tests/dedicated_server_config_validation.gd"
"tests/digging_prototype_validation.gd" "tests/digging_prototype_validation.gd"
"tests/equipment_catalog_validation.gd"
"tests/exported_decal_hotfix_validation.gd" "tests/exported_decal_hotfix_validation.gd"
"tests/file_dialog_controller_navigation_validation.gd" "tests/file_dialog_controller_navigation_validation.gd"
"tests/fish_catalog_content_validation.gd" "tests/fish_catalog_content_validation.gd"
@ -50,6 +52,7 @@ readonly -a QUICK_TESTS=(
"tests/tree_gathering_prototype_validation.gd" "tests/tree_gathering_prototype_validation.gd"
"tests/unified_inventory_validation.gd" "tests/unified_inventory_validation.gd"
"tests/world_time_validation.gd" "tests/world_time_validation.gd"
"tests/world_layout_validation.gd"
"tests/world_spawn_protocol_validation.gd" "tests/world_spawn_protocol_validation.gd"
"tests/world_weather_validation.gd" "tests/world_weather_validation.gd"
) )
@ -72,10 +75,12 @@ readonly -a HOST_TESTS=(
"tests/gathering_showcase_validation.gd" "tests/gathering_showcase_validation.gd"
"tests/inventory_storage_persistence_validation.gd" "tests/inventory_storage_persistence_validation.gd"
"tests/job_system_validation.gd" "tests/job_system_validation.gd"
"tests/progression_archive_validation.gd"
"tests/surface_drawing_runtime_validation.gd" "tests/surface_drawing_runtime_validation.gd"
) )
readonly -a NETWORK_TESTS=( readonly -a NETWORK_TESTS=(
"tests/chat_privacy_multiplayer_validation.gd"
"tests/economy_regression_validation.gd" "tests/economy_regression_validation.gd"
"tests/fish_showcase_multiplayer_validation.gd" "tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd"
@ -85,6 +90,7 @@ readonly -a NETWORK_TESTS=(
"tests/profile_multiplayer_validation.gd" "tests/profile_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd"
"tests/world_layout_multiplayer_validation.gd"
"tests/world_spawn_multiplayer_validation.gd" "tests/world_spawn_multiplayer_validation.gd"
) )
@ -140,7 +146,10 @@ wait_for_network_host() {
sleep 2 sleep 2
return 0 return 0
fi 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 if ! kill -0 "${host_pid}" 2>/dev/null; then
return 1 return 1
fi fi
@ -226,7 +235,7 @@ run_session_switch_network_test() {
--headless --path "${PROJECT_ROOT}" --script "${script}" -- second_host \ --headless --path "${PROJECT_ROOT}" --script "${script}" -- second_host \
>"${second_root}/output.log" 2>&1 & >"${second_root}/output.log" 2>&1 &
second_pid=$! second_pid=$!
for _attempt in {1..100}; do for _attempt in {1..600}; do
if ! kill -0 "${first_pid}" 2>/dev/null \ if ! kill -0 "${first_pid}" 2>/dev/null \
|| ! kill -0 "${second_pid}" 2>/dev/null; then || ! kill -0 "${second_pid}" 2>/dev/null; then
break break

View file

@ -17,6 +17,8 @@ var world_seed: int = DEFAULT_WORLD_SEED
var public_listing: bool = false var public_listing: bool = false
var discovery_url: String = "" var discovery_url: String = ""
var data_directory: String = "" var data_directory: String = ""
var chat_logging: bool = false
var chat_log_path: String = ""
var operator_fingerprints: PackedStringArray = PackedStringArray() var operator_fingerprints: PackedStringArray = PackedStringArray()
var error_message: String = "" var error_message: String = ""
@ -71,6 +73,12 @@ func _load_file(path: String) -> bool:
discovery_url = str(file.get_value( discovery_url = str(file.get_value(
"discovery", "url", discovery_url "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( operator_fingerprints = _parse_fingerprint_list(file.get_value(
"moderation", "operators", operator_fingerprints "moderation", "operators", operator_fingerprints
)) ))
@ -100,6 +108,12 @@ func _apply_environment() -> void:
data_directory = _environment_string( data_directory = _environment_string(
"NETFISHING_DATA_DIR", data_directory "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"): if OS.has_environment("NETFISHING_SERVER_OPERATORS"):
operator_fingerprints = _parse_fingerprint_list( operator_fingerprints = _parse_fingerprint_list(
OS.get_environment("NETFISHING_SERVER_OPERATORS") OS.get_environment("NETFISHING_SERVER_OPERATORS")
@ -130,10 +144,16 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
operator_fingerprints = _parse_fingerprint_list( operator_fingerprints = _parse_fingerprint_list(
argument.trim_prefix("--operators=") argument.trim_prefix("--operators=")
) )
elif argument.begins_with("--chat-log-path="):
chat_log_path = argument.trim_prefix("--chat-log-path=")
elif argument == "--public": elif argument == "--public":
public_listing = true public_listing = true
elif argument == "--private": elif argument == "--private":
public_listing = false public_listing = false
elif argument == "--chat-logging":
chat_logging = true
elif argument == "--no-chat-logging":
chat_logging = false
func _validate() -> void: func _validate() -> void:
@ -141,6 +161,9 @@ func _validate() -> void:
bind_address = bind_address.strip_edges() bind_address = bind_address.strip_edges()
discovery_url = discovery_url.strip_edges().trim_suffix("/") discovery_url = discovery_url.strip_edges().trim_suffix("/")
data_directory = data_directory.strip_edges() 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) operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
if server_name.is_empty(): if server_name.is_empty():
error_message = "Server name cannot be 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 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(): elif not data_directory.is_empty() and not data_directory.is_absolute_path():
error_message = "Server data directory must be an 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 ( elif public_listing and not (
discovery_url.begins_with("https://") discovery_url.begins_with("https://")
or discovery_url.begins_with("http://") or discovery_url.begins_with("http://")

View file

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

View file

@ -86,6 +86,11 @@ func _run() -> void:
var chat_ui := game_ui.get_node("%ChatUI") as ChatUI var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
assert(player != null and service != null and toolbar != null) assert(player != null and service != null and toolbar != null)
assert(chat_ui != 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"))) assert(bool(game_ui.call("_can_start_virtual_mouse")))
var virtual_pointer_states: Array[bool] = [] var virtual_pointer_states: Array[bool] = []
game_ui.virtual_pointer_mode_changed.connect( game_ui.virtual_pointer_mode_changed.connect(
@ -317,6 +322,22 @@ func _run() -> void:
assert(not ( assert(not (
game_ui.get_node("%ExperiencePresentation") as Control game_ui.get_node("%ExperiencePresentation") as Control
).visible) ).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) quick_menu.open_menu(true)
assert(quick_menu.visible and quick_menu.is_open()) assert(quick_menu.visible and quick_menu.is_open())
quick_menu.close_menu() quick_menu.close_menu()
@ -365,6 +386,12 @@ func _run() -> void:
await process_frame await process_frame
assert(service.is_active() and not service.is_placement_mode()) assert(service.is_active() and not service.is_placement_mode())
assert(toolbar.visible) 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 var ui_root := game_ui.get_node("%UIRoot") as Control
assert(toolbar.get_parent() == ui_root) assert(toolbar.get_parent() == ui_root)
assert(is_zero_approx(toolbar.position.y)) 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.", "Open Data Folder left a controller-activatable dialog behind.",
) )
var change_data_folder := panel.get_node("%ChangeDataFolder") as Button 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 copy_fingerprint := panel.get_node("%CopyPlayerFingerprint") as Button
var export_player := panel.get_node("%ExportPlayerIdentity") as Button var export_player := panel.get_node("%ExportPlayerIdentity") as Button
var import_player := panel.get_node("%ImportPlayerIdentity") as Button var import_player := panel.get_node("%ImportPlayerIdentity") as Button
@ -362,6 +364,8 @@ func _validate_data_settings_navigation() -> void:
data_tab, data_tab,
open_data_folder, open_data_folder,
change_data_folder, change_data_folder,
export_progression,
import_progression,
copy_fingerprint, copy_fingerprint,
export_player, export_player,
import_player, import_player,
@ -385,7 +389,7 @@ func _validate_data_settings_navigation() -> void:
_assert_neighbor( _assert_neighbor(
open_data_folder, open_data_folder,
&"focus_neighbor_bottom", &"focus_neighbor_bottom",
copy_fingerprint, export_progression,
) )
_assert_neighbor( _assert_neighbor(
change_data_folder, change_data_folder,
@ -395,12 +399,42 @@ func _validate_data_settings_navigation() -> void:
_assert_neighbor( _assert_neighbor(
change_data_folder, change_data_folder,
&"focus_neighbor_bottom", &"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, copy_fingerprint,
) )
_assert_neighbor( _assert_neighbor(
copy_fingerprint, copy_fingerprint,
&"focus_neighbor_top", &"focus_neighbor_top",
open_data_folder, export_progression,
) )
_assert_neighbor( _assert_neighbor(
copy_fingerprint, copy_fingerprint,

View file

@ -19,6 +19,12 @@ func _run() -> void:
defaults.call("_validate") defaults.call("_validate")
assert(defaults.is_valid()) assert(defaults.is_valid())
assert(not bool(defaults.get("public_listing"))) 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( var path: String = ProjectSettings.globalize_path(
"user://dedicated-server-validation.cfg" "user://dedicated-server-validation.cfg"
@ -32,6 +38,12 @@ func _run() -> void:
file.set_value("server", "public", true) file.set_value("server", "public", true)
file.set_value("server", "data_directory", "/tmp/configured-server") file.set_value("server", "data_directory", "/tmp/configured-server")
file.set_value("discovery", "url", "https://discovery.netfishing.org/") 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( file.set_value(
"moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A]) "moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
) )
@ -48,6 +60,11 @@ func _run() -> void:
assert(int(configured.get("world_seed")) == 928374) assert(int(configured.get("world_seed")) == 928374)
assert(bool(configured.get("public_listing"))) assert(bool(configured.get("public_listing")))
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") 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( assert(
configured.get("operator_fingerprints") configured.get("operator_fingerprints")
== PackedStringArray([OPERATOR_A, OPERATOR_B]) == PackedStringArray([OPERATOR_A, OPERATOR_B])
@ -89,6 +106,51 @@ func _run() -> void:
invalid_seed.call("_validate") invalid_seed.call("_validate")
assert(not invalid_seed.is_valid()) 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() var discovery := DiscoveryClient.new()
assert( assert(
str(discovery.call("_default_room_name", "River")) 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_price(&"magnet") == 250)
assert(FishingShopStockType.get_stock_item_ids().has(&"magnet")) assert(FishingShopStockType.get_stock_item_ids().has(&"magnet"))
assert(FishingShopStockType.is_permanent_unlock(&"magnet", 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( var crab_net: ItemDataType = ItemCatalogResource.get_available_item_by_id(
&"crab_net" &"crab_net"
) )

View file

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

View file

@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void:
version_four, version_four,
4, 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(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert( assert(
is_equal_approx( is_equal_approx(

View file

@ -2,6 +2,7 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn") const MainScene = preload("res://main/main.tscn")
const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const TEST_SEED: int = 864209
func _initialize() -> void: func _initialize() -> void:
@ -22,9 +23,23 @@ func _run() -> void:
func _run_host() -> void: func _run_host() -> void:
var main: Node = await _create_initialized_main() 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"))) assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager 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") main.call("_enter_gameplay")
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
assert(session.set_host_open(true)) assert(session.set_host_open(true))
@ -217,7 +232,9 @@ func _run_client() -> void:
int(player.get("_fishing_visual_phase")) int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.CASTING == 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 await physics_frame
player.set_fishing_visual(false) player.set_fishing_visual(false)
player.call("_set_sitting", true, true) 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 fishing_spot := main.get_node("%FishingSpot") as FishingSpot
var catalog := main.get("fish_catalog") as FishPool var catalog := main.get("fish_catalog") as FishPool
assert(player != null and fishing_spot != null and catalog != null) 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.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net")) assert(player.hotbar.assign_item(0, &"crab_net"))
assert(player.hotbar.select_slot(0)) assert(player.hotbar.select_slot(0))
@ -60,13 +61,20 @@ func _run() -> void:
assert(not player.is_movement_enabled()) assert(not player.is_movement_enabled())
Input.action_release("fish_primary") Input.action_release("fish_primary")
await process_frame 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() var pocket_event := InputEventAction.new()
pocket_event.action = &"fish_primary" pocket_event.action = &"fish_primary"
pocket_event.pressed = true pocket_event.pressed = true
Input.parse_input_event(pocket_event) Input.parse_input_event(pocket_event)
await process_frame 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) assert(fishing_spot.get("_pending_catch") == null)
var pocket_deadline: int = Time.get_ticks_msec() + 6000 var pocket_deadline: int = Time.get_ticks_msec() + 6000

View file

@ -82,18 +82,42 @@ func _validate_generated_region(
generator: TerrainChunkGenerator, generator: TerrainChunkGenerator,
) -> void: ) -> void:
var placements := generator.placement_keys() var placements := generator.placement_keys()
assert(placements.size() == 49) var expected_chunk_count := generator.grid_size.x * generator.grid_size.y
assert(placements[24].begins_with("chunk_spawn@")) 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_spawn") == 1)
assert(_placement_count(placements, "chunk_0001") >= 1) assert(_placement_count(placements, "chunk_0001") >= 1)
assert(_placement_count(placements, "chunk_0002") >= 1) assert(_placement_count(placements, "chunk_0002") >= 1)
assert(_placement_count(placements, "chunk_0003") >= 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_0004") >= 1)
assert(_placement_count(placements, "chunk_0005") >= 1) assert(_placement_count(placements, "chunk_0005") >= 1)
assert(_placement_count(placements, "chunk_0006") >= 1) assert(_placement_count(placements, "chunk_0006") >= 1)
assert(_placement_count(placements, "chunk_0007") >= 1) assert(_placement_count(placements, "chunk_0007") >= 1)
assert(generator.get_generated_chunks_root().get_child_count() == 49) assert(_placement_count(placements, "chunk_0008") >= 1)
assert(generator.get_primary_terrain_meshes().size() == 49) 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 shop := region.get_node("Interactables/FishingShopWorld") as Node3D
var storage := region.get_node("Interactables/PlayerStorageBox") 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 var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D
assert(ocean != null and fresh_root != null) assert(ocean != null and fresh_root != null)
assert(ocean.water_type == WaterType.Type.SALT_WATER) assert(ocean.water_type == WaterType.Type.SALT_WATER)
assert(is_equal_approx(ocean.position.y, GeneratedWorldRegion.WATER_HEIGHT))
var fresh_placement_count := 0 var fresh_placement_count := 0
for record: Dictionary in generator.placement_records(): for record: Dictionary in generator.placement_records():
var tags: PackedStringArray = record.get("tags", PackedStringArray()) var tags: PackedStringArray = record.get("tags", PackedStringArray())
@ -123,6 +148,7 @@ func _validate_generated_region(
var fresh := child as WaterBodyAuthoring var fresh := child as WaterBodyAuthoring
assert(fresh != null) assert(fresh != null)
assert(fresh.water_type == WaterType.Type.FRESH_WATER) assert(fresh.water_type == WaterType.Type.FRESH_WATER)
assert(is_equal_approx(fresh.position.y, GeneratedWorldRegion.WATER_HEIGHT))
assert(fresh.visible) assert(fresh.visible)
assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0) assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0)
assert(not fresh.visual_surface_enabled) assert(not fresh.visual_surface_enabled)
@ -155,13 +181,13 @@ func _validate_generated_region(
&"terrain_chunk_coordinate", &"terrain_chunk_coordinate",
Vector2i.ZERO, Vector2i.ZERO,
) )
var center := Vector2i( var spawn_coordinate := Vector2i(
generator.grid_size.x / 2, generator.grid_size.x / 2,
generator.grid_size.y / 2, generator.grid_size.y / 2,
) )
var spawn_distance := ( var spawn_distance := (
absi(coordinate.x - center.x) absi(coordinate.x - spawn_coordinate.x)
+ absi(coordinate.y - center.y) + absi(coordinate.y - spawn_coordinate.y)
) )
assert(spawn_distance >= definition.minimum_spawn_chunk_distance) assert(spawn_distance >= definition.minimum_spawn_chunk_distance)
_validate_decoration_transform( _validate_decoration_transform(
@ -169,6 +195,13 @@ func _validate_generated_region(
child as Node3D, child as Node3D,
definition, 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, &"grass_tree") > 0)
assert(_decoration_group_count(region, &"sand_tree") > 0) assert(_decoration_group_count(region, &"sand_tree") > 0)
assert( 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( func _validate_ocean_facing_record(
record: Dictionary, record: Dictionary,
grid_size: Vector2i, grid_size: Vector2i,
@ -224,6 +320,23 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void:
assert(instance.position.is_zero_approx()) assert(instance.position.is_zero_approx())
assert(_find_mesh_instance(instance) != null) assert(_find_mesh_instance(instance) != null)
instance.free() 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( func _validate_biome_catalog(
@ -239,7 +352,9 @@ func _validate_biome_catalog(
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
var biome_id := region.get_biome_at(coordinate) var biome_id := region.get_biome_at(coordinate)
var tags: PackedStringArray = record.get("tags", PackedStringArray()) 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 != &"") assert(biome_id != &"")
var biome := catalog.definition_for_id(biome_id) var biome := catalog.definition_for_id(biome_id)
assert(biome != null and biome.supports_chunk_tags(tags)) assert(biome != null and biome.supports_chunk_tags(tags))
@ -276,7 +391,16 @@ func _validate_decoration_transform(
var collision := prop.get_node_or_null( var collision := prop.get_node_or_null(
"TrunkCollision/CollisionShape" "TrunkCollision/CollisionShape"
) as CollisionShape3D ) 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) assert(visual != null and visual.mesh != null)
if definition.has_collision(): if definition.has_collision():
assert(collision != null) assert(collision != null)
@ -319,16 +443,40 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D:
return null 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( func _validate_authored_chunk_surfaces(
region: GeneratedWorldRegion, region: GeneratedWorldRegion,
generator: TerrainChunkGenerator, generator: TerrainChunkGenerator,
) -> void: ) -> void:
var generated := generator.get_generated_chunks_root() var generated := generator.get_generated_chunks_root()
var found_beach := false var found_beach := false
var found_stream := false
var found_alternate_stream := false
var found_pond := false var found_pond := false
var found_grass_ocean_edge := false var found_grass_ocean_edge := false
var found_grass_beach_transition := false var found_grass_beach_transition := false
var found_grass_ocean_corner := 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(): for chunk_root: Node in generated.get_children():
if chunk_root.name.begins_with("chunk_0002r"): if chunk_root.name.begins_with("chunk_0002r"):
var beach := chunk_root.find_child( var beach := chunk_root.find_child(
@ -338,6 +486,27 @@ func _validate_authored_chunk_surfaces(
var material := beach.get_active_material(0) var material := beach.get_active_material(0)
assert(material != null and material.resource_name == "sand") assert(material != null and material.resource_name == "sand")
found_beach = true 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"): elif chunk_root.name.begins_with("chunk_0004r"):
var pond := chunk_root.find_child( var pond := chunk_root.find_child(
"chunk_0004", true, false "chunk_0004", true, false
@ -383,11 +552,32 @@ func _validate_authored_chunk_surfaces(
assert("grass_lite" in corner_materials) assert("grass_lite" in corner_materials)
assert("cliff_wall" in corner_materials) assert("cliff_wall" in corner_materials)
found_grass_ocean_corner = true 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_beach)
assert(found_stream)
assert(found_alternate_stream)
assert(found_pond) assert(found_pond)
assert(found_grass_ocean_edge) assert(found_grass_ocean_edge)
assert(found_grass_beach_transition) assert(found_grass_beach_transition)
assert(found_grass_ocean_corner) assert(found_grass_ocean_corner)
assert(found_beach_ocean_corner)
assert(found_grass_sand_diagonal)
func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray:

View file

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

View file

@ -49,6 +49,7 @@ func _run() -> void:
_validate_weather_guarantees(board) _validate_weather_guarantees(board)
_validate_late_board_weather_fallback() _validate_late_board_weather_fallback()
_validate_remote_tamper_rejection(jobs, board) _validate_remote_tamper_rejection(jobs, board)
_validate_creature_jobs(jobs)
var sell_job: Dictionary = _find_job( var sell_job: Dictionary = _find_job(
jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
@ -195,15 +196,12 @@ func _run() -> void:
assert(session.set_host_open(false)) assert(session.set_host_open(false))
assert(save_manager.save_now()) assert(save_manager.save_now())
var save_file := FileAccess.open( var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
str(save_manager.get("_save_path")), FileAccess.READ str(save_manager.get("_save_path"))
) )
assert(save_file != null) assert(bool(decoded.get("ok", false)))
var parsed: Variant = JSON.parse_string(save_file.get_as_text()) var save_data: Dictionary = decoded["data"]
save_file.close() assert(int(save_data.get("save_version", -1)) == 10)
assert(typeof(parsed) == TYPE_DICTIONARY)
var save_data: Dictionary = parsed
assert(int(save_data.get("save_version", -1)) == 9)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
_validate_pause_session_switch(main, session) _validate_pause_session_switch(main, session)
@ -263,6 +261,100 @@ func _validate_unavailable_fishnet_layout() -> void:
await process_frame 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: func _validate_weather_guarantees(board: Dictionary) -> void:
var jobs: Array = board.get("jobs", []) var jobs: Array = board.get("jobs", [])
var schedule: Array = board.get("weather_schedule", []) var schedule: Array = board.get("weather_schedule", [])

View file

@ -7,6 +7,12 @@ const KeyboardMouseMappingPanelType = preload(
"res://ui/keyboard_mouse_mapping_panel.gd" "res://ui/keyboard_mouse_mapping_panel.gd"
) )
const SettingsPanelScene = preload("res://ui/settings_panel.tscn") 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: func _initialize() -> void:
@ -27,6 +33,13 @@ func _run() -> void:
str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION)
].get("kind", "")) == "mouse_button" ].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( assert(
str(defaults[ str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_CHAT) str(KeyboardMouseMappingManagerType.ROLE_CHAT)
@ -60,6 +73,15 @@ func _run() -> void:
)) ))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1)) assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1))
assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) 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() var joypad_event := InputEventJoypadButton.new()
joypad_event.button_index = JOY_BUTTON_A joypad_event.button_index = JOY_BUTTON_A
@ -117,11 +139,64 @@ func _run() -> void:
)) ))
assert(_has_physical_key(&"jump", KEY_SPACE)) assert(_has_physical_key(&"jump", KEY_SPACE))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) 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(_has_physical_key(&"open_chat", KEY_T))
assert(_event_count(&"jump", true) == joypad_events_before) 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() settings_panel.queue_free()
panel.queue_free() panel.queue_free()
migrated_manager.queue_free()
manager.queue_free() manager.queue_free()
print("Keyboard and mouse mapping validation: PASS") print("Keyboard and mouse mapping validation: PASS")
quit(0) quit(0)

View file

@ -43,6 +43,7 @@ func _run() -> void:
await process_frame await process_frame
assert(bool(normal_main.get("_application_initialized"))) assert(bool(normal_main.get("_application_initialized")))
_validate_normal_profile(normal_main) _validate_normal_profile(normal_main)
_validate_new_game_music_transition(normal_main)
_stop_audio_players(normal_main) _stop_audio_players(normal_main)
normal_main.queue_free() normal_main.queue_free()
for _frame: int in 8: for _frame: int in 8:
@ -115,6 +116,7 @@ func _validate_main_profile(main: Node) -> void:
assert(player_menu != null) assert(player_menu != null)
assert(not player_menu.is_cooler_water_effect_enabled()) assert(not player_menu.is_cooler_water_effect_enabled())
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != 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("%DuskMusic") as AudioStreamPlayer).stream != null)
assert( assert(
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream == null (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("CloudField") is MultiMeshInstance3D)
assert(clouds.get_node_or_null("CloudCeiling") == null) assert(clouds.get_node_or_null("CloudCeiling") == null)
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != 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("%DuskMusic") as AudioStreamPlayer).stream != null)
assert( assert(
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream != null (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: func _first_fresh_water_visual(main: Node) -> MeshInstance3D:
var root := main.get_node( var root := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"

View file

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

View file

@ -21,12 +21,14 @@ shadow_enabled = true
[node name="TerrainChunkGenerator" type="Node3D" parent="."] [node name="TerrainChunkGenerator" type="Node3D" parent="."]
script = ExtResource("2_generator") script = ExtResource("2_generator")
catalog = ExtResource("3_catalog") catalog = ExtResource("3_catalog")
grid_size = Vector2i(7, 7) grid_size = Vector2i(11, 9)
generation_seed = 13001 generation_seed = 13001
build_collision = true build_collision = true
show_chunk_labels = false show_chunk_labels = false
force_center_chunk_id = &"chunk_spawn" 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")] [node name="Player" parent="." instance=ExtResource("4_player")]
position = Vector3(0, 0.3, 0) position = Vector3(0, 0.3, 0)

View file

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

View file

@ -152,15 +152,10 @@ func _run_client() -> void:
var chat_service := main.get_node( var chat_service := main.get_node(
"%NetworkChatService" "%NetworkChatService"
) as NetworkChatService ) as NetworkChatService
var history_deadline: int = Time.get_ticks_msec() + 8000 # Player-hosted rooms deliberately send no prior scrollback to joining or
while Time.get_ticks_msec() < history_deadline: # reconnecting clients. Live messages sent after authentication still work.
if chat_service.get_history().any( await create_timer(0.5).timeout
func(message: Dictionary) -> bool: assert(not chat_service.get_history().any(
return str(message.get("body", "")) == HOST_HISTORY_MESSAGE
):
break
await process_frame
assert(chat_service.get_history().any(
func(message: Dictionary) -> bool: func(message: Dictionary) -> bool:
return str(message.get("body", "")) == HOST_HISTORY_MESSAGE 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_0005",
"chunk_0006", "chunk_0006",
"chunk_0007", "chunk_0007",
"chunk_0008",
"chunk_0009",
"chunk_0010",
"chunk_0011",
"chunk_0012",
"chunk_0013",
"chunk_0014",
"chunk_0015",
"chunk_0016",
"chunk_spawn", "chunk_spawn",
] ]
const REQUIRED_IDS: Array[String] = [ const REQUIRED_IDS: Array[String] = [
@ -23,10 +32,11 @@ const REQUIRED_IDS: Array[String] = [
"chunk_0005", "chunk_0005",
"chunk_0006", "chunk_0006",
"chunk_0007", "chunk_0007",
"chunk_0008",
] ]
const TEST_SEED := 13001 const TEST_SEED := 13001
const CONNECTOR_STRESS_SEED := 287 const CONNECTOR_STRESS_SEED := 287
const EXPECTED_SOLVER_VARIANT_COUNT := 27 const EXPECTED_SOLVER_VARIANT_COUNT := 35
const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = { const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
&"chunk_0000": 4, &"chunk_0000": 4,
&"chunk_0001": 4, &"chunk_0001": 4,
@ -36,6 +46,15 @@ const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
&"chunk_0005": 4, &"chunk_0005": 4,
&"chunk_0006": 4, &"chunk_0006": 4,
&"chunk_0007": 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, &"chunk_spawn": 1,
} }
@ -49,6 +68,7 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
_validate_catalog() _validate_catalog()
_validate_profiles() _validate_profiles()
_validate_second_tier_coastal_layout()
_validate_generation() _validate_generation()
_finish() _finish()
@ -75,6 +95,15 @@ func _validate_catalog() -> void:
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005") var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006") var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") 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") var spawn := CATALOG.definition_for_id(&"chunk_spawn")
_check( _check(
( (
@ -84,6 +113,15 @@ func _validate_catalog() -> void:
and grass_ocean_edge != null and grass_ocean_edge != null
and grass_beach_transition != null and grass_beach_transition != null
and grass_ocean_corner != 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 and spawn != null
), ),
"The authored biome-rule definitions must be available.", "The authored biome-rule definitions must be available.",
@ -95,6 +133,15 @@ func _validate_catalog() -> void:
and grass_ocean_edge != null and grass_ocean_edge != null
and grass_beach_transition != null and grass_beach_transition != null
and grass_ocean_corner != 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 and spawn != null
): ):
_check( _check(
@ -193,6 +240,124 @@ func _validate_catalog() -> void:
), ),
"Both corner land seams must accept straight grass ocean edges.", "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: 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: func _validate_generation() -> void:
var first := _make_generator() var first := _make_generator()
root.add_child(first) root.add_child(first)
@ -243,6 +493,10 @@ func _validate_generation() -> void:
_placements_contain(first_keys, stable_id), _placements_contain(first_keys, stable_id),
"Generated diagnostic must contain %s." % 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( _check(
_all_neighbor_edges_match(first), _all_neighbor_edges_match(first),
"Every generated neighboring edge must match.", "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") var stream := CATALOG.definition_for_id(&"chunk_0003")
_check(stream != null, "The stream definition must be available.") var alternate_stream := CATALOG.definition_for_id(&"chunk_0016")
if stream == null: _check(
stream != null and alternate_stream != null,
"Both stream visual definitions must be available.",
)
if stream == null or alternate_stream == null:
return return
for quarter_turns: int in 4: for quarter_turns: int in 4:
var stream_variant := _find_variant( var stream_variant := _find_variant(
@ -364,7 +622,16 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
stream_variant != null, stream_variant != null,
"The stream must support rotation %d." % quarter_turns, "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 continue
var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge( var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge(
TerrainChunkTopology.Edge.NORTH, TerrainChunkTopology.Edge.NORTH,
@ -384,6 +651,25 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
== expected_outlet, == expected_outlet,
"The rotated stream outlet must follow its authored orientation.", "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") var beach := CATALOG.definition_for_id(&"chunk_0002")
_check(beach != null, "The beach definition must be available.") _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_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006") var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008")
_check( _check(
( (
grass_ocean_edge != null grass_ocean_edge != null
and grass_beach_transition != null and grass_beach_transition != null
and grass_ocean_corner != null and grass_ocean_corner != null
and beach_ocean_corner != null
), ),
"The authored coastline additions must be available.", "The authored coastline additions must be available.",
) )
@ -455,6 +743,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
grass_ocean_edge == null grass_ocean_edge == null
or grass_beach_transition == null or grass_beach_transition == null
or grass_ocean_corner == null or grass_ocean_corner == null
or beach_ocean_corner == null
): ):
return return
var beach_zero := _find_variant(generator, &"chunk_0002", 0) 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 grass_edge_south := _find_variant(generator, &"chunk_0005", 3)
var transition_zero := _find_variant(generator, &"chunk_0006", 0) var transition_zero := _find_variant(generator, &"chunk_0006", 0)
var corner_zero := _find_variant(generator, &"chunk_0007", 0) var corner_zero := _find_variant(generator, &"chunk_0007", 0)
var beach_corner_zero := _find_variant(generator, &"chunk_0008", 0)
_check( _check(
beach_zero != null beach_zero != null
and grass_edge_zero != null and grass_edge_zero != null
and grass_edge_south != null and grass_edge_south != null
and transition_zero != 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.", "The unrotated coastline variants must be available.",
) )
if ( if (
@ -476,6 +767,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
and grass_edge_south != null and grass_edge_south != null
and transition_zero != null and transition_zero != null
and corner_zero != null and corner_zero != null
and beach_corner_zero != null
): ):
_check( _check(
grass_edge_zero.profile( 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.", "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_grass := _find_variant(generator, &"chunk_0000", 0)
var flat_sand := _find_variant(generator, &"chunk_0001", 0) var flat_sand := _find_variant(generator, &"chunk_0001", 0)
_check( _check(
@ -560,7 +926,58 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
), ),
"No transition rotation may accept sand behind it.", "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: for quarter_turns: int in 4:
var coast_variant := _find_variant( var coast_variant := _find_variant(
generator, generator,
@ -580,11 +997,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
), ),
"%s must never place its ocean edge inside the map." % stable_id, "%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 continue
var boundary_coordinate := Vector2i(3, 3) var boundary_coordinate := Vector2i(3, 3)
var ocean_edges := coast_variant.rotated_edge_mask( 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(): for edge_value: int in TerrainChunkTopology.Edge.values():
if (ocean_edges & (1 << edge_value)) == 0: if (ocean_edges & (1 << edge_value)) == 0:
@ -603,15 +1021,15 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
coast_variant, coast_variant,
boundary_coordinate, 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: func _validate_generation_summary(generator: TerrainChunkGenerator) -> void:
var summary := generator._build_summary() var summary := generator._build_summary()
_check( _check(
int(summary.get("variant_count", 0)) == 33, int(summary.get("variant_count", 0)) == 69,
"The current catalog must expose all 33 authored rotations.", "The current catalog must expose all 69 authored rotations.",
) )
_check( _check(
int(summary.get("solver_variant_count", 0)) int(summary.get("solver_variant_count", 0))
@ -664,6 +1082,7 @@ func _make_generator() -> TerrainChunkGenerator:
generator.build_collision = false generator.build_collision = false
generator.force_center_chunk_id = &"chunk_spawn" generator.force_center_chunk_id = &"chunk_spawn"
generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS) generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS)
generator.grass_sand_smoothing_enabled = true
generator.maximum_backtracks = 100000 generator.maximum_backtracks = 100000
return generator return generator
@ -811,6 +1230,27 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void:
straight_edge_neighbors == 2, straight_edge_neighbors == 2,
"Every grass corner must join two straight grass ocean edges.", "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": if placement.definition.stable_id != &"chunk_0001":
continue continue
var touches_coast := false 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, # Coastline meshes may intentionally stop before the positive-X edge,
# leaving the surrounding ocean visible. Positive X is the canonical # leaving the surrounding ocean visible. Positive X is the canonical
# authored ocean direction; Godot supplies the quarter-turn variants. # 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 # Corner pieces additionally leave canonical negative Y open. Their
# two marked ocean edges rotate together at runtime. # two marked outer edges rotate together at runtime.
allow_open_south_edge = "ocean_edge_corner" in label 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( bound_problems = _validate_bounds(
bounds, bounds,
tolerance, tolerance,

View file

@ -893,12 +893,14 @@ func _on_entry_text_submitted(_value: String) -> void:
func _send() -> void: func _send() -> void:
if _send_pending: if _send_pending:
return return
var body := _entry.text var body := NetworkChatProtocol.sanitize_body(_entry.text)
if body.strip_edges().is_empty(): if body.is_empty():
close_chat() close_chat()
return return
if _handle_editor_world_command(body):
return
_send_pending = true _send_pending = true
_pending_send_body = NetworkChatProtocol.sanitize_body(body) _pending_send_body = body
_entry.editable = false _entry.editable = false
if not _service.send_local_message( if not _service.send_local_message(
body, body,
@ -912,6 +914,82 @@ func _send() -> void:
_set_status("Sending…") _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: func _on_local_message_confirmed(message: Dictionary) -> void:
if ( if (
not _send_pending not _send_pending

View file

@ -168,6 +168,12 @@ func _on_pressed() -> void:
func _on_gui_input(event: InputEvent) -> void: func _on_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton 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 ( if (
mouse_event != null mouse_event != null
and mouse_event.button_index == MOUSE_BUTTON_RIGHT and mouse_event.button_index == MOUSE_BUTTON_RIGHT
@ -179,6 +185,11 @@ func _on_gui_input(event: InputEvent) -> void:
accept_event() accept_event()
func _release_pointer_focus() -> void:
if has_focus():
release_focus()
func _set_context_hovered(active: bool) -> void: func _set_context_hovered(active: bool) -> void:
_context_hovered = active _context_hovered = active
_update_context_presence() _update_context_presence()

View file

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

View file

@ -214,6 +214,7 @@ var _controller_text_entry_is_open: Callable
func _ready() -> void: func _ready() -> void:
_prioritize_surface_drawing_pointer_input()
_bite_prompt_button.pressed.connect(_on_bite_prompt_pressed) _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed)
_apply_active_bait_indicator_style() _apply_active_bait_indicator_style()
_refresh_active_bait_indicator() _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: func _input(event: InputEvent) -> void:
# The on-screen keyboard owns controller input while it is open. Its # The on-screen keyboard owns controller input while it is open. Its
# overlay is processed before the UI beneath it and consumes the event. # 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( func setup_data_and_identity(
data_root: PlayerDataRoot, data_root: PlayerDataRoot,
progression_saves: PlayerSaveManager,
identity_backups: IdentityBackupService, identity_backups: IdentityBackupService,
player_identity: PlayerIdentityStore, player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore, host_identity: HostIdentityStore,
@ -1275,6 +1308,7 @@ func setup_data_and_identity(
]: ]:
panel.setup_data_and_identity( panel.setup_data_and_identity(
data_root, data_root,
progression_saves,
identity_backups, identity_backups,
player_identity, player_identity,
host_identity, host_identity,
@ -1603,9 +1637,14 @@ func _can_toggle_gameplay_hud() -> bool:
func _refresh_gameplay_hud_visibility() -> void: 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 = ( var show_world_hud: bool = (
_gameplay_ui_enabled _gameplay_ui_enabled
and not _gameplay_hud_hidden and (not _gameplay_hud_hidden or fishing_override_active)
and not _system_menu_open and not _system_menu_open
and not _player_menu_open and not _player_menu_open
and not _shop_open and not _shop_open
@ -1660,6 +1699,7 @@ func set_storage_prompt_visible(
_storage_prompt.visible = ( _storage_prompt.visible = (
requested_visible requested_visible
and _gameplay_ui_enabled and _gameplay_ui_enabled
and not _gameplay_hud_hidden
and not _system_menu_open and not _system_menu_open
and not _player_menu_open and not _player_menu_open
and not _shop_open and not _shop_open
@ -1697,6 +1737,7 @@ func set_shop_prompt_visible(
_shop_prompt.visible = ( _shop_prompt.visible = (
requested_visible requested_visible
and _gameplay_ui_enabled and _gameplay_ui_enabled
and not _gameplay_hud_hidden
and not _system_menu_open and not _system_menu_open
and not _player_menu_open and not _player_menu_open
and not _shop_open and not _shop_open
@ -1923,6 +1964,7 @@ func _refresh_fishing_panel_visibility() -> void:
_gameplay_ui_enabled _gameplay_ui_enabled
and has_content and has_content
) )
_refresh_gameplay_hud_visibility()
func _on_bite_prompt_changed(prompt_visible: bool) -> void: 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: 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( static func _line_edit_keyboard_type(

View file

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

View file

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

View file

@ -125,6 +125,7 @@ var _environment_volume: float = 1.0
var _network_profile: NetworkProfilePreferences var _network_profile: NetworkProfilePreferences
var _network_session: NetworkSession var _network_session: NetworkSession
var _data_root: PlayerDataRoot var _data_root: PlayerDataRoot
var _progression_saves: PlayerSaveManager
var _identity_backups: IdentityBackupService var _identity_backups: IdentityBackupService
var _player_identity: PlayerIdentityStore var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore var _host_identity: HostIdentityStore
@ -134,6 +135,8 @@ var _controller_mapping_panel: ControllerMappingPanelType
var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType
var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType
var _data_folder_dialog: FileDialog var _data_folder_dialog: FileDialog
var _progression_import_dialog: FileDialog
var _progression_export_dialog: FileDialog
var _backup_file_dialog: FileDialog var _backup_file_dialog: FileDialog
var _export_file_dialog: FileDialog var _export_file_dialog: FileDialog
var _passphrase_dialog: ConfirmationDialog var _passphrase_dialog: ConfirmationDialog
@ -143,6 +146,7 @@ var _pending_identity_operation := ""
var _pending_identity_type := "" var _pending_identity_type := ""
var _pending_identity_path := "" var _pending_identity_path := ""
var _pending_import_data: Dictionary = {} var _pending_import_data: Dictionary = {}
var _pending_progression_path := ""
func _notification(what: int) -> void: func _notification(what: int) -> void:
@ -244,6 +248,8 @@ func _connect_controls() -> void:
%KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping) %KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping)
%OpenDataFolder.pressed.connect(_open_data_folder) %OpenDataFolder.pressed.connect(_open_data_folder)
%ChangeDataFolder.pressed.connect(_choose_data_folder) %ChangeDataFolder.pressed.connect(_choose_data_folder)
%ExportProgression.pressed.connect(_choose_progression_export)
%ImportProgression.pressed.connect(_choose_progression_import)
%ExportPlayerIdentity.pressed.connect( %ExportPlayerIdentity.pressed.connect(
_choose_identity_export.bind("player") _choose_identity_export.bind("player")
) )
@ -337,6 +343,7 @@ func setup_keyboard_mouse_mapping(
func setup_data_and_identity( func setup_data_and_identity(
data_root: PlayerDataRoot, data_root: PlayerDataRoot,
progression_saves: PlayerSaveManager,
identity_backups: IdentityBackupService, identity_backups: IdentityBackupService,
player_identity: PlayerIdentityStore, player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore, host_identity: HostIdentityStore,
@ -344,6 +351,7 @@ func setup_data_and_identity(
interface_fonts: InterfaceFontController, interface_fonts: InterfaceFontController,
) -> void: ) -> void:
_data_root = data_root _data_root = data_root
_progression_saves = progression_saves
_identity_backups = identity_backups _identity_backups = identity_backups
_player_identity = player_identity _player_identity = player_identity
_host_identity = host_identity _host_identity = host_identity
@ -611,6 +619,11 @@ func _refresh_data_page() -> void:
_data_root.override_active _data_root.override_active
or (_network_session != null and _network_session.is_session_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 = ( var fingerprint: String = (
_player_identity.fingerprint if _player_identity != null else "" _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." _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: func _choose_data_folder() -> void:
if _data_root == null or _data_root.override_active: if _data_root == null or _data_root.override_active:
_feedback.text = "the data folder is externally managed." _feedback.text = "the data folder is externally managed."

View file

@ -567,7 +567,7 @@ size_flags_horizontal = 3
focus_mode = 2 focus_mode = 2
focus_neighbor_right = NodePath("../ChangeDataFolder") focus_neighbor_right = NodePath("../ChangeDataFolder")
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression")
text = "open data folder" text = "open data folder"
[node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"] [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_mode = 2
focus_neighbor_left = NodePath("../OpenDataFolder") focus_neighbor_left = NodePath("../OpenDataFolder")
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression")
text = "change data folder" 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"] [node name="CopyPlayerFingerprint" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44) custom_minimum_size = Vector2(0, 44)
layout_mode = 2 layout_mode = 2
focus_mode = 2 focus_mode = 2
focus_neighbor_top = NodePath("../FolderRow/OpenDataFolder") focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression")
focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity") focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity")
text = "copy player fingerprint" 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 NetworkSessionType = preload("res://network/network_session.gd")
const SavedServerStoreType = preload("res://network/saved_server_store.gd") const SavedServerStoreType = preload("res://network/saved_server_store.gd")
const JoinGamePageType = preload("res://ui/network/join_game_page.gd") const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd")
const CurrencyPresentationType = preload( const CurrencyPresentationType = preload(
"res://ui/currency_presentation.gd" "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 INTRO_BRANDING_START_DELAY: float = 0.35
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0 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 continue_game_requested
signal quit_requested signal quit_requested
signal join_game_requested(endpoint: String) signal join_game_requested(endpoint: String)
enum ConfirmationAction { enum ConfirmationAction {
NONE, NONE,
NEW_GAME,
DELETE_SAVE, DELETE_SAVE,
} }
@ -142,12 +142,12 @@ enum ConfirmationAction {
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter @onready var _start_prompt_center: CenterContainer = %StartPromptCenter
@onready var _start_prompt_label: Label = %StartPromptLabel @onready var _start_prompt_label: Label = %StartPromptLabel
@onready var _join_game_page: JoinGamePageType = %JoinGamePage @onready var _join_game_page: JoinGamePageType = %JoinGamePage
@onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage
@onready var _credits_page: TitleCreditsPageType = %CreditsPage @onready var _credits_page: TitleCreditsPageType = %CreditsPage
var _save_manager: SaveManagerType var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType var _settings_manager: SettingsManagerType
var _inspection: SaveInspectionType var _inspection: SaveInspectionType
var _pending_new_game_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE
var _action_in_progress: bool = false var _action_in_progress: bool = false
var _decorative_rng := RandomNumberGenerator.new() var _decorative_rng := RandomNumberGenerator.new()
@ -210,6 +210,8 @@ func _ready() -> void:
) )
_new_game_button.pressed.connect(_on_new_game_pressed) _new_game_button.pressed.connect(_on_new_game_pressed)
_join_game_button.pressed.connect(_open_join_game) _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) _settings_button.pressed.connect(_open_settings)
_credits_button.pressed.connect(_open_credits) _credits_button.pressed.connect(_open_credits)
_delete_button.pressed.connect(_on_delete_pressed) _delete_button.pressed.connect(_on_delete_pressed)
@ -283,6 +285,7 @@ func reopen() -> void:
_reset_confirmation() _reset_confirmation()
_settings_panel.hide() _settings_panel.hide()
_join_game_page.close_page() _join_game_page.close_page()
_new_game_setup_page.close_page()
_credits_page.close_page() _credits_page.close_page()
_refresh_save_inspection() _refresh_save_inspection()
show() show()
@ -311,6 +314,7 @@ func open_join_game_page(endpoint: String = "") -> void:
_settings_panel.hide() _settings_panel.hide()
_confirmation_page.hide_page() _confirmation_page.hide_page()
_credits_page.close_page() _credits_page.close_page()
_new_game_setup_page.close_page()
_join_game_page.open_page(endpoint) _join_game_page.open_page(endpoint)
@ -326,6 +330,7 @@ func _open_join_game() -> void:
if ( if (
_action_in_progress _action_in_progress
or _is_confirmation_active() or _is_confirmation_active()
or _new_game_setup_page.visible
or _credits_page.visible or _credits_page.visible
): ):
return return
@ -346,6 +351,7 @@ func _open_credits() -> void:
or _is_confirmation_active() or _is_confirmation_active()
or _settings_panel.visible or _settings_panel.visible
or _join_game_page.visible or _join_game_page.visible
or _new_game_setup_page.visible
or _credits_page.visible or _credits_page.visible
): ):
return return
@ -583,6 +589,11 @@ func _input(event: InputEvent) -> void:
_join_game_page.request_back() _join_game_page.request_back()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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 ( if (
_title_settings_transition_active _title_settings_transition_active
or _title_entry_transition_active or _title_entry_transition_active
@ -629,6 +640,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
not _button_center.visible not _button_center.visible
or _is_confirmation_active() or _is_confirmation_active()
or _settings_panel.visible or _settings_panel.visible
or _new_game_setup_page.visible
or _credits_page.visible or _credits_page.visible
): ):
return false return false
@ -1031,19 +1043,59 @@ func _on_new_game_pressed() -> void:
"the existing save cannot be accessed safely." "the existing save cannot be accessed safely."
) )
return return
_pending_new_game_seed = PlayerSaveManager.roll_world_seed() _open_new_game_setup(
var warning := "start a new world?" _inspection.status != SaveInspectionType.Status.MISSING
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
) )
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: func _on_delete_pressed() -> void:
if ( if (
_action_in_progress _action_in_progress
@ -1071,14 +1123,6 @@ func _on_confirmation_accepted() -> void:
var action: ConfirmationAction = _confirmation_action var action: ConfirmationAction = _confirmation_action
_confirmation_page.lock_interaction() _confirmation_page.lock_interaction()
_action_in_progress = true _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(): if not _save_manager.delete_progression_save():
_feedback_label.text = _center_feedback_text( _feedback_label.text = _center_feedback_text(
"failed to delete saved progression." "failed to delete saved progression."
@ -1295,7 +1339,7 @@ func _on_settings_applied() -> void:
func _on_settings_closed() -> void: func _on_settings_closed() -> void:
pass _refresh_save_inspection()
func _on_settings_closing() -> void: 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="Script" path="res://ui/title_screen.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] [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/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/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="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"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
shader = ExtResource("4_water_shader") shader = ExtResource("4_water_shader")
@ -225,7 +226,7 @@ unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "v0.13.1-alpha" text = "v0.14.0-alpha"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
@ -486,3 +487,13 @@ anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 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, total: int,
badge_name: StringName = &"SupplyQuantityBadge", badge_name: StringName = &"SupplyQuantityBadge",
top_margin: float = SUPPLY_BADGE_EDGE_MARGIN, top_margin: float = SUPPLY_BADGE_EDGE_MARGIN,
x_offset: float = SUPPLY_BADGE_X_OFFSET,
) -> Panel: ) -> Panel:
var badge := Panel.new() var badge := Panel.new()
badge.name = badge_name badge.name = badge_name
@ -68,7 +69,7 @@ static func add_supply_quantity_badge(
quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
badge.add_child(quantity_label) badge.add_child(quantity_label)
configure_supply_quantity_badge( configure_supply_quantity_badge(
badge, quantity_label, current, total, top_margin badge, quantity_label, current, total, top_margin, x_offset
) )
return badge return badge
@ -79,6 +80,7 @@ static func configure_supply_quantity_badge(
current: int, current: int,
total: int, total: int,
top_margin: float = SUPPLY_BADGE_EDGE_MARGIN, top_margin: float = SUPPLY_BADGE_EDGE_MARGIN,
x_offset: float = SUPPLY_BADGE_X_OFFSET,
) -> void: ) -> void:
var supply_text := supply_quantity_text(current, total) var supply_text := supply_quantity_text(current, total)
var text_width: float = TuffyFont.get_string_size( 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), ceilf(text_width + SUPPLY_BADGE_HORIZONTAL_PADDING),
) )
badge.offset_left = ( 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_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.offset_bottom = top_margin + SUPPLY_BADGE_HEIGHT
badge.pivot_offset = Vector2( badge.pivot_offset = Vector2(
badge_width * 0.5, badge_width * 0.5,

View file

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

View file

@ -6,10 +6,11 @@
[sub_resource type="Resource" id="Rule_grass_tree"] [sub_resource type="Resource" id="Rule_grass_tree"]
script = ExtResource("2_rule") script = ExtResource("2_rule")
procedural_group = &"grass_tree" procedural_group = &"grass_tree"
placement_chance = 9000 placement_chance = 8200
adjacency_bonus = 1000 adjacency_bonus = 1200
maximum_density = 10000 maximum_density = 8500
minimum_placements = 2 minimum_placements = 2
placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3") allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3")
[sub_resource type="Resource" id="Rule_grass_detail"] [sub_resource type="Resource" id="Rule_grass_detail"]
@ -26,6 +27,6 @@ script = ExtResource("1_definition")
stable_id = &"biome_forest" stable_id = &"biome_forest"
label = "forest" label = "forest"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
forbidden_chunk_tags = PackedStringArray("spawn") forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface")
region_weight = 1.4 region_weight = 1.4
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")]) 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"] [sub_resource type="Resource" id="Rule_grass_tree"]
script = ExtResource("2_rule") script = ExtResource("2_rule")
procedural_group = &"grass_tree" procedural_group = &"grass_tree"
placement_chance = 9000 placement_chance = 8200
adjacency_bonus = 1000 adjacency_bonus = 1200
maximum_density = 10000 maximum_density = 8500
minimum_placements = 2 minimum_placements = 2
placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_pine") allowed_prop_ids = PackedStringArray("prop_pine")
[sub_resource type="Resource" id="Rule_grass_detail"] [sub_resource type="Resource" id="Rule_grass_detail"]
@ -26,6 +27,6 @@ script = ExtResource("1_definition")
stable_id = &"biome_pine_forest" stable_id = &"biome_pine_forest"
label = "pine forest" label = "pine forest"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
forbidden_chunk_tags = PackedStringArray("spawn") forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface")
region_weight = 1.2 region_weight = 1.2
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")]) 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" stable_id = &"biome_plains"
label = "plains" label = "plains"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
forbidden_chunk_tags = PackedStringArray("mixed_surface")
region_weight = 1.0 region_weight = 1.0
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")]) 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/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
mipmaps/generate=true mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" roughness/src_normal=""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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