Expand gameplay systems and interface controls
This commit is contained in:
parent
09b7a78533
commit
2710544070
46 changed files with 2072 additions and 153 deletions
BIN
audio/music/world/tulips.wav
Normal file
BIN
audio/music/world/tulips.wav
Normal file
Binary file not shown.
24
audio/music/world/tulips.wav.import
Normal file
24
audio/music/world/tulips.wav.import
Normal 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
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
items/catalog/fishing_net.tres
Normal file
16
items/catalog/fishing_net.tres
Normal 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
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
47
main/main.gd
47
main/main.gd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
179
save/progression_save_codec.gd
Normal file
179
save/progression_save_codec.gd
Normal 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)
|
||||
1
save/progression_save_codec.gd.uid
Normal file
1
save/progression_save_codec.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://lad8mvedktww
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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://")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
120
tests/chat_privacy_multiplayer_validation.gd
Normal file
120
tests/chat_privacy_multiplayer_validation.gd
Normal 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()
|
||||
1
tests/chat_privacy_multiplayer_validation.gd.uid
Normal file
1
tests/chat_privacy_multiplayer_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ivtqrnppnmli
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", [])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
135
tests/progression_archive_validation.gd
Normal file
135
tests/progression_archive_validation.gd
Normal 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)
|
||||
1
tests/progression_archive_validation.gd.uid
Normal file
1
tests/progression_archive_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ccl47ftc1cu5l
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1295,7 +1295,7 @@ func _on_settings_applied() -> void:
|
|||
|
||||
|
||||
func _on_settings_closed() -> void:
|
||||
pass
|
||||
_refresh_save_inspection()
|
||||
|
||||
|
||||
func _on_settings_closing() -> void:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue