Expand gameplay systems and interface controls

This commit is contained in:
Alexander Sellite 2026-08-20 17:53:22 -04:00
parent 09b7a78533
commit 2710544070
46 changed files with 2072 additions and 153 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -124,6 +124,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
const TITLE_MUSIC_PATH: String = (
"res://audio/music/title/as_in_four_wolves.ogg"
)
const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav"
const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3"
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
const PLAYER_MENU_PATTERN_SCALE: float = 0.85
@ -138,6 +139,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
@export_category("Title Music")
@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0
@export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0
@export_range(-40.0, 0.0, 0.5) var new_game_music_volume_db: float = -6.0
@onready var _test_world: TestWorldType = $TestWorld
@onready var _player: PlayerType = %Player
@ -154,6 +156,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
)
@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
@onready var _title_music: AudioStreamPlayer = %TitleMusic
@onready var _new_game_music: AudioStreamPlayer = %NewGameMusic
@onready var _dusk_music: AudioStreamPlayer = %DuskMusic
@onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation
@onready var _pixelation_reset: PixelationResetOverlayType = (
@ -356,6 +359,7 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void:
func _configure_audio_performance_profile(light_profile: bool) -> void:
_title_music.stream = _load_optional_audio_stream(TITLE_MUSIC_PATH)
_new_game_music.stream = _load_optional_audio_stream(NEW_GAME_MUSIC_PATH)
_dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH)
if light_profile:
_shoreline_ambience.set_audio_enabled(false)
@ -409,6 +413,14 @@ func _start_dedicated_server() -> void:
):
_fail_dedicated_server("The server operator list is invalid.")
return
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_generated_world(config.world_seed, false):
_fail_dedicated_server("The configured world seed could not be generated.")
return
@ -636,6 +648,7 @@ func _initialize_application(dedicated: bool) -> void:
_world_time,
_world_weather,
_player_jobs,
_player,
)
_player_jobs.set_save_manager(_save_manager)
_save_manager.set_autosave_enabled(false)
@ -747,7 +760,11 @@ func _initialize_application(dedicated: bool) -> void:
_asset_reservations,
_player.inventory_layout,
)
_player_jobs.bind_authoritative_services(_network_fishing, _network_sale)
_player_jobs.bind_authoritative_services(
_network_fishing,
_network_sale,
_network_world_spawns,
)
_network_shop.setup(
_network_session,
_player_spawn_service,
@ -824,6 +841,7 @@ func _initialize_application(dedicated: bool) -> void:
)
_game_ui.setup_data_and_identity(
_data_root,
_save_manager,
_identity_backups,
_player_identity,
_host_identity,
@ -1571,7 +1589,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void:
)
):
_dusk_music_played_for_natural_day = true
if _dusk_music.stream != null:
if (
_dusk_music.stream != null
and not _new_game_music.playing
):
_dusk_music.play(0.0)
@ -1675,23 +1696,28 @@ func _resize_native_overlays() -> void:
func _on_new_game_requested(world_seed: int) -> void:
if _gameplay_started or _quit_in_progress:
return
_start_new_game_music()
if not _apply_generated_world(world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not generate that world seed. Try rolling another world."
)
return
if not _prepare_host_world_seed(world_seed):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not prepare the generated world for hosting."
)
return
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible."
)
@ -2120,6 +2146,7 @@ func _on_quit_requested() -> void:
func _show_title_music(restart_from_beginning: bool = false) -> void:
_new_game_music.stop()
_title_music_requested = true
_replace_title_music_transition()
if _title_music.stream == null:
@ -2131,6 +2158,19 @@ func _show_title_music(restart_from_beginning: bool = false) -> void:
_title_music.play(0.0)
func _start_new_game_music() -> void:
if _new_game_music.stream == null:
return
_title_music_requested = false
_replace_title_music_transition()
_title_music.stop()
_title_music.volume_db = title_music_volume_db
_dusk_music.stop()
_new_game_music.stop()
_new_game_music.volume_db = new_game_music_volume_db
_new_game_music.play(0.0)
func _fade_out_title_music(on_complete: Callable = Callable()) -> void:
_title_music_requested = false
var generation: int = _replace_title_music_transition()
@ -2196,6 +2236,9 @@ func _exit_tree() -> void:
if is_instance_valid(_title_music):
_title_music.stop()
_title_music.stream = null
if is_instance_valid(_new_game_music):
_new_game_music.stop()
_new_game_music.stream = null
if is_instance_valid(_dusk_music):
_dusk_music.stop()
_dusk_music.stream = null

View file

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

View file

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

View file

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

View file

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

View file

@ -107,6 +107,11 @@ fish_primary={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
reel_alternate={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":96,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
camera_drag={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)

View file

@ -32,8 +32,11 @@ const WorldWeatherServiceType = preload(
"res://world/world_weather_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 MAX_SAFE_BALANCE: int = 1000000000000
const DEFAULT_WORLD_SEED: int = 13001
@ -49,6 +52,8 @@ class LoadSnapshot:
var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = []
var unlocked_bait_ids: Array[StringName] = []
var active_bait_id: StringName
var active_lure_id: StringName
var hotbar_slots: Array[StringName] = []
var fish_hotbar_slots: Array[StringName] = []
var selected_hotbar_slot: int = 0
@ -87,6 +92,7 @@ var _experience: PlayerExperienceType
var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType
var _player: PlayerType
var _autosave_timer: Timer
var _is_configured: bool = false
var _is_restoring: bool = false
@ -112,6 +118,21 @@ func _backup_path() -> String:
return _save_path + ".backup"
func _codec_scratch_path() -> String:
return _save_path + ".codec.tmp"
func _legacy_save_path() -> String:
return _save_path.get_base_dir().path_join(LEGACY_SAVE_FILENAME)
func _read_path() -> String:
if FileAccess.file_exists(_save_path):
return _save_path
var legacy: String = _legacy_save_path()
return legacy if FileAccess.file_exists(legacy) else ""
func _ready() -> void:
_autosave_timer = Timer.new()
_autosave_timer.one_shot = true
@ -135,6 +156,7 @@ func setup(
world_time: WorldTimeServiceType,
world_weather: WorldWeatherServiceType,
jobs: PlayerJobServiceType,
player: PlayerType,
) -> void:
_inventory = inventory
_collection_log = collection_log
@ -151,6 +173,7 @@ func setup(
_world_time = world_time
_world_weather = world_weather
_jobs = jobs
_player = player
_is_configured = (
_inventory != null
and _collection_log != null
@ -167,6 +190,7 @@ func setup(
and _world_time != null
and _world_weather != null
and _jobs != null
and _player != null
)
if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.")
@ -209,6 +233,10 @@ func setup(
_experience.experience_changed.connect(_on_experience_changed)
if not _jobs.changed.is_connected(_mark_dirty):
_jobs.changed.connect(_mark_dirty)
if not _player.active_bait_changed.is_connected(_on_active_tackle_changed):
_player.active_bait_changed.connect(_on_active_tackle_changed)
if not _player.active_lure_changed.is_connected(_on_active_tackle_changed):
_player.active_lure_changed.connect(_on_active_tackle_changed)
func load_player_data() -> bool:
@ -216,24 +244,22 @@ func load_player_data() -> bool:
return false
_automatic_saving_blocked = false
_recover_interrupted_write()
if _save_path.is_empty() or not FileAccess.file_exists(_save_path):
var read_path: String = _read_path()
if _save_path.is_empty() or read_path.is_empty():
_is_dirty = false
return true
var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
_handle_corrupt_save("Unable to open player save.")
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
_handle_corrupt_save("Player save could not be decoded.", read_path)
return false
var json_text: String = save_file.get_as_text()
save_file.close()
_expected_hash = PortableFileGuard.hash_file(_save_path)
var json := JSON.new()
var parse_error: Error = json.parse(json_text)
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
_handle_corrupt_save("Player save contains malformed JSON.")
return false
var save_data: Dictionary = json.data
_expected_hash = (
PortableFileGuard.hash_file(_save_path)
if read_path == _save_path else ""
)
var save_data: Dictionary = decoded["data"]
var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION:
_automatic_saving_blocked = true
@ -250,13 +276,16 @@ func load_player_data() -> bool:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
_handle_corrupt_save(
"Player save version %d is unsupported." % version
"Player save version %d is unsupported." % version,
read_path,
)
return false
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null:
_handle_corrupt_save("Player save failed structural validation.")
_handle_corrupt_save(
"Player save failed structural validation.", read_path
)
return false
_is_restoring = true
@ -277,6 +306,7 @@ func load_player_data() -> bool:
_bag.replace_all_items(snapshot.bag_items)
and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids)
)
var tackle_restored: bool = _restore_tackle_selection(snapshot)
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
snapshot.reel_speed_level,
snapshot.barrier_power_level
@ -316,6 +346,7 @@ func load_player_data() -> bool:
or not collection_restored
or not wallet_restored
or not bag_restored
or not tackle_restored
or not hotbar_restored
or not upgrades_restored
or not cooler_restored
@ -329,7 +360,7 @@ func load_player_data() -> bool:
push_error(
(
"Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s "
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s"
)
@ -338,6 +369,7 @@ func load_player_data() -> bool:
collection_restored,
wallet_restored,
bag_restored,
tackle_restored,
hotbar_restored,
upgrades_restored,
cooler_restored,
@ -352,6 +384,8 @@ func load_player_data() -> bool:
return false
_is_dirty = false
if read_path == _legacy_save_path():
_migrate_legacy_plaintext_save(save_data, read_path)
print(
"Loaded player save version %d with %d catches."
% [SAVE_VERSION, snapshot.catches.size()]
@ -362,24 +396,21 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new()
_recover_interrupted_write()
result.has_primary_file = FileAccess.file_exists(_save_path)
var read_path: String = _read_path()
result.has_primary_file = not read_path.is_empty()
if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING
result.message = "no save found."
return result
var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
result.status = SaveInspectionType.Status.IO_ERROR
result.message = "the save could not be read."
return result
var json := JSON.new()
var parse_error: Error = json.parse(save_file.get_as_text())
save_file.close()
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
result.status = SaveInspectionType.Status.MALFORMED
result.message = "the save is corrupt and was preserved."
return result
var save_data: Dictionary = json.data
var save_data: Dictionary = decoded["data"]
result.detected_version = _read_integer(save_data.get("save_version"), -1)
if result.detected_version > SAVE_VERSION:
result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION
@ -408,6 +439,88 @@ func inspect_save() -> SaveInspectionType:
return result
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) -> bool:
if not _is_configured:
return false
@ -431,9 +544,19 @@ static func roll_world_seed() -> int:
func delete_progression_save() -> bool:
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
for primary_path: String in [_save_path, _legacy_save_path()]:
if (
FileAccess.file_exists(primary_path)
and not _remove_if_present(primary_path)
):
return false
for path: String in [_temp_path(), _backup_path()]:
for path: String in [
_temp_path(),
_backup_path(),
_codec_scratch_path(),
_legacy_save_path() + ".tmp",
_legacy_save_path() + ".backup",
]:
if FileAccess.file_exists(path) and not _remove_if_present(path):
push_warning("Unable to remove stale player-save auxiliary file.")
if _autosave_timer != null:
@ -485,16 +608,8 @@ func save_now() -> bool:
var save_data: Dictionary = _build_save_dictionary()
if save_data.is_empty():
return false
var json_text: String = JSON.stringify(save_data, "\t")
if json_text.is_empty():
return false
var result := PortableFileGuard.write_guarded(
_save_path,
json_text.to_utf8_buffer(),
_expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
var result: Dictionary = _write_current_save_data(
save_data, _expected_hash
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(
@ -574,6 +689,17 @@ func _build_save_dictionary() -> Dictionary:
)
):
return {}
var active_bait_id: StringName = _player.active_bait_id
var active_lure_id: StringName = _player.active_lure_id
if (
(not active_bait_id.is_empty() and not _valid_active_tackle(
active_bait_id, true
))
or (not active_lure_id.is_empty() and not _valid_active_tackle(
active_lure_id, false
))
):
return {}
return {
"save_version": SAVE_VERSION,
@ -592,6 +718,10 @@ func _build_save_dictionary() -> Dictionary:
"items": serialized_items,
"unlocked_bait_ids": serialized_unlocked_baits,
},
"tackle": {
"active_bait_id": String(active_bait_id),
"active_lure_id": String(active_lure_id),
},
"hotbar": {
"selected_slot": _hotbar.get_selected_slot(),
"slots": serialized_slots,
@ -620,6 +750,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
or typeof(save_data.get("collection")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
or typeof(save_data.get("tackle")) != TYPE_DICTIONARY
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
@ -630,6 +761,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var collection_data: Dictionary = save_data["collection"]
var inventory_data: Dictionary = save_data["inventory"]
var bag_data: Dictionary = save_data["bag"]
var tackle_data: Dictionary = save_data["tackle"]
var hotbar_data: Dictionary = save_data["hotbar"]
var inventory_layout_data: Dictionary = save_data["inventory_layout"]
var experience_data: Dictionary = save_data["experience"]
@ -665,6 +797,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY
)
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
or typeof(tackle_data.get("active_bait_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or typeof(tackle_data.get("active_lure_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or not hotbar_data.has("selected_slot")
or not experience_data.has("total_experience")
):
@ -897,6 +1035,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
):
seen_unlocked_baits[owned.item_id] = true
snapshot.unlocked_bait_ids.append(owned.item_id)
snapshot.active_bait_id = _validated_tackle_id(
StringName(str(tackle_data.get("active_bait_id", ""))),
true,
seen_items,
)
snapshot.active_lure_id = _validated_tackle_id(
StringName(str(tackle_data.get("active_lure_id", ""))),
false,
seen_items,
)
var slot_values: Array = hotbar_data["slots"]
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
@ -995,6 +1143,8 @@ func _migrate_save(
migrated = _migrate_version_7_to_8(migrated)
8:
migrated = _migrate_version_8_to_9(migrated)
9:
migrated = _migrate_version_9_to_10(migrated)
_:
return {}
if migrated.is_empty():
@ -1242,6 +1392,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary:
return migrated
func _migrate_version_9_to_10(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
migrated["tackle"] = {
"active_bait_id": "",
"active_lure_id": "",
}
migrated["save_version"] = 10
return migrated
func _mark_dirty() -> void:
if (
_is_restoring
@ -1291,33 +1451,168 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void:
_mark_dirty()
func _on_active_tackle_changed(_item_id: StringName) -> void:
_mark_dirty()
func _on_autosave_timeout() -> void:
if _is_dirty:
save_now()
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(_save_path):
_remove_if_present(_temp_path())
_remove_if_present(_backup_path())
_remove_if_present(_codec_scratch_path())
_recover_path_write(_save_path)
_recover_path_write(_legacy_save_path())
func _recover_path_write(path: String) -> void:
var temporary: String = path + ".tmp"
var backup: String = path + ".backup"
if FileAccess.file_exists(path):
_remove_if_present(temporary)
_remove_if_present(backup)
return
if FileAccess.file_exists(_backup_path()):
_rename_file(_backup_path(), _save_path)
_remove_if_present(_temp_path())
if FileAccess.file_exists(backup):
_rename_file(backup, path)
_remove_if_present(temporary)
func _handle_corrupt_save(message: String) -> void:
func _write_current_save_data(
save_data: Dictionary,
expected_hash: String,
) -> Dictionary:
var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save(
save_data,
_codec_scratch_path(),
)
if bytes.is_empty():
return {"ok": false}
return PortableFileGuard.write_guarded(
_save_path,
bytes,
expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
)
func _prepare_external_save_data(value: Variant) -> Dictionary:
if typeof(value) != TYPE_DICTIONARY:
return {"ok": false, "message": "progression data is malformed."}
var save_data: Dictionary = (value as Dictionary).duplicate(true)
var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION:
return {
"ok": false,
"message": "this progression belongs to a newer game version.",
}
if version < 1:
return {"ok": false, "message": "the progression version is unsupported."}
if version != SAVE_VERSION:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
return {
"ok": false,
"message": "the progression version is unsupported.",
}
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null:
return {"ok": false, "message": "the progression is structurally invalid."}
return {
"ok": true,
"data": save_data,
"catch_count": snapshot.catches.size(),
"wallet_balance": snapshot.wallet_balance,
"discovered_species_count": snapshot.discovered_ids.size(),
"world_seed": snapshot.world_seed,
}
func _migrate_legacy_plaintext_save(
save_data: Dictionary,
legacy_path: String,
) -> void:
var result: Dictionary = _write_current_save_data(save_data, "")
if not bool(result.get("ok", false)):
push_warning("Legacy progression loaded but could not be encrypted yet.")
return
_expected_hash = str(result.get("hash", ""))
if not _archive_replaced_save(legacy_path, "plaintext-migration"):
push_warning(
"Legacy progression was encrypted, but its migration backup failed."
)
return
_remove_legacy_save_files()
print("Upgraded legacy plaintext progression to the opaque save format.")
func _archive_replaced_save(
source_path: String,
reason: String = "before-import",
) -> bool:
if source_path.is_empty() or not FileAccess.file_exists(source_path):
return true
var timestamp: String = Time.get_datetime_string_from_system().replace(
":", "-"
)
var destination: String = _data_root.root_path.path_join(
"backups/saves/player-save-%s-%s%s"
% [reason, timestamp, ARCHIVE_EXTENSION]
)
if FileAccess.file_exists(destination):
destination = destination.trim_suffix(ARCHIVE_EXTENSION) + (
"-%s%s" % [Time.get_ticks_usec(), ARCHIVE_EXTENSION]
)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
source_path == _legacy_save_path(),
)
var bytes: PackedByteArray
if bool(decoded.get("ok", false)):
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if bool(prepared.get("ok", false)):
bytes = ProgressionSaveCodec.encode_archive(
prepared["data"], destination + ".codec.tmp"
)
if bytes.is_empty():
bytes = PortableFileGuard.read_bytes(source_path)
destination = (
destination.trim_suffix(ARCHIVE_EXTENSION) + ".preserved"
)
if bytes.is_empty():
return false
var result: Dictionary = PortableFileGuard.write_guarded(
destination,
bytes,
"",
_data_root.conflict_directory(),
_data_root.device_id,
)
return bool(result.get("ok", false))
func _remove_legacy_save_files() -> void:
for path: String in [
_legacy_save_path(),
_legacy_save_path() + ".tmp",
_legacy_save_path() + ".backup",
]:
_remove_if_present(path)
func _handle_corrupt_save(message: String, source_path: String) -> void:
push_warning(message)
_restore_defaults()
if not FileAccess.file_exists(_save_path):
if not FileAccess.file_exists(source_path):
return
var timestamp: int = int(Time.get_unix_time_from_system())
var corrupt_path: String = (
"%s.corrupt-%d" % [_save_path, timestamp]
"%s.corrupt-%d" % [source_path, timestamp]
)
if FileAccess.file_exists(corrupt_path):
corrupt_path += "-%d" % Time.get_ticks_usec()
if not _rename_file(_save_path, corrupt_path):
if not _rename_file(source_path, corrupt_path):
_automatic_saving_blocked = true
push_warning(
"Corrupt player save was left in place; automatic saving is "
@ -1350,6 +1645,8 @@ func _restore_defaults() -> void:
_bag.replace_all_items(default_items)
var default_unlocked_baits: Array[StringName] = []
_bag.replace_unlocked_bait_ids(default_unlocked_baits)
_player.unequip_bait()
_player.unequip_lure()
_fishing_upgrades.reset_to_defaults()
_cooler_capacity.reset_to_defaults()
_inventory_layout.reset_to_defaults()
@ -1366,6 +1663,53 @@ func _restore_defaults() -> void:
_is_dirty = false
func _valid_active_tackle(item_id: StringName, bait: bool) -> bool:
var item = _item_catalog.get_item_by_id(item_id)
return (
item != null
and item.is_available()
and (item.is_bait() if bait else item.is_lure())
and _bag.owns_item(item_id)
)
func _validated_tackle_id(
item_id: StringName,
bait: bool,
seen_items: Dictionary[StringName, bool],
) -> StringName:
if item_id.is_empty():
return StringName()
var item = _item_catalog.get_item_by_id(item_id)
if (
item == null
or not item.is_available()
or not seen_items.has(item_id)
or not (item.is_bait() if bait else item.is_lure())
):
push_warning("Skipped invalid saved active tackle '%s'." % String(item_id))
return StringName()
return item_id
func _restore_tackle_selection(snapshot: LoadSnapshot) -> bool:
_player.unequip_bait()
_player.unequip_lure()
var bait_restored: bool = (
snapshot.active_bait_id.is_empty()
or _player.equip_bait(
_item_catalog.get_item_by_id(snapshot.active_bait_id)
)
)
var lure_restored: bool = (
snapshot.active_lure_id.is_empty()
or _player.equip_lure(
_item_catalog.get_item_by_id(snapshot.active_lure_id)
)
)
return bait_restored and lure_restored
func _read_integer(
value: Variant,
invalid_value: int,

View file

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

View file

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

View file

@ -22,6 +22,7 @@ readonly -a QUICK_TESTS=(
"tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd"
"tests/digging_prototype_validation.gd"
"tests/equipment_catalog_validation.gd"
"tests/exported_decal_hotfix_validation.gd"
"tests/file_dialog_controller_navigation_validation.gd"
"tests/fish_catalog_content_validation.gd"
@ -72,10 +73,12 @@ readonly -a HOST_TESTS=(
"tests/gathering_showcase_validation.gd"
"tests/inventory_storage_persistence_validation.gd"
"tests/job_system_validation.gd"
"tests/progression_archive_validation.gd"
"tests/surface_drawing_runtime_validation.gd"
)
readonly -a NETWORK_TESTS=(
"tests/chat_privacy_multiplayer_validation.gd"
"tests/economy_regression_validation.gd"
"tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1295,7 +1295,7 @@ func _on_settings_applied() -> void:
func _on_settings_closed() -> void:
pass
_refresh_save_inspection()
func _on_settings_closing() -> void:

View file

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