diff --git a/fish/fish_quality.gd b/fish/fish_quality.gd index f851e66..d43039b 100644 --- a/fish/fish_quality.gd +++ b/fish/fish_quality.gd @@ -16,6 +16,10 @@ const ALL_TIERS_MASK: int = (1 << TIER_COUNT) - 1 # multipliers later without changing catch serialization or tier identity. const BASE_ROLL_WEIGHTS: Array[float] = [40.0, 32.0, 18.0, 8.0, 2.0] const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0] +# Provisional challenge curve. Fish profiles continue to own their baseline +# barrier health; quality scales that authored baseline before player upgrades +# apply damage. This keeps future rods, bait, and lures on one shared seam. +const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25] const DISPLAY_NAMES: PackedStringArray = [ "boring", "average", @@ -52,6 +56,24 @@ static func apply_sale_value(base_value: int, quality: int) -> int: ) +static func barrier_health_multiplier(quality: int) -> float: + return ( + BARRIER_HEALTH_MULTIPLIERS[quality] + if is_valid(quality) + else BARRIER_HEALTH_MULTIPLIERS[Tier.BORING] + ) + + +static func apply_barrier_health(base_health: int, quality: int) -> int: + return maxi( + ceili( + float(maxi(base_health, 1)) + * barrier_health_multiplier(quality) + ), + 1, + ) + + static func roll( rng: RandomNumberGenerator, weight_multipliers: Array[float] = [], diff --git a/fishing/bass_catch_profile.tres b/fishing/bass_catch_profile.tres index 077b229..6385eec 100644 --- a/fishing/bass_catch_profile.tres +++ b/fishing/bass_catch_profile.tres @@ -11,6 +11,3 @@ barrier_health_max = 7 first_barrier_margin = 0.14 final_barrier_margin = 0.1 minimum_barrier_spacing = 0.14 -chase_start_delay = 1.0 -chase_start_offset = 0.16 -chase_speed = 0.145 diff --git a/fishing/carp_catch_profile.tres b/fishing/carp_catch_profile.tres index bc3f5dc..94a8470 100644 --- a/fishing/carp_catch_profile.tres +++ b/fishing/carp_catch_profile.tres @@ -11,6 +11,3 @@ barrier_health_max = 8 first_barrier_margin = 0.13 final_barrier_margin = 0.09 minimum_barrier_spacing = 0.14 -chase_start_delay = 1.1 -chase_start_offset = 0.18 -chase_speed = 0.15 diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd index 53617df..6eb058e 100644 --- a/fishing/catch_controller.gd +++ b/fishing/catch_controller.gd @@ -4,6 +4,7 @@ extends Node const CatchDifficultyProfileType = preload( "res://fishing/catch_difficulty_profile.gd" ) +const FishQualityType = preload("res://fish/fish_quality.gd") signal encounter_updated( progress: float, @@ -42,6 +43,13 @@ class Barrier: maximum_health = barrier_health +# Fight movement uses one shared baseline. Species and quality difficulty comes +# from barrier placement and health, while reel upgrades affect only the +# player's progress speed. +const CHASE_START_DELAY: float = 0.5 +const CHASE_START_OFFSET: float = 0.04 +const CHASE_SPEED: float = 0.07 + @export_category("Accessibility") @export var auto_click_enabled: bool = false @export_range(0.05, 2.0, 0.01) var auto_click_interval: float = 0.20 @@ -60,9 +68,8 @@ var _active_barrier_index: int = -1 var _reel_input_held: bool = false var _reel_speed: float = 0.0 var _click_power: int = 1 -var _chase_start_delay: float = 0.0 +var _fish_quality: int = FishQualityType.Tier.BORING var _chase_delay_remaining: float = 0.0 -var _chase_speed: float = 0.0 var _failure_epsilon: float = 0.0001 var _auto_click_accumulator: float = 0.0 var _rng: RandomNumberGenerator = RandomNumberGenerator.new() @@ -103,6 +110,7 @@ func start_encounter( profile: CatchDifficultyProfileType, reel_speed: float, click_power: int, + fish_quality: int = FishQualityType.Tier.BORING, ) -> void: reset() if profile == null: @@ -111,10 +119,13 @@ func start_encounter( _reel_speed = maxf(reel_speed, 0.0) _click_power = maxi(click_power, 1) - _chase_start_delay = maxf(profile.chase_start_delay, 0.0) - _chase_delay_remaining = _chase_start_delay - chase_progress = -maxf(profile.chase_start_offset, 0.01) - _chase_speed = maxf(profile.chase_speed, 0.0) + _fish_quality = ( + fish_quality + if FishQualityType.is_valid(fish_quality) + else FishQualityType.Tier.BORING + ) + _chase_delay_remaining = CHASE_START_DELAY + chase_progress = -CHASE_START_OFFSET _seed_encounter_rng() _generate_barriers(profile) progress = 0.0 @@ -127,12 +138,13 @@ func start_authoritative_encounter( reel_speed: float, click_power: int, seed: int, + fish_quality: int = FishQualityType.Tier.BORING, ) -> void: var previous_test_mode: bool = use_deterministic_test_seed var previous_seed: int = deterministic_test_seed use_deterministic_test_seed = true deterministic_test_seed = seed - start_encounter(profile, reel_speed, click_power) + start_encounter(profile, reel_speed, click_power, fish_quality) use_deterministic_test_seed = previous_test_mode deterministic_test_seed = previous_seed @@ -168,9 +180,8 @@ func reset() -> void: _reel_input_held = false _reel_speed = 0.0 _click_power = 1 - _chase_start_delay = 0.0 + _fish_quality = FishQualityType.Tier.BORING _chase_delay_remaining = 0.0 - _chase_speed = 0.0 _auto_click_accumulator = 0.0 emit_signal( "encounter_updated", @@ -215,7 +226,7 @@ func _update_chase(delta: float) -> void: if active_delta <= 0.0: return chase_progress = minf( - chase_progress + _chase_speed * active_delta, + chase_progress + CHASE_SPEED * active_delta, 1.0 ) @@ -302,7 +313,14 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void: + spacing * float(barrier_index) + random_offsets[barrier_index] * random_slack ) - var health: int = _rng.randi_range(health_range.x, health_range.y) + var base_health: int = _rng.randi_range( + health_range.x, + health_range.y, + ) + var health: int = FishQualityType.apply_barrier_health( + base_health, + _fish_quality, + ) _barriers.append(Barrier.new(position, health)) diff --git a/fishing/catch_difficulty_profile.gd b/fishing/catch_difficulty_profile.gd index 3ad20ad..bd2508c 100644 --- a/fishing/catch_difficulty_profile.gd +++ b/fishing/catch_difficulty_profile.gd @@ -8,9 +8,6 @@ extends Resource @export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15 @export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10 @export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15 -@export_range(0.0, 5.0, 0.05) var chase_start_delay: float = 1.0 -@export_range(0.01, 0.5, 0.01) var chase_start_offset: float = 0.15 -@export_range(0.0, 2.0, 0.01) var chase_speed: float = 0.14 func get_barrier_count_range() -> Vector2i: diff --git a/fishing/common_catch_profile.tres b/fishing/common_catch_profile.tres index 1169e2e..4af3284 100644 --- a/fishing/common_catch_profile.tres +++ b/fishing/common_catch_profile.tres @@ -11,6 +11,3 @@ barrier_health_max = 7 first_barrier_margin = 0.15 final_barrier_margin = 0.1 minimum_barrier_spacing = 0.15 -chase_start_delay = 1.0 -chase_start_offset = 0.15 -chase_speed = 0.14 diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index c694461..a12427b 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -919,6 +919,10 @@ func _activate_bite() -> void: state = FishingState.FIGHTING _state_time_remaining = 0.0 _withdrawal_input_held = false + _pending_catch = _fish_selector.create_catch(_selected_fish) + if _pending_catch == null or not _pending_catch.is_valid(): + _cancel_attempt() + return bite_activated.emit() status_changed.emit("fish on!") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) @@ -926,7 +930,8 @@ func _activate_bite() -> void: _catch_controller.start_encounter( _selected_fish.catch_profile, _get_effective_reel_speed(), - _get_effective_barrier_damage() + _get_effective_barrier_damage(), + _pending_catch.quality, ) _catch_controller.set_reel_input( Input.is_action_pressed("fish_primary") @@ -993,7 +998,6 @@ func _on_catch_completed() -> void: _cancel_attempt() return - _pending_catch = _fish_selector.create_catch(_selected_fish) if _pending_catch == null or not _pending_catch.is_valid(): _cancel_attempt() return diff --git a/jobs/job_catalog.gd b/jobs/job_catalog.gd new file mode 100644 index 0000000..4429e89 --- /dev/null +++ b/jobs/job_catalog.gd @@ -0,0 +1,416 @@ +class_name JobCatalog +extends RefCounted + +const FishDataType = preload("res://fish/fish_data.gd") + +enum Kind { + CATCH_TOTAL, + CATCH_WATER, + SELL_TOTAL, + CATCH_QUALITY, + CATCH_PHASE, + CATCH_WEATHER, + CATCH_SPECIES, + REACH_LEVEL, + DISCOVER_SPECIES, + MASTER_QUALITIES, +} + +const DAILY_JOB_COUNT: int = 4 +const WEATHER_SEGMENT_HOURS: float = ( + WorldWeatherService.DAILY_PLAN_SEGMENT_HOURS +) +const WEATHER_SEGMENT_COUNT: int = ( + WorldWeatherService.DAILY_PLAN_SEGMENT_COUNT +) +const MAX_JOB_REWARD_COINS: int = 100000 +const MAX_JOB_REWARD_EXPERIENCE: int = 10000 + + +static func generate_daily_jobs( + plan_id: String, + candidates: Array[FishDataType], + allow_weather_jobs: bool = true, +) -> Array[Dictionary]: + var rng := RandomNumberGenerator.new() + rng.seed = plan_id.hash() + var jobs: Array[Dictionary] = [ + _job( + "fresh_catches", + "fresh catch delivery", + "catch 5 fresh water fish", + Kind.CATCH_WATER, + 5, + 35, + 50, + {"water_type": int(WaterType.Type.FRESH_WATER)}, + ), + _job( + "sell_fish", + "market supply", + "sell 5 fish", + Kind.SELL_TOTAL, + 5, + 40, + 50, + ), + ] + var optional: Array[Dictionary] = [ + _job( + "quality_catch", + "quality check", + "catch an impressive fish or better", + Kind.CATCH_QUALITY, + 1, + 55, + 75, + {"minimum_quality": int(FishQuality.Tier.IMPRESSIVE)}, + ), + _job( + "night_catch", + "night shift", + "catch 3 fish at night", + Kind.CATCH_PHASE, + 3, + 35, + 50, + {"phase": int(WorldTimeService.Phase.NIGHT)}, + ), + ] + if allow_weather_jobs: + optional.append(_job( + "fog_catch", + "through the fog", + "catch 2 fish in foggy weather", + Kind.CATCH_WEATHER, + 2, + 65, + 75, + {"weather": int(WorldWeatherService.Weather.FOGGY)}, + )) + var species: Array[FishDataType] = [] + for fish: FishDataType in candidates: + if fish != null and fish.is_selectable(): + species.append(fish) + if not species.is_empty(): + species.sort_custom( + func(a: FishDataType, b: FishDataType) -> bool: + return String(a.id) < String(b.id) + ) + var species_index: int = rng.randi_range(0, species.size() - 1) + var selected: FishDataType = species[species_index] + optional.append(_job( + "species_%s" % String(selected.id), + "specific request", + "catch 3 %s" % selected.display_name.to_lower(), + Kind.CATCH_SPECIES, + 3, + 45, + 50, + {"fish_id": String(selected.id)}, + )) + _shuffle(optional, rng) + while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty(): + jobs.append(optional.pop_back()) + return jobs + + +static func generate_weather_schedule( + plan_id: String, + jobs: Array[Dictionary], + anchor_index: int = 0, +) -> Array[Dictionary]: + anchor_index = clampi(anchor_index, 0, WEATHER_SEGMENT_COUNT - 1) + var required: Array[int] = [] + for job: Dictionary in jobs: + if int(job.get("kind", -1)) != Kind.CATCH_WEATHER: + continue + var weather: int = int(job.get("weather", -1)) + if WorldWeatherService.is_valid_weather(weather) and weather not in required: + required.append(weather) + var rng := RandomNumberGenerator.new() + rng.seed = plan_id.hash() ^ 0x57454154 + var weather_values: Array[int] = [] + for index: int in WEATHER_SEGMENT_COUNT: + var roll: float = rng.randf() + var weather: int = int(WorldWeatherService.Weather.SUNNY) + if roll >= 0.42 and roll < 0.70: + weather = int(WorldWeatherService.Weather.CLOUDY) + elif roll >= 0.70 and roll < 0.88: + weather = int(WorldWeatherService.Weather.RAINY) + elif roll >= 0.88: + weather = int(WorldWeatherService.Weather.FOGGY) + weather_values.append(weather) + for requirement_index: int in required.size(): + var early_index: int = mini( + anchor_index + requirement_index, + WEATHER_SEGMENT_COUNT - 2, + ) + var remaining_segments: int = WEATHER_SEGMENT_COUNT - early_index + var later_index: int = mini( + early_index + maxi( + 1, floori(float(remaining_segments) / 2.0) + ), + WEATHER_SEGMENT_COUNT - 1, + ) + weather_values[early_index] = required[requirement_index] + weather_values[later_index] = required[requirement_index] + var schedule: Array[Dictionary] = [] + for index: int in WEATHER_SEGMENT_COUNT: + schedule.append({ + "start_hour": fposmod( + WorldTimeService.DAY_START_HOUR + + float(index) * WEATHER_SEGMENT_HOURS, + WorldTimeService.HOURS_PER_DAY, + ), + "weather": weather_values[index], + }) + return schedule + + +static func lifetime_chains( + registered_species_count: int, +) -> Array[Dictionary]: + var chains: Array[Dictionary] = [ + _chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [ + [150, 250], [700, 1000], [2500, 3000], + ]), + _chain("sell", Kind.SELL_TOTAL, [50, 500, 2500], [ + [125, 200], [600, 800], [2200, 2400], + ]), + _chain("level", Kind.REACH_LEVEL, [5, 10, 25, 50], [ + [100, 150], [250, 350], [1000, 1200], [3000, 3500], + ]), + ] + if registered_species_count > 0: + chains.append(_chain( + "discover", + Kind.DISCOVER_SPECIES, + [registered_species_count], + [[500, 750]], + )) + chains.append(_chain( + "master", + Kind.MASTER_QUALITIES, + [registered_species_count], + [[1500, 2000]], + )) + return chains + + +static func visible_lifetime_jobs( + registered_species_count: int, + claimed_ids: Array[String], +) -> Array[Dictionary]: + var visible: Array[Dictionary] = [] + for chain: Dictionary in lifetime_chains(registered_species_count): + var targets: Array = chain.get("targets", []) + var rewards: Array = chain.get("rewards", []) + for tier_index: int in targets.size(): + var job_id: String = "%s_%d" % [ + str(chain.get("id", "")), + int(targets[tier_index]), + ] + if job_id in claimed_ids: + continue + var reward: Array = rewards[tier_index] + visible.append(_job( + job_id, + _lifetime_title(str(chain.get("id", "")), int(targets[tier_index])), + _lifetime_description( + str(chain.get("id", "")), int(targets[tier_index]) + ), + int(chain.get("kind", Kind.CATCH_TOTAL)) as Kind, + int(targets[tier_index]), + int(reward[0]), + int(reward[1]), + )) + break + return visible + + +static func weather_requirements(jobs: Array[Dictionary]) -> Array[int]: + var result: Array[int] = [] + for job: Dictionary in jobs: + if int(job.get("kind", -1)) != Kind.CATCH_WEATHER: + continue + var weather: int = int(job.get("weather", -1)) + if WorldWeatherService.is_valid_weather(weather) and weather not in result: + result.append(weather) + return result + + +static func is_valid_job(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var job: Dictionary = value + var kind: int = int(job.get("kind", -1)) + if not ( + typeof(job.get("id")) == TYPE_STRING + and not str(job["id"]).is_empty() + and str(job["id"]).length() <= 96 + and typeof(job.get("title")) == TYPE_STRING + and str(job["title"]).length() <= 96 + 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 + ) + and kind >= Kind.CATCH_TOTAL + and kind <= Kind.MASTER_QUALITIES + and is_bounded_integer(job.get("target"), 1, 1000000000) + and is_bounded_integer( + job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS + ) + and is_bounded_integer( + job.get("experience"), 0, MAX_JOB_REWARD_EXPERIENCE + ) + ): + return false + match kind: + Kind.CATCH_WATER: + var water_type: int = int(job.get("water_type", -1)) + return ( + is_bounded_integer( + job.get("water_type"), + WaterType.Type.FRESH_WATER, + WaterType.Type.OTHER, + ) + and water_type >= WaterType.Type.FRESH_WATER + and water_type <= WaterType.Type.OTHER + ) + Kind.CATCH_QUALITY: + return ( + is_bounded_integer( + job.get("minimum_quality"), + FishQuality.Tier.BORING, + FishQuality.Tier.SHINY, + ) + and FishQuality.is_valid(int(job.get("minimum_quality", -1))) + ) + Kind.CATCH_PHASE: + var phase: int = int(job.get("phase", -1)) + return ( + is_bounded_integer( + job.get("phase"), + WorldTimeService.Phase.DAWN, + WorldTimeService.Phase.NIGHT, + ) + and phase >= WorldTimeService.Phase.DAWN + and phase <= WorldTimeService.Phase.NIGHT + ) + Kind.CATCH_WEATHER: + return ( + is_bounded_integer( + job.get("weather"), + WorldWeatherService.Weather.SUNNY, + WorldWeatherService.Weather.FOGGY, + ) + and WorldWeatherService.is_valid_weather( + int(job.get("weather", -1)) + ) + ) + Kind.CATCH_SPECIES: + return ( + typeof(job.get("fish_id")) == TYPE_STRING + and not str(job.get("fish_id", "")).is_empty() + and str(job.get("fish_id", "")).length() <= 96 + ) + return true + + +static func is_bounded_integer( + value: Variant, + minimum: int, + maximum: int, +) -> bool: + if typeof(value) == TYPE_INT: + return int(value) >= minimum and int(value) <= maximum + if typeof(value) != TYPE_FLOAT: + return false + var number: float = float(value) + return ( + is_finite(number) + and number >= float(minimum) + and number <= float(maximum) + and is_equal_approx(number, round(number)) + ) + + +static func is_valid_weather_schedule(value: Variant) -> bool: + return WorldWeatherService.is_valid_daily_plan_schedule(value) + + +static func _job( + id: String, + title: String, + description: String, + kind: Kind, + target: int, + fish_coin: int, + experience: int, + extra: Dictionary = {}, +) -> Dictionary: + var result: Dictionary = { + "id": id, + "title": title, + "description": description, + "kind": int(kind), + "target": target, + "fish_coin": fish_coin, + "experience": experience, + } + result.merge(extra, true) + return result + + +static func _chain( + id: String, + kind: Kind, + targets: Array[int], + rewards: Array, +) -> Dictionary: + return { + "id": id, + "kind": int(kind), + "targets": targets, + "rewards": rewards, + } + + +static func _lifetime_title(chain_id: String, target: int) -> String: + match chain_id: + "catch": + return "seasoned angler" + "sell": + return "fish coin regular" + "level": + return "reach level %d" % target + "discover": + return "complete the catalog" + "master": + return "quality master" + return "long-term job" + + +static func _lifetime_description(chain_id: String, target: int) -> String: + match chain_id: + "catch": + return "catch %d fish" % target + "sell": + return "sell %d fish" % target + "level": + return "reach player level %d" % target + "discover": + return "discover all %d cataloged fish" % target + "master": + return "collect every quality of all %d fish" % target + return "keep fishing" + + +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) + var value: Dictionary = values[index] + values[index] = values[swap_index] + values[swap_index] = value diff --git a/jobs/job_catalog.gd.uid b/jobs/job_catalog.gd.uid new file mode 100644 index 0000000..f54ab48 --- /dev/null +++ b/jobs/job_catalog.gd.uid @@ -0,0 +1 @@ +uid://c52vqhdties3o diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd new file mode 100644 index 0000000..5f764f8 --- /dev/null +++ b/jobs/player_job_service.gd @@ -0,0 +1,786 @@ +class_name PlayerJobService +extends Node + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") + +const FORMAT_VERSION: int = 1 +const MAX_PROGRESS_VALUE: int = 1000000000 +const MAX_PENDING_REWARDS: int = 64 +const DAILY_REFRESH_HOUR: float = WorldTimeService.DAY_START_HOUR + +signal changed +signal board_changed +signal reward_claimed(title: String, fish_coin: int, experience: int) +signal status_changed(message: String) + +var _wallet: PlayerWallet +var _experience: PlayerExperience +var _collection: CollectionLog +var _catalog: FishPoolType +var _world_time: WorldTimeService +var _world_weather: WorldWeatherService +var _session: NetworkSession +var _save_manager: PlayerSaveManager +var _network_fishing: NetworkFishingService +var _network_sale: NetworkSaleService + +var _progression_ready: bool = false +var _host_board: Dictionary = {} +var _active_board: Dictionary = {} +var _active_plan_id: String = "" +var _daily_progress: Dictionary[String, int] = {} +var _daily_completions: Dictionary[String, int] = {} +var _pending_rewards: Array[Dictionary] = [] +var _lifetime_claimed: Array[String] = [] +var _total_catches: int = 0 +var _total_sold: int = 0 +var _previous_hour: float = WorldTimeService.DEFAULT_START_HOUR + + +func setup( + wallet: PlayerWallet, + experience: PlayerExperience, + collection: CollectionLog, + catalog: FishPoolType, + world_time: WorldTimeService, + world_weather: WorldWeatherService, + session: NetworkSession, +) -> void: + _wallet = wallet + _experience = experience + _collection = collection + _catalog = catalog + _world_time = world_time + _world_weather = world_weather + _session = session + _previous_hour = _world_time.get_time_hours() + _world_time.time_changed.connect(_on_time_changed) + _session.state_changed.connect(_on_session_state_changed) + _collection.collection_changed.connect(_on_current_state_changed) + _experience.experience_changed.connect(_on_experience_changed) + + +func bind_authoritative_services( + network_fishing: NetworkFishingService, + network_sale: NetworkSaleService, +) -> void: + _network_fishing = network_fishing + _network_sale = network_sale + if not _network_fishing.local_catch_received.is_connected( + _on_authoritative_catch + ): + _network_fishing.local_catch_received.connect(_on_authoritative_catch) + if not _network_sale.local_sale_finished.is_connected( + _on_authoritative_sale_finished + ): + _network_sale.local_sale_finished.connect( + _on_authoritative_sale_finished + ) + + +func set_save_manager(save_manager: PlayerSaveManager) -> void: + _save_manager = save_manager + + +func begin_progression_session() -> void: + _progression_ready = true + _previous_hour = _world_time.get_time_hours() + if _session.is_host(): + _activate_host_board() + changed.emit() + + +func end_progression_session() -> void: + _progression_ready = false + _active_board = {} + changed.emit() + + +func get_daily_jobs() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var jobs: Array = _active_board.get("jobs", []) + for value: Variant in jobs: + if typeof(value) != TYPE_DICTIONARY: + continue + var job: Dictionary = (value as Dictionary).duplicate(true) + var job_id: String = str(job.get("id", "")) + job["progress"] = mini( + int(_daily_progress.get(job_id, 0)), int(job.get("target", 1)) + ) + job["completed_count"] = int(_daily_completions.get(job_id, 0)) + var pending: Array[Dictionary] = _pending_for_job( + _active_plan_id, job_id + ) + job["pending_count"] = pending.size() + job["claim_id"] = ( + str(pending[0].get("claim_id", "")) if not pending.is_empty() else "" + ) + job["claimable"] = not pending.is_empty() + result.append(job) + return result + + +func get_lifetime_jobs() -> Array[Dictionary]: + var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs( + _registered_species_count(), _lifetime_claimed + ) + for job: Dictionary in result: + var progress: int = _lifetime_progress(job) + job["progress"] = mini(progress, int(job.get("target", 1))) + job["complete"] = progress >= int(job.get("target", 1)) + job["claimable"] = bool(job["complete"]) + return result + + +func get_pending_rewards() -> Array[Dictionary]: + return _pending_rewards.duplicate(true) + + +func get_forecast() -> Array[Dictionary]: + var schedule: Array = _active_board.get("weather_schedule", []) + var result: Array[Dictionary] = [] + for value: Variant in schedule: + if typeof(value) == TYPE_DICTIONARY: + result.append((value as Dictionary).duplicate(true)) + return result + + +func get_plan_id() -> String: + return _active_plan_id + + +func get_time_until_refresh_text() -> String: + if _active_plan_id.is_empty() or _world_time == null: + return "daily jobs unavailable" + var elapsed: float = fposmod( + _world_time.get_time_hours() - DAILY_REFRESH_HOUR, + WorldTimeService.HOURS_PER_DAY, + ) + var hours_remaining: float = WorldTimeService.HOURS_PER_DAY - elapsed + var real_seconds: int = ceili( + hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND + ) + return "refreshes in %d:%02d" % [ + floori(float(real_seconds) / 60.0), real_seconds % 60, + ] + + +func claim(claim_id: String) -> bool: + if claim_id.is_empty() or _wallet == null or _experience == null: + return false + for index: int in _pending_rewards.size(): + var reward: Dictionary = _pending_rewards[index] + if str(reward.get("claim_id", "")) != claim_id: + continue + return _claim_pending_reward(index, reward) + for lifetime_job: Dictionary in get_lifetime_jobs(): + if str(lifetime_job.get("id", "")) != claim_id: + continue + if not bool(lifetime_job.get("claimable", false)): + return false + return _claim_lifetime_reward(lifetime_job) + return false + + +func get_host_board_network_data() -> Dictionary: + return _host_board.duplicate(true) + + +func apply_remote_board(board: Dictionary) -> bool: + if not validate_board(board) or not _matches_canonical_board(board): + return false + var plan_id: String = str(board.get("plan_id", "")) + if plan_id != _active_plan_id: + _expire_incomplete_daily_jobs() + _active_plan_id = plan_id + _daily_progress.clear() + _daily_completions.clear() + _active_board = board.duplicate(true) + changed.emit() + return true + + +func clear_remote_board() -> void: + if _session != null and _session.is_host(): + _activate_host_board() + return + _active_board = {} + changed.emit() + + +func to_save_data() -> Dictionary: + var progress: Dictionary = {} + for job_id: String in _daily_progress: + progress[job_id] = _daily_progress[job_id] + var completions: Dictionary = {} + for job_id: String in _daily_completions: + completions[job_id] = _daily_completions[job_id] + return { + "format_version": FORMAT_VERSION, + "host_board": _host_board.duplicate(true), + "active_plan_id": _active_plan_id, + "daily_progress": progress, + "daily_completions": completions, + "pending_rewards": _pending_rewards.duplicate(true), + "lifetime_claimed": _lifetime_claimed.duplicate(), + "statistics": { + "fish_caught": _total_catches, + "fish_sold": _total_sold, + }, + } + + +func restore_from_save_data(data: Dictionary) -> bool: + if not validate_save_data(data): + return false + var received_board: Dictionary = _active_board.duplicate(true) + _host_board = (data.get("host_board", {}) as Dictionary).duplicate(true) + _active_plan_id = str(data.get("active_plan_id", "")) + _daily_progress.clear() + var progress: Dictionary = data.get("daily_progress", {}) + for key: Variant in progress: + _daily_progress[str(key)] = int(progress[key]) + _daily_completions.clear() + var completions: Dictionary = data.get("daily_completions", {}) + for key: Variant in completions: + _daily_completions[str(key)] = mini(int(completions[key]), 1) + _pending_rewards.clear() + var rewards: Array = data.get("pending_rewards", []) + var restored_daily_rewards: Dictionary[String, bool] = {} + for value: Variant in rewards: + var reward: Dictionary = value + var reward_key: String = "%s/%s" % [ + str(reward.get("source_plan_id", "")), + str(reward.get("source_job_id", "")), + ] + if restored_daily_rewards.has(reward_key): + continue + restored_daily_rewards[reward_key] = true + _pending_rewards.append(reward.duplicate(true)) + _lifetime_claimed.clear() + var claimed: Array = data.get("lifetime_claimed", []) + for value: Variant in claimed: + _lifetime_claimed.append(str(value)) + var statistics: Dictionary = data.get("statistics", {}) + _total_catches = int(statistics.get("fish_caught", 0)) + _total_sold = int(statistics.get("fish_sold", 0)) + if _session != null and _session.is_host(): + _active_board = _host_board.duplicate(true) + _active_plan_id = str(_host_board.get("plan_id", "")) + elif ( + _session != null + and _session.is_joined_client() + and not received_board.is_empty() + ): + apply_remote_board(received_board) + _apply_host_weather_plan() + changed.emit() + return true + + +func reset_to_defaults() -> void: + _host_board = {} + _active_board = {} + _active_plan_id = "" + _daily_progress.clear() + _daily_completions.clear() + _pending_rewards.clear() + _lifetime_claimed.clear() + _total_catches = 0 + _total_sold = 0 + changed.emit() + + +static func default_save_data() -> Dictionary: + return { + "format_version": FORMAT_VERSION, + "host_board": {}, + "active_plan_id": "", + "daily_progress": {}, + "daily_completions": {}, + "pending_rewards": [], + "lifetime_claimed": [], + "statistics": {"fish_caught": 0, "fish_sold": 0}, + } + + +static func validate_board(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var board: Dictionary = value + if ( + typeof(board.get("plan_id")) != TYPE_STRING + or str(board.get("plan_id", "")).is_empty() + or str(board.get("plan_id", "")).length() > 96 + or not JobCatalog.is_bounded_integer( + board.get("cycle"), 0, MAX_PROGRESS_VALUE + ) + or not JobCatalog.is_bounded_integer( + board.get("schedule_anchor_index"), + 0, + JobCatalog.WEATHER_SEGMENT_COUNT - 1, + ) + or typeof(board.get("jobs")) != TYPE_ARRAY + or not JobCatalog.is_valid_weather_schedule( + board.get("weather_schedule", []) + ) + ): + return false + var jobs: Array = board.get("jobs", []) + if jobs.is_empty() or jobs.size() > 8: + return false + var seen: Dictionary[String, bool] = {} + for job_value: Variant in jobs: + if not JobCatalog.is_valid_job(job_value): + return false + var job: Dictionary = job_value + var job_id: String = str(job.get("id", "")) + if seen.has(job_id): + return false + seen[job_id] = true + return true + + +static func validate_save_data(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + if ( + not JobCatalog.is_bounded_integer( + data.get("format_version"), FORMAT_VERSION, FORMAT_VERSION + ) + or typeof(data.get("host_board")) != TYPE_DICTIONARY + or typeof(data.get("active_plan_id")) != TYPE_STRING + or typeof(data.get("daily_progress")) != TYPE_DICTIONARY + or typeof(data.get("daily_completions")) != TYPE_DICTIONARY + or typeof(data.get("pending_rewards")) != TYPE_ARRAY + or typeof(data.get("lifetime_claimed")) != TYPE_ARRAY + or typeof(data.get("statistics")) != TYPE_DICTIONARY + ): + return false + var host_board: Dictionary = data.get("host_board", {}) + if not host_board.is_empty() and not validate_board(host_board): + return false + var progress: Dictionary = data.get("daily_progress", {}) + if progress.size() > 8: + return false + for key: Variant in progress: + if ( + typeof(key) != TYPE_STRING + or str(key).is_empty() + or str(key).length() > 96 + or not JobCatalog.is_bounded_integer( + progress[key], 0, MAX_PROGRESS_VALUE + ) + ): + return false + var completions: Dictionary = data.get("daily_completions", {}) + if completions.size() > 8: + return false + for key: Variant in completions: + if ( + typeof(key) != TYPE_STRING + or str(key).is_empty() + or str(key).length() > 96 + or not JobCatalog.is_bounded_integer( + completions[key], 0, MAX_PROGRESS_VALUE + ) + ): + return false + var rewards: Array = data.get("pending_rewards", []) + if rewards.size() > MAX_PENDING_REWARDS: + return false + for reward: Variant in rewards: + if not _valid_reward(reward): + return false + var claimed: Array = data.get("lifetime_claimed", []) + if claimed.size() > 64 or not _valid_string_array(claimed, 96): + return false + var statistics: Dictionary = data.get("statistics", {}) + return ( + JobCatalog.is_bounded_integer( + statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE + ) + and JobCatalog.is_bounded_integer( + statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE + ) + ) + + +func _activate_host_board() -> void: + if _host_board.is_empty(): + _generate_host_board(0) + else: + _active_board = _host_board.duplicate(true) + _switch_plan(str(_host_board.get("plan_id", ""))) + _apply_host_weather_plan() + board_changed.emit() + changed.emit() + + +func _generate_host_board(cycle: int) -> void: + _expire_incomplete_daily_jobs() + var random_suffix: String = Crypto.new().generate_random_bytes(12).hex_encode() + var plan_id: String = "daily:%d:%s" % [cycle, random_suffix] + var candidates: Array[FishDataType] = [] + if _catalog != null: + for fish: FishDataType in _catalog.candidates: + if fish != null: + candidates.append(fish) + var schedule_anchor_index: int = _current_weather_segment() + var allow_weather_jobs: bool = ( + JobCatalog.WEATHER_SEGMENT_COUNT - schedule_anchor_index >= 2 + ) + var jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs( + plan_id, candidates, allow_weather_jobs + ) + _host_board = { + "plan_id": plan_id, + "cycle": cycle, + "schedule_anchor_index": schedule_anchor_index, + "jobs": jobs, + "weather_schedule": JobCatalog.generate_weather_schedule( + plan_id, jobs, schedule_anchor_index + ), + } + _active_board = _host_board.duplicate(true) + _switch_plan(plan_id) + _apply_host_weather_plan() + board_changed.emit() + changed.emit() + + +func _switch_plan(plan_id: String) -> void: + if plan_id == _active_plan_id: + return + _active_plan_id = plan_id + _daily_progress.clear() + _daily_completions.clear() + + +func _apply_host_weather_plan() -> void: + if ( + _world_weather == null + or _world_time == null + or _session == null + or not _session.is_host() + or _host_board.is_empty() + ): + return + var schedule: Array = _host_board.get("weather_schedule", []) + _world_weather.configure_daily_plan( + str(_host_board.get("plan_id", "")), schedule, _world_time + ) + + +func _on_time_changed( + time_hours: float, + _phase: WorldTimeService.Phase, +) -> void: + if ( + _progression_ready + and _session != null + and _session.is_host() + and _crossed_daily_refresh(_previous_hour, time_hours) + ): + var cycle: int = int(_host_board.get("cycle", -1)) + 1 + _generate_host_board(maxi(cycle, 0)) + _previous_hour = time_hours + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if not _progression_ready: + return + if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]: + _activate_host_board() + elif state == NetworkSession.State.JOINED_CLIENT: + _active_board = {} + changed.emit() + elif state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + clear_remote_board() + + +func _on_authoritative_catch(fish_catch: FishCatchType) -> void: + if not _progression_ready or fish_catch == null or not fish_catch.is_valid(): + return + _total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE) + _update_daily_for_catch(fish_catch) + changed.emit() + + +func _on_authoritative_sale_finished( + _request_id: String, + accepted: bool, + _message: String, + catch_ids: Array[StringName], + _payout: int, +) -> void: + if not _progression_ready or not accepted or catch_ids.is_empty(): + return + _total_sold = mini(_total_sold + catch_ids.size(), MAX_PROGRESS_VALUE) + for job: Dictionary in get_daily_jobs(): + if int(job.get("kind", -1)) == JobCatalog.Kind.SELL_TOTAL: + _advance_daily(job, catch_ids.size()) + changed.emit() + + +func _update_daily_for_catch(fish_catch: FishCatchType) -> void: + for job: Dictionary in get_daily_jobs(): + var matches: bool = false + match int(job.get("kind", -1)): + JobCatalog.Kind.CATCH_TOTAL: + matches = true + JobCatalog.Kind.CATCH_WATER: + matches = 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)) + JobCatalog.Kind.CATCH_PHASE: + matches = 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)) + JobCatalog.Kind.CATCH_SPECIES: + matches = String(fish_catch.fish_id) == str(job.get("fish_id", "")) + if matches: + _advance_daily(job, 1) + + +func _advance_daily(job: Dictionary, amount: int) -> void: + var job_id: String = str(job.get("id", "")) + if job_id.is_empty() or amount <= 0: + return + if int(_daily_completions.get(job_id, 0)) > 0: + return + var target: int = int(job.get("target", 1)) + var progress: int = mini( + int(_daily_progress.get(job_id, 0)) + amount, + target, + ) + if progress >= target and _pending_rewards.size() < MAX_PENDING_REWARDS: + _daily_completions[job_id] = 1 + var reward: Dictionary = { + "claim_id": _daily_claim_id(_active_plan_id, job_id), + "source_plan_id": _active_plan_id, + "source_job_id": job_id, + "title": str(job.get("title", "daily job")), + "fish_coin": int(job.get("fish_coin", 0)), + "experience": int(job.get("experience", 0)), + } + _pending_rewards.append(reward) + status_changed.emit("job complete — payment ready on the net") + _daily_progress[job_id] = progress + + +func _claim_pending_reward(index: int, reward: Dictionary) -> bool: + var state_snapshot: Dictionary = to_save_data() + var wallet_snapshot: int = _wallet.get_balance() + var experience_snapshot: int = _experience.get_total_experience() + _pending_rewards.remove_at(index) + if not _apply_reward(reward) or not _save_claim_transaction(): + _wallet.restore_balance(wallet_snapshot) + _experience.restore_total_experience(experience_snapshot) + restore_from_save_data(state_snapshot) + return false + _reward_claimed(reward) + return true + + +func _claim_lifetime_reward(job: Dictionary) -> bool: + var job_id: String = str(job.get("id", "")) + if job_id.is_empty() or job_id in _lifetime_claimed: + return false + var state_snapshot: Dictionary = to_save_data() + var wallet_snapshot: int = _wallet.get_balance() + var experience_snapshot: int = _experience.get_total_experience() + _lifetime_claimed.append(job_id) + if not _apply_reward(job) or not _save_claim_transaction(): + _wallet.restore_balance(wallet_snapshot) + _experience.restore_total_experience(experience_snapshot) + restore_from_save_data(state_snapshot) + return false + _reward_claimed(job) + return true + + +func _apply_reward(reward: Dictionary) -> bool: + var fish_coin: int = int(reward.get("fish_coin", 0)) + var experience: int = int(reward.get("experience", 0)) + if fish_coin > 0 and not _wallet.credit(fish_coin): + return false + if experience > 0 and not _experience.award_experience(experience): + return false + changed.emit() + return true + + +func _save_claim_transaction() -> bool: + return _save_manager != null and _save_manager.save_if_dirty() + + +func _reward_claimed(reward: Dictionary) -> void: + var title: String = str(reward.get("title", "job")) + var fish_coin: int = int(reward.get("fish_coin", 0)) + var experience: int = int(reward.get("experience", 0)) + reward_claimed.emit(title, fish_coin, experience) + status_changed.emit("payment received — $%d and %d xp" % [ + fish_coin, experience, + ]) + changed.emit() + + +func _lifetime_progress(job: Dictionary) -> int: + match int(job.get("kind", -1)): + JobCatalog.Kind.CATCH_TOTAL: + return _total_catches + JobCatalog.Kind.SELL_TOTAL: + return _total_sold + 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 + 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 0 + + +func _registered_species_count() -> int: + var count: int = 0 + if _catalog != null: + for fish: FishDataType in _catalog.candidates: + if fish != null and fish.is_selectable(): + count += 1 + return count + + +func _expire_incomplete_daily_jobs() -> void: + _daily_progress.clear() + _daily_completions.clear() + + +func _on_current_state_changed() -> void: + if _progression_ready: + changed.emit() + + +func _on_experience_changed(_total: int, _level: int) -> void: + if _progression_ready: + changed.emit() + + +func _pending_for_job( + plan_id: String, + job_id: String, +) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for reward: Dictionary in _pending_rewards: + if ( + str(reward.get("source_plan_id", "")) == plan_id + and str(reward.get("source_job_id", "")) == job_id + ): + result.append(reward) + return result + + +func _matches_canonical_board(board: Dictionary) -> bool: + var plan_id: String = str(board.get("plan_id", "")) + var candidates: Array[FishDataType] = [] + if _catalog != null: + for fish: FishDataType in _catalog.candidates: + if fish != null: + candidates.append(fish) + var anchor_index: int = int(board.get("schedule_anchor_index", -1)) + var allow_weather_jobs: bool = ( + JobCatalog.WEATHER_SEGMENT_COUNT - anchor_index >= 2 + ) + var jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs( + plan_id, candidates, allow_weather_jobs + ) + var schedule: Array[Dictionary] = JobCatalog.generate_weather_schedule( + plan_id, jobs, anchor_index + ) + return ( + board.get("jobs", []) == jobs + and board.get("weather_schedule", []) == schedule + ) + + +func _current_weather_segment() -> int: + if _world_time == null: + return 0 + var elapsed_hours: float = fposmod( + _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR, + WorldTimeService.HOURS_PER_DAY, + ) + return clampi( + floori(elapsed_hours / JobCatalog.WEATHER_SEGMENT_HOURS), + 0, + JobCatalog.WEATHER_SEGMENT_COUNT - 1, + ) + + +static func _daily_claim_id( + plan_id: String, + job_id: String, +) -> String: + return "%s/%s" % [plan_id, job_id] + + +static func _crossed_daily_refresh(previous: float, current: float) -> bool: + if current >= previous: + return previous < DAILY_REFRESH_HOUR and current >= DAILY_REFRESH_HOUR + return ( + previous < DAILY_REFRESH_HOUR + or current >= DAILY_REFRESH_HOUR + ) + + +static func _valid_string_array(values: Array, maximum_length: int) -> bool: + var seen: Dictionary[String, bool] = {} + for value: Variant in values: + if typeof(value) != TYPE_STRING: + return false + var text: String = str(value) + if text.is_empty() or text.length() > maximum_length or seen.has(text): + return false + seen[text] = true + return true + + +static func _valid_reward(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var reward: Dictionary = value + return ( + typeof(reward.get("claim_id")) == TYPE_STRING + and not str(reward.get("claim_id", "")).is_empty() + and str(reward.get("claim_id", "")).length() <= 196 + and typeof(reward.get("source_plan_id")) == TYPE_STRING + and not str(reward.get("source_plan_id", "")).is_empty() + and str(reward.get("source_plan_id", "")).length() <= 96 + and typeof(reward.get("source_job_id")) == TYPE_STRING + and not str(reward.get("source_job_id", "")).is_empty() + and str(reward.get("source_job_id", "")).length() <= 96 + and typeof(reward.get("title")) == TYPE_STRING + and str(reward.get("title", "")).length() <= 96 + and JobCatalog.is_bounded_integer( + reward.get("fish_coin"), 0, JobCatalog.MAX_JOB_REWARD_COINS + ) + and JobCatalog.is_bounded_integer( + reward.get("experience"), + 0, + JobCatalog.MAX_JOB_REWARD_EXPERIENCE, + ) + ) diff --git a/jobs/player_job_service.gd.uid b/jobs/player_job_service.gd.uid new file mode 100644 index 0000000..110c6e2 --- /dev/null +++ b/jobs/player_job_service.gd.uid @@ -0,0 +1 @@ +uid://daqtje84gqgu0 diff --git a/main/main.gd b/main/main.gd index 7a074a3..cda5c8f 100644 --- a/main/main.gd +++ b/main/main.gd @@ -88,6 +88,8 @@ const WorldWeatherServiceType = preload("res://world/world_weather_service.gd") const NetworkWorldWeatherServiceType = preload( "res://network/network_world_weather_service.gd" ) +const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") +const NetworkJobServiceType = preload("res://network/network_job_service.gd") const TITLE_MUSIC_SILENCE_DB: float = -80.0 const PLAYER_MENU_PATTERN_SCALE: float = 0.85 @@ -130,6 +132,8 @@ const SHOP_PATTERN_SCALE: float = 1.75 @onready var _network_world_weather: NetworkWorldWeatherServiceType = ( %NetworkWorldWeatherService ) +@onready var _player_jobs: PlayerJobServiceType = %PlayerJobService +@onready var _network_jobs: NetworkJobServiceType = %NetworkJobService @onready var _data_root: PlayerDataRoot = %PlayerDataRoot @onready var _identity_backups: IdentityBackupService = %IdentityBackupService @onready var _network_profile: NetworkProfilePreferencesType = ( @@ -294,6 +298,16 @@ func _initialize_after_data_root() -> void: ) _network_world_time.setup(_network_session, _world_time) _network_world_weather.setup(_network_session, _world_weather) + _player_jobs.setup( + _player.wallet, + _player.experience, + _player.collection_log, + fish_catalog, + _world_time, + _world_weather, + _network_session, + ) + _network_jobs.setup(_network_session, _player_jobs) _identity_backups.setup(_data_root, _player_identity, _host_identity) _network_profile_service.setup( _network_session, @@ -359,7 +373,10 @@ func _initialize_after_data_root() -> void: _player.art_unlocks, _player.experience, _world_time, + _world_weather, + _player_jobs, ) + _player_jobs.set_save_manager(_save_manager) _save_manager.set_autosave_enabled(false) _asset_reservations.setup( _player.wallet, _player.inventory, _player.bag, item_catalog @@ -436,6 +453,7 @@ func _initialize_after_data_root() -> void: sale_buyers, _asset_reservations ) + _player_jobs.bind_authoritative_services(_network_fishing, _network_sale) _network_shop.setup( _network_session, _player_spawn_service, @@ -500,6 +518,7 @@ func _initialize_after_data_root() -> void: _player.art_unlocks, _world_time, _world_weather, + _player_jobs, ) _game_ui.setup_data_and_identity( _data_root, @@ -1017,8 +1036,10 @@ func _set_gameplay_active(active: bool) -> void: _game_ui.set_gameplay_ui_enabled(active) _save_manager.set_autosave_enabled(active) if active: + _player_jobs.begin_progression_session() _refresh_active_hotbar_item() else: + _player_jobs.end_progression_session() _set_player_menu_backdrop_visible(false) _set_shop_backdrop_visible(false) @@ -1141,7 +1162,10 @@ func _prepare_private_host() -> bool: NetworkSessionType.State.SERVER_LOST, ]: _network_session.reset_failure() - if not _network_session.start_private_host(): + if not _network_session.start_private_host( + NetworkSessionType.DEFAULT_PORT, + NetworkSessionType.DEFAULT_PRIVATE_HOST_PORT_ATTEMPTS, + ): _game_ui.get_title_screen().report_network_error( "Could not start the private multiplayer session." ) diff --git a/main/main.tscn b/main/main.tscn index 1e60b8d..03a38ab 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -51,6 +51,8 @@ [ext_resource type="Script" path="res://network/network_world_time_service.gd" id="49_network_world_time"] [ext_resource type="Script" path="res://world/world_weather_service.gd" id="50_world_weather"] [ext_resource type="Script" path="res://network/network_world_weather_service.gd" id="51_network_world_weather"] +[ext_resource type="Script" path="res://jobs/player_job_service.gd" id="52_player_jobs"] +[ext_resource type="Script" path="res://network/network_job_service.gd" id="53_network_jobs"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -138,6 +140,14 @@ script = ExtResource("50_world_weather") unique_name_in_owner = true script = ExtResource("51_network_world_weather") +[node name="PlayerJobService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("52_player_jobs") + +[node name="NetworkJobService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("53_network_jobs") + [node name="PlayerDataRoot" type="Node" parent="." unique_id=281868360] unique_name_in_owner = true script = ExtResource("37_data_root") diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index be214e2..18b68bc 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -429,11 +429,21 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void: return attempt.phase = NetworkFishingAttempt.Phase.FIGHTING attempt.encounter_seed = _new_seed() + var selector := FishSelectorType.new() + selector.use_deterministic_test_seed = true + selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF + selector.begin_roll() + var fish_catch: FishCatch = selector.create_catch(fish) + if fish_catch == null or not fish_catch.is_valid(): + _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") + return + attempt.catch_payload = fish_catch.to_network_dict() attempt.controller.start_authoritative_encounter( fish.catch_profile, attempt.reel_speed, attempt.barrier_damage, - attempt.encounter_seed + attempt.encounter_seed, + fish_catch.quality, ) var data: Dictionary = { "attempt_id": attempt.attempt_id, @@ -617,11 +627,10 @@ func _on_attempt_caught(peer_id: int) -> void: if fish == null: _cancel_attempt(peer_id, "Fishing attempt ended.") return - var selector := FishSelectorType.new() - selector.use_deterministic_test_seed = true - selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF - selector.begin_roll() - var fish_catch: FishCatch = selector.create_catch(fish) + var fish_catch: FishCatch = FishCatchType.from_network_dict( + attempt.catch_payload, + fish, + ) if fish_catch == null or not fish_catch.is_valid(): _cancel_attempt(peer_id, "Fishing attempt ended.") return diff --git a/network/network_job_service.gd b/network/network_job_service.gd new file mode 100644 index 0000000..7745903 --- /dev/null +++ b/network/network_job_service.gd @@ -0,0 +1,102 @@ +class_name NetworkJobService +extends Node + +const MAX_SESSION_ID_LENGTH: int = 96 + +var _session: NetworkSession +var _jobs: PlayerJobService +var _sequence: int = 0 +var _last_received_sequence: int = -1 + + +func setup(session: NetworkSession, jobs: PlayerJobService) -> void: + _session = session + _jobs = jobs + _session.state_changed.connect(_on_session_state_changed) + _session.peer_authenticated.connect(_on_peer_authenticated) + _jobs.board_changed.connect(_on_board_changed) + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + _sequence = 0 + _last_received_sequence = -1 + if state == NetworkSession.State.JOINED_CLIENT: + if not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY): + _jobs.clear_remote_board() + elif state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _jobs.clear_remote_board() + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if ( + _session == null + or not _session.is_host() + or not _session.peer_supports_capability( + peer_id, NetworkProtocol.JOBS_CAPABILITY + ) + ): + return + _send_board(peer_id) + + +func _on_board_changed() -> void: + if _session == null or not _session.is_host(): + return + for peer_id: int in _session.get_authenticated_peer_ids(): + if ( + peer_id != _session.get_local_peer_id() + and _session.peer_supports_capability( + peer_id, NetworkProtocol.JOBS_CAPABILITY + ) + ): + _send_board(peer_id) + + +func _send_board(peer_id: int) -> void: + var board: Dictionary = _jobs.get_host_board_network_data() + if board.is_empty() or not PlayerJobService.validate_board(board): + return + _sequence += 1 + receive_job_board.rpc_id(peer_id, { + "session_id": _session.get_session_id(), + "sequence": _sequence, + "board": board, + }) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_job_board(data: Dictionary) -> void: + if ( + _session == null + or _jobs == null + or not _session.is_joined_client() + or not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY) + or not validate_snapshot(data) + or str(data.get("session_id", "")) != _session.get_session_id() + ): + return + var sequence: int = int(data.get("sequence", -1)) + if sequence <= _last_received_sequence: + return + var board: Dictionary = data.get("board", {}) + if _jobs.apply_remote_board(board): + _last_received_sequence = sequence + + +static func validate_snapshot(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + return ( + typeof(data.get("session_id")) == TYPE_STRING + and not str(data.get("session_id", "")).is_empty() + and str(data.get("session_id", "")).length() <= MAX_SESSION_ID_LENGTH + and typeof(data.get("sequence")) == TYPE_INT + and int(data.get("sequence", -1)) >= 0 + and PlayerJobService.validate_board(data.get("board")) + ) diff --git a/network/network_job_service.gd.uid b/network/network_job_service.gd.uid new file mode 100644 index 0000000..d50e8dc --- /dev/null +++ b/network/network_job_service.gd.uid @@ -0,0 +1 @@ +uid://drlyytr2o3uw8 diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 94236b7..9ba7353 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -23,6 +23,7 @@ const ART_SHOP_CAPABILITY: String = "art_shop_v1" const WORLD_TIME_CAPABILITY: String = "world_time_v1" const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" +const JOBS_CAPABILITY: String = "jobs_v1" enum RejectionCode { NONE, @@ -93,6 +94,7 @@ static func make_client_hello( SURFACE_DRAWING_CAPABILITY, WORLD_TIME_CAPABILITY, WORLD_WEATHER_CAPABILITY, + JOBS_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -213,6 +215,7 @@ static func make_server_hello( SURFACE_DRAWING_CAPABILITY, WORLD_TIME_CAPABILITY, WORLD_WEATHER_CAPABILITY, + JOBS_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index 8c948af..1083c24 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -2,6 +2,7 @@ class_name NetworkSession extends Node const DEFAULT_PORT: int = 7777 +const DEFAULT_PRIVATE_HOST_PORT_ATTEMPTS: int = 16 const DEFAULT_SESSION_MAX_PLAYERS: int = 8 const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31 const CONNECTION_TIMEOUT_SECONDS: float = 10.0 @@ -97,6 +98,7 @@ var _local_appearance_snapshot: Dictionary = ( CharacterCustomizationCatalog.default_snapshot() ) var _moderation_disconnect_message := "" +var _host_port: int = 0 func _ready() -> void: @@ -131,7 +133,10 @@ func setup( _profile_ready = _profile_ready and _player_identity.load_or_create() -func start_private_host(port: int = DEFAULT_PORT) -> bool: +func start_private_host( + port: int = DEFAULT_PORT, + port_attempts: int = 1, +) -> bool: if ( state != State.INACTIVE or not _profile_ready @@ -143,19 +148,31 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: if not _host_identity.load_or_create(): _fail(_host_identity.error_message) return false - if port < 1 or port > 65535: + if port < 1 or port > 65535 or port_attempts < 1: _fail("The hosting port must be from 1 to 65535.") return false _operation_generation += 1 _set_state(State.STARTING_PRIVATE_HOST, "Starting private game...") - _replace_transport() - var error: Error = _transport.start_host( - port, - transport_max_clients, - ) - if error != OK: - _fail("Could not start a private game on UDP port %d." % port) + var selected_port: int = 0 + var final_port: int = mini(port + port_attempts - 1, 65535) + for candidate_port: int in range(port, final_port + 1): + if not _can_bind_udp_port(candidate_port): + continue + _replace_transport() + var error: Error = _transport.start_host( + candidate_port, + transport_max_clients, + ) + if error == OK: + selected_port = candidate_port + break + if selected_port == 0: + _fail( + "Could not start a private game on UDP ports %d–%d." + % [port, final_port] + ) return false + _host_port = selected_port var peer: MultiplayerPeer = _transport.get_multiplayer_peer() peer.refuse_new_connections = true multiplayer.multiplayer_peer = peer @@ -173,6 +190,7 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, + NetworkProtocol.JOBS_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -200,12 +218,23 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: var host_avatar := _spawn_service.get_avatar(1) if host_avatar != null: host_avatar.apply_appearance_snapshot(_local_appearance_snapshot) - _set_state(State.PRIVATE_HOST, "Private game • UDP %d" % port) + _set_state(State.PRIVATE_HOST, "Private game • UDP %d" % selected_port) host_openness_changed.emit(false) _emit_peer_count() return true +func get_host_port() -> int: + return _host_port if is_host() else 0 + + +static func _can_bind_udp_port(port: int) -> bool: + var probe := PacketPeerUDP.new() + var error: Error = probe.bind(port) + probe.close() + return error == OK + + func join_direct(endpoint_text: String) -> bool: if ( state != State.INACTIVE @@ -283,10 +312,10 @@ func set_host_open(is_open: bool) -> bool: _set_state( State.OPEN_HOST if is_open else State.PRIVATE_HOST, ( - "Open game • %d / %d players" + "Open game • UDP %d • %d / %d players" if is_open - else "Private game • %d / %d players" - ) % [_registry.size(), session_max_players] + else "Private game • UDP %d • %d / %d players" + ) % [_host_port, _registry.size(), session_max_players] ) host_openness_changed.emit(is_open) return true @@ -1729,3 +1758,4 @@ func _teardown_peer() -> void: _server_identity_fingerprint = "" _server_identity_public_key = "" _session_identity_keys.clear() + _host_port = 0 diff --git a/network/network_world_weather_service.gd b/network/network_world_weather_service.gd index ec0cab7..43d5762 100644 --- a/network/network_world_weather_service.gd +++ b/network/network_world_weather_service.gd @@ -44,6 +44,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_weather.set_persistence_tracking_enabled(true) _world_weather.begin_authoritative_session( _active_session_id.hash() ) @@ -52,6 +53,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _active_session_id = _session.get_session_id() _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_weather.set_persistence_tracking_enabled(false) if _session.supports_server_capability( NetworkProtocol.WORLD_WEATHER_CAPABILITY ): @@ -69,6 +71,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_weather.set_persistence_tracking_enabled(false) _world_weather.end_session() diff --git a/player/player.gd b/player/player.gd index 2f0f134..8933247 100644 --- a/player/player.gd +++ b/player/player.gd @@ -31,6 +31,7 @@ const FishingRodAttachmentScene = preload( const CHARACTER_IDLE_ANIMATION: StringName = &"idle" const CHARACTER_WALKING_ANIMATION: StringName = &"walking" const CHARACTER_SITTING_ANIMATION: StringName = &"sitting" +const BASE_REEL_SPEED: float = 0.16 var appearance_snapshot: Dictionary = ( CharacterCustomizationCatalog.default_snapshot() @@ -69,7 +70,7 @@ class ShowcaseCameraSnapshot: @export var local_control_enabled: bool = true @export_category("Fishing Stats") -@export_range(0.01, 2.0, 0.01) var reel_speed: float = 0.32 +@export_range(0.01, 2.0, 0.01) var reel_speed: float = BASE_REEL_SPEED @export_range(1, 100, 1) var click_power: int = 1 @export_category("Showcase") diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 72f7676..9a9a3be 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -25,8 +25,12 @@ const PlayerExperienceType = preload( "res://progression/player_experience.gd" ) const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldWeatherServiceType = preload( + "res://world/world_weather_service.gd" +) +const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") -const SAVE_VERSION: int = 6 +const SAVE_VERSION: int = 7 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 @@ -48,6 +52,14 @@ class LoadSnapshot: var art_unlock_mask: int = 0 var total_experience: int = 0 var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR + var has_world_weather_state: bool = false + var world_weather: WorldWeatherServiceType.Weather = ( + WorldWeatherServiceType.DEFAULT_WEATHER + ) + var world_weather_seconds_remaining: float = ( + WorldWeatherServiceType.SUNNY_DURATION_RANGE.x + ) + var jobs_data: Dictionary = PlayerJobServiceType.default_save_data() @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @@ -64,6 +76,8 @@ var _cooler_capacity: PlayerCoolerCapacityType var _art_unlocks: PlayerArtUnlocksType var _experience: PlayerExperienceType var _world_time: WorldTimeServiceType +var _world_weather: WorldWeatherServiceType +var _jobs: PlayerJobServiceType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -108,6 +122,8 @@ func setup( art_unlocks: PlayerArtUnlocksType, experience: PlayerExperienceType, world_time: WorldTimeServiceType, + world_weather: WorldWeatherServiceType, + jobs: PlayerJobServiceType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -121,6 +137,8 @@ func setup( _art_unlocks = art_unlocks _experience = experience _world_time = world_time + _world_weather = world_weather + _jobs = jobs _is_configured = ( _inventory != null and _collection_log != null @@ -134,6 +152,8 @@ func setup( and _art_unlocks != null and _experience != null and _world_time != null + and _world_weather != null + and _jobs != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -172,6 +192,8 @@ func setup( _on_experience_changed ): _experience.experience_changed.connect(_on_experience_changed) + if not _jobs.changed.is_connected(_mark_dirty): + _jobs.changed.connect(_mark_dirty) func load_player_data() -> bool: @@ -258,6 +280,13 @@ func load_player_data() -> bool: var world_time_restored: bool = ( _world_time.restore_persistent_time_hours(snapshot.world_time_hours) ) + var world_weather_restored: bool = true + if snapshot.has_world_weather_state: + world_weather_restored = _world_weather.restore_persistent_state( + snapshot.world_weather, + snapshot.world_weather_seconds_remaining, + ) + var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data) _is_restoring = false if ( not inventory_restored @@ -270,6 +299,8 @@ func load_player_data() -> bool: or not art_restored or not experience_restored or not world_time_restored + or not world_weather_restored + or not jobs_restored ): push_error("Validated player save could not be restored.") return false @@ -505,7 +536,12 @@ func _build_save_dictionary() -> Dictionary: "experience": _experience.to_save_data(), "world": { "time_hours": _world_time.get_persistent_time_hours(), + "weather": int(_world_weather.get_persistent_weather()), + "weather_seconds_remaining": ( + _world_weather.get_persistent_seconds_remaining() + ), }, + "jobs": _jobs.to_save_data(), } @@ -517,6 +553,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("bag")) != TYPE_DICTIONARY or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY or typeof(save_data.get("experience")) != TYPE_DICTIONARY + or typeof(save_data.get("jobs")) != TYPE_DICTIONARY ): return null var wallet_data: Dictionary = save_data["wallet"] @@ -525,6 +562,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var bag_data: Dictionary = save_data["bag"] var hotbar_data: Dictionary = save_data["hotbar"] var experience_data: Dictionary = save_data["experience"] + var jobs_data: Dictionary = save_data["jobs"] var world_data: Dictionary = {} if typeof(save_data.get("world")) == TYPE_DICTIONARY: world_data = save_data["world"] @@ -579,12 +617,40 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: ) if snapshot.total_experience < 0: return null + if not PlayerJobServiceType.validate_save_data(jobs_data): + return null + snapshot.jobs_data = jobs_data.duplicate(true) if world_data.has("time_hours"): snapshot.world_time_hours = _read_world_time_hours( world_data["time_hours"] ) if snapshot.world_time_hours < 0.0: return null + var has_weather: bool = world_data.has("weather") + var has_weather_seconds: bool = world_data.has( + "weather_seconds_remaining" + ) + if has_weather != has_weather_seconds: + return null + if has_weather: + var weather_value: int = _read_integer( + world_data["weather"], + -1, + WorldWeatherServiceType.Weather.FOGGY, + ) + var weather_seconds: float = _read_world_weather_seconds( + world_data["weather_seconds_remaining"] + ) + if ( + not WorldWeatherServiceType.is_valid_weather(weather_value) + or weather_seconds < 0.0 + ): + return null + snapshot.has_world_weather_state = true + snapshot.world_weather = ( + weather_value as WorldWeatherServiceType.Weather + ) + snapshot.world_weather_seconds_remaining = weather_seconds var discovered_values: Array = collection_data["discovered_fish_ids"] var seen_discoveries: Dictionary[StringName, bool] = {} for value: Variant in discovered_values: @@ -806,6 +872,8 @@ func _migrate_save( migrated = _migrate_version_4_to_5(migrated) 5: migrated = _migrate_version_5_to_6(migrated) + 6: + migrated = _migrate_version_6_to_7(migrated) _: return {} if migrated.is_empty(): @@ -915,6 +983,15 @@ func _migrate_version_5_to_6(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_6_to_7(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["save_version"] = 7 + # Historical catch and sale totals cannot be reconstructed honestly from + # current inventory. New cumulative counters begin at feature introduction. + migrated["jobs"] = PlayerJobServiceType.default_save_data() + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -1029,6 +1106,8 @@ func _restore_defaults() -> void: _world_time.restore_persistent_time_hours( WorldTimeServiceType.DEFAULT_START_HOUR ) + _world_weather.reset_persistent_state() + _jobs.reset_to_defaults() _is_restoring = false _is_dirty = false @@ -1067,6 +1146,19 @@ func _read_world_time_hours(value: Variant) -> float: return time_hours +func _read_world_weather_seconds(value: Variant) -> float: + if typeof(value) not in [TYPE_FLOAT, TYPE_INT]: + return -1.0 + var seconds_remaining: float = float(value) + if ( + not is_finite(seconds_remaining) + or seconds_remaining < 0.0 + or seconds_remaining > WorldWeatherServiceType.MAX_PERSISTED_SECONDS + ): + return -1.0 + return seconds_remaining + + func _read_upgrade_level( value: Variant, maximum_level: int, diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index bd1df0f..ec802b0 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -31,6 +31,9 @@ func _run() -> void: var player := main.get("_player") as Player var world_time := main.get_node("%WorldTimeService") as WorldTimeService + var world_weather := ( + main.get_node("%WorldWeatherService") as WorldWeatherService + ) world_time.synchronize_time(19.75) assert(player.experience.award_experience(125)) var fish_catalog := main.get("fish_catalog") as FishPool @@ -92,6 +95,10 @@ func _run() -> void: await process_frame assert(not service.is_local_showcase_visible()) assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + var saved_weather: WorldWeatherService.Weather = ( + world_weather.get_weather() + ) + var saved_weather_seconds: float = world_weather.get_seconds_remaining() assert(save_manager.save_now()) var save_path: String = str(save_manager.get("_save_path")) @@ -103,17 +110,25 @@ func _run() -> void: var hotbar_data: Dictionary = (parsed as Dictionary)["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"]) == 6) + assert(int((parsed as Dictionary)["save_version"]) == 7) assert( int((parsed as Dictionary)["experience"]["total_experience"]) == 125 ) + assert(absf( + float((parsed as Dictionary)["world"]["time_hours"]) - 19.75 + ) < 0.01) assert( - is_equal_approx( - float((parsed as Dictionary)["world"]["time_hours"]), - 19.75, - ) + int((parsed as Dictionary)["world"]["weather"]) + == int(saved_weather) ) + assert(absf( + float( + (parsed as Dictionary)["world"][ + "weather_seconds_remaining" + ] + ) - saved_weather_seconds + ) < 0.1) var saved_catches: Array = (parsed as Dictionary)["inventory"]["catches"] assert(int((saved_catches[0] as Dictionary)["quality"]) == fish_catch.quality) var saved_masks: Dictionary = ( @@ -127,9 +142,18 @@ func _run() -> void: assert(player.hotbar.clear_slot(1)) assert(player.experience.restore_total_experience(0)) world_time.synchronize_time(8.0) + world_weather.clear_daily_plan() + world_weather.apply_authoritative_snapshot( + WorldWeatherService.Weather.FOGGY, + 10.0, + ) assert(save_manager.load_player_data()) assert(player.experience.get_total_experience() == 125) - assert(is_equal_approx(world_time.get_time_hours(), 19.75)) + assert(absf(world_time.get_time_hours() - 19.75) < 0.01) + assert(world_weather.get_weather() == saved_weather) + assert(absf( + world_weather.get_seconds_remaining() - saved_weather_seconds + ) < 2.0) assert(player.experience.get_level() == 2) assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id) assert(player.hotbar.get_selected_slot() == 1) diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index e4f3288..2ecaf66 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -19,6 +19,8 @@ func _initialize() -> void: func _run() -> void: _validate_tiers_and_distribution() + _validate_barrier_challenge_curve() + _validate_fight_pacing_and_reel_upgrades() _validate_catch_round_trip_and_sale() _validate_mail_round_trip() _validate_collection_mastery() @@ -72,6 +74,115 @@ func _validate_tiers_and_distribution() -> void: assert(absf(observed - expected) < 0.015) +func _validate_barrier_challenge_curve() -> void: + assert( + FishQualityType.BARRIER_HEALTH_MULTIPLIERS.size() + == FishQualityType.TIER_COUNT + ) + var expected_health: Array[int] = [8, 10, 13, 18, 26] + var previous_health: int = 0 + for quality: int in FishQualityType.TIER_COUNT: + var health: int = FishQualityType.apply_barrier_health(8, quality) + assert(health == expected_health[quality]) + assert(health > previous_health) + previous_health = health + assert(FishQualityType.apply_barrier_health(8, -1) == 8) + assert(FishQualityType.apply_barrier_health(0, FishQualityType.Tier.SHINY) == 4) + + var profile := CatchDifficultyProfile.new() + profile.barrier_count_min = 1 + profile.barrier_count_max = 1 + profile.barrier_health_min = 8 + profile.barrier_health_max = 8 + profile.first_barrier_margin = 0.2 + profile.final_barrier_margin = 0.2 + profile.minimum_barrier_spacing = 0.1 + var controller := CatchController.new() + root.add_child(controller) + for quality: int in FishQualityType.TIER_COUNT: + controller.start_authoritative_encounter( + profile, + 0.1, + 1, + 818181, + quality, + ) + var barriers_value: Variant = controller.get("_barriers") + assert(barriers_value is Array) + var barriers: Array = barriers_value as Array + assert(barriers.size() == 1) + var barrier := barriers[0] as RefCounted + assert(barrier != null) + assert(int(barrier.get("maximum_health")) == expected_health[quality]) + controller.queue_free() + + var shiny_health: int = FishQualityType.apply_barrier_health( + 8, + FishQualityType.Tier.SHINY, + ) + var base_power_clicks: int = ceili(float(shiny_health) / 1.0) + var max_power_clicks: int = ceili( + float(shiny_health) + / float(PlayerFishingUpgrades.MAX_BARRIER_POWER_LEVEL + 1) + ) + assert(base_power_clicks == 26) + assert(max_power_clicks == 7) + assert(max_power_clicks < base_power_clicks) + + +func _validate_fight_pacing_and_reel_upgrades() -> void: + assert(is_equal_approx(CatchController.CHASE_SPEED, 0.07)) + assert(is_equal_approx(CatchController.CHASE_START_DELAY, 0.5)) + assert(is_equal_approx(CatchController.CHASE_START_OFFSET, 0.04)) + assert(is_equal_approx(Player.BASE_REEL_SPEED, 0.16)) + + var upgrades := PlayerFishingUpgrades.new() + assert(is_equal_approx(upgrades.get_reel_speed_multiplier(), 1.0)) + assert( + upgrades.restore_levels( + PlayerFishingUpgrades.MAX_REEL_SPEED_LEVEL, + 0, + ) + ) + var upgraded_multiplier: float = upgrades.get_reel_speed_multiplier() + assert(is_equal_approx(upgraded_multiplier, 1.5)) + + var profile := CatchDifficultyProfile.new() + profile.barrier_count_min = 0 + profile.barrier_count_max = 0 + var controller := CatchController.new() + root.add_child(controller) + controller.start_authoritative_encounter( + profile, + Player.BASE_REEL_SPEED, + 1, + 919191, + ) + controller.set_reel_input(true) + controller.call("_update_free_reeling", 1.0) + var base_progress: float = controller.progress + assert(is_equal_approx(base_progress, Player.BASE_REEL_SPEED)) + + controller.reset() + controller.start_authoritative_encounter( + profile, + Player.BASE_REEL_SPEED * upgraded_multiplier, + 1, + 919191, + ) + controller.set_reel_input(true) + controller.call("_update_free_reeling", 1.0) + assert( + is_equal_approx( + controller.progress, + Player.BASE_REEL_SPEED * upgraded_multiplier, + ) + ) + assert(controller.progress > base_progress) + controller.queue_free() + upgrades.queue_free() + + func _validate_catch_round_trip_and_sale() -> void: var fish: FishData = Catalog.get_fish_by_id(&"bluegill") assert(fish != null) @@ -242,7 +353,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 6) + assert(int(migrated.get("save_version", -1)) == 7) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( @@ -259,4 +370,7 @@ func _validate_version_four_migration() -> void: var boring_bit: int = FishQualityType.bit_for(FishQualityType.Tier.BORING) assert(int(masks["bluegill"]) == boring_bit) assert(int(masks["carp"]) == boring_bit) + assert( + PlayerJobService.validate_save_data(migrated.get("jobs", {})) + ) manager.queue_free() diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index 2a98968..dc79c44 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -97,6 +97,60 @@ func _run() -> void: assert(player.is_movement_enabled()) assert(not bobber.visible) + # A private host rolls and retains the authoritative catch before the fight + # so its quality can scale the same barriers clients receive in snapshots. + fishing_spot.call("_begin_aiming", player) + fishing_spot.set("_cast_charge", 0.32) + fishing_spot.call("_update_cast_charge", 0.0) + fishing_spot.call("_confirm_cast") + var second_wait_deadline: int = Time.get_ticks_msec() + 4000 + while ( + Time.get_ticks_msec() < second_wait_deadline + and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) + attempts = service.get("_attempts") + attempt = attempts.get(session.get_local_peer_id()) + assert(attempt != null) + service.call("_start_bite", attempt) + await process_frame + assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING) + var catalog: FishPool = main.get("fish_catalog") as FishPool + assert(catalog != null) + var fish: FishData = catalog.get_fish_by_id(attempt.fish_id) + var fish_catch := FishCatch.from_network_dict( + attempt.catch_payload, + fish, + ) + assert(fish_catch != null and fish_catch.is_valid()) + var baseline_controller := CatchController.new() + root.add_child(baseline_controller) + baseline_controller.start_authoritative_encounter( + fish.catch_profile, + attempt.reel_speed, + attempt.barrier_damage, + attempt.encounter_seed, + FishQuality.Tier.BORING, + ) + var quality_barriers: Array = attempt.controller.get("_barriers") + var baseline_barriers: Array = baseline_controller.get("_barriers") + assert(quality_barriers.size() == baseline_barriers.size()) + for barrier_index: int in quality_barriers.size(): + var quality_barrier: RefCounted = quality_barriers[barrier_index] + var baseline_barrier: RefCounted = baseline_barriers[barrier_index] + assert( + int(quality_barrier.get("maximum_health")) + == FishQuality.apply_barrier_health( + int(baseline_barrier.get("maximum_health")), + fish_catch.quality, + ) + ) + baseline_controller.queue_free() + service.call("_cancel_attempt", session.get_local_peer_id(), "") + await process_frame + assert(not service.has_local_attempt()) + print("Fishing authority validation: PASS") session.disconnect_session("") main.queue_free() diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd new file mode 100644 index 0000000..5994eae --- /dev/null +++ b/tests/job_multiplayer_validation.gd @@ -0,0 +1,111 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18139 + + +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("Job multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + var jobs := main.get_node("%PlayerJobService") as PlayerJobService + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + await process_frame + assert(session.set_host_open(true)) + var host_board: Dictionary = jobs.get_host_board_network_data() + assert(PlayerJobService.validate_board(host_board)) + + var remote_peer_id: int = 0 + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + remote_peer_id = peer_id + break + assert(remote_peer_id > 1) + assert(session.peer_supports_capability( + remote_peer_id, NetworkProtocol.JOBS_CAPABILITY + )) + + var disconnect_deadline: int = Time.get_ticks_msec() + 12000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + print("Job multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +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 jobs := main.get_node("%PlayerJobService") as PlayerJobService + var weather := main.get_node("%WorldWeatherService") as WorldWeatherService + 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")) + and not jobs.get_plan_id().is_empty() + ): + break + assert(session.is_joined_client()) + assert(session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY)) + assert(not jobs.get_plan_id().is_empty()) + assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT) + assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT) + assert(weather.get_daily_plan_id().is_empty()) + var game_ui := main.get_node("%GameUI") as GameUI + var player_menu := game_ui.get("_player_menu") as PlayerMenu + player_menu.call("_show_section_immediate", PlayerMenu.Section.NET) + assert((player_menu.get_node("%TheNetPage") as TheNetPage).visible) + print("Job multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +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 diff --git a/tests/job_multiplayer_validation.gd.uid b/tests/job_multiplayer_validation.gd.uid new file mode 100644 index 0000000..1b526ba --- /dev/null +++ b/tests/job_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://c4y0pmxk2bhmu diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd new file mode 100644 index 0000000..24cad9d --- /dev/null +++ b/tests/job_system_validation.gd @@ -0,0 +1,256 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres") + + +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 session := main.get_node("%NetworkSession") as NetworkSession + assert(session != null) + var occupied_port := PacketPeerUDP.new() + assert(occupied_port.bind(18137, "127.0.0.1") == OK) + assert(session.start_private_host(18137, 3)) + assert(session.get_host_port() == 18138) + occupied_port.close() + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + await physics_frame + await physics_frame + + var jobs := main.get_node("%PlayerJobService") as PlayerJobService + var weather := main.get_node("%WorldWeatherService") as WorldWeatherService + var network_fishing := main.get( + "_network_fishing" + ) as NetworkFishingService + var network_sale := main.get("_network_sale") as NetworkSaleService + var player := main.get("_player") as Player + assert(jobs != null and weather != null and player != null) + assert(network_fishing != null and network_sale != null) + + var board: Dictionary = jobs.get_host_board_network_data() + assert(PlayerJobService.validate_board(board)) + assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT) + assert(weather.get_daily_plan_id() == jobs.get_plan_id()) + _validate_weather_guarantees(board) + _validate_late_board_weather_fallback() + _validate_remote_tamper_rejection(jobs, board) + + var sell_job: Dictionary = _find_job( + jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL + ) + assert(not sell_job.is_empty()) + var sell_target: int = int(sell_job.get("target", 0)) + var sold_ids: Array[StringName] = [] + for index: int in sell_target * 2: + sold_ids.append(StringName("job-sale-%d" % index)) + network_sale.local_sale_finished.emit( + "job-test", true, "sold", sold_ids, 1 + ) + await process_frame + var pending: Array[Dictionary] = jobs.get_pending_rewards() + assert(pending.size() == 1) + var refreshed_sell: Dictionary = _find_job( + jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL + ) + assert(int(refreshed_sell.get("progress", -1)) == sell_target) + assert(int(refreshed_sell.get("completed_count", 0)) == 1) + + var game_ui := main.get_node("%GameUI") as GameUI + var player_menu := game_ui.get("_player_menu") as PlayerMenu + assert(player_menu != null) + player_menu.open_menu() + for _frame: int in 24: + await process_frame + assert(player_menu.visible) + player_menu.call("_show_section_immediate", PlayerMenu.Section.NET) + var net_page := player_menu.get_node("%TheNetPage") as TheNetPage + assert(net_page.visible) + assert((player_menu.get_node("%TheNetTab") as Button).button_pressed) + assert( + (player_menu.get_node("%NavigationCluster") as Control).get_child_count() + == 7 + ) + + var wallet_before: int = player.wallet.get_balance() + var experience_before: int = player.experience.get_total_experience() + var first_claim_id: String = str(pending[0].get("claim_id", "")) + net_page.call("_claim", first_claim_id) + await process_frame + await process_frame + assert(bool(game_ui.get("_experience_animation_active"))) + var experience_presentation := ( + game_ui.get_node("%ExperiencePresentation") as Control + ) + assert(experience_presentation.visible) + assert((game_ui.get_node("%ExperienceProgressPanel") as Control).visible) + assert((game_ui.get_node("%ExperienceBubble") as Control).visible) + assert( + experience_presentation.get_index() + == experience_presentation.get_parent().get_child_count() - 1 + ) + assert(not (game_ui.get_node("%GameplayTransientHUD") as Control).visible) + assert(player_menu.visible) + assert(not jobs.claim(first_claim_id)) + assert( + player.wallet.get_balance() + == wallet_before + int(pending[0].get("fish_coin", 0)) + ) + assert( + player.experience.get_total_experience() + == experience_before + int(pending[0].get("experience", 0)) + ) + refreshed_sell = _find_job( + jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL + ) + assert(int(refreshed_sell.get("progress", -1)) == sell_target) + assert(int(refreshed_sell.get("completed_count", 0)) == 1) + assert(not bool(refreshed_sell.get("claimable", true))) + network_sale.local_sale_finished.emit( + "job-test-repeat", true, "sold", sold_ids, 1 + ) + await process_frame + assert(jobs.get_pending_rewards().is_empty()) + refreshed_sell = _find_job( + jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL + ) + assert(int(refreshed_sell.get("progress", -1)) == sell_target) + assert(int(refreshed_sell.get("completed_count", 0)) == 1) + + var fresh_job: Dictionary = _find_job( + jobs.get_daily_jobs(), JobCatalog.Kind.CATCH_WATER + ) + assert(not fresh_job.is_empty()) + var bluegill: FishData = Catalog.get_fish_by_id(&"bluegill") + assert(bluegill != null) + for index: int in int(fresh_job.get("target", 0)): + var fish_catch := FishCatch.new() + fish_catch.fish = bluegill + fish_catch.fish_id = bluegill.id + fish_catch.catch_id = StringName("job-catch-%d" % index) + fish_catch.catch_sequence = index + 1 + fish_catch.weight_lb = bluegill.get_minimum_weight() + fish_catch.display_scale = bluegill.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.quality = FishQuality.Tier.BORING + fish_catch.sale_value = bluegill.get_sale_value_for_weight( + fish_catch.weight_lb + ) + assert(fish_catch.is_valid()) + network_fishing.local_catch_received.emit(fish_catch) + await process_frame + var refreshed_fresh: Dictionary = _find_job( + jobs.get_daily_jobs(), JobCatalog.Kind.CATCH_WATER + ) + assert(int(refreshed_fresh.get("completed_count", 0)) == 1) + + var pause_menu := game_ui.get_pause_menu() + var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage + assert(join_page != null) + join_page.call("_refresh") + var session_summary := ( + join_page.get_node("%SessionSummary") as Label + ) + assert("UDP 18138" in session_summary.text) + assert(session.set_host_open(true)) + await process_frame + assert("UDP 18138" in session_summary.text) + assert(session.set_host_open(false)) + + assert(save_manager.save_now()) + var save_file := FileAccess.open( + str(save_manager.get("_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 save_data: Dictionary = parsed + assert(int(save_data.get("save_version", -1)) == 7) + assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) + + main.queue_free() + await process_frame + print("Job system validation: PASS") + quit() + + +func _validate_weather_guarantees(board: Dictionary) -> void: + var jobs: Array = board.get("jobs", []) + var schedule: Array = board.get("weather_schedule", []) + var anchor_index: int = int(board.get("schedule_anchor_index", 0)) + for required_weather: int in JobCatalog.weather_requirements(jobs): + var occurrences: int = 0 + var has_later_window: bool = false + for index: int in schedule.size(): + var entry: Dictionary = schedule[index] + if int(entry.get("weather", -1)) != required_weather: + continue + occurrences += 1 + if index > anchor_index: + has_later_window = true + assert(occurrences >= 2) + assert(has_later_window) + + +func _validate_late_board_weather_fallback() -> void: + var late_jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs( + "late-board-validation", [], false + ) + assert(JobCatalog.weather_requirements(late_jobs).is_empty()) + var anchored_jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs( + "anchored-board-validation", [], true + ) + var anchored_schedule: Array[Dictionary] = ( + JobCatalog.generate_weather_schedule( + "anchored-board-validation", anchored_jobs, 9 + ) + ) + for required_weather: int in JobCatalog.weather_requirements(anchored_jobs): + var occurrence_indices: Array[int] = [] + for index: int in anchored_schedule.size(): + var entry: Dictionary = anchored_schedule[index] + if ( + index >= 9 + and int(entry.get("weather", -1)) == required_weather + ): + occurrence_indices.append(index) + assert(occurrence_indices.size() >= 2) + assert(occurrence_indices[0] >= 9) + assert(occurrence_indices[-1] > 9) + + +func _validate_remote_tamper_rejection( + jobs: PlayerJobService, + board: Dictionary, +) -> void: + var tampered: Dictionary = board.duplicate(true) + var tampered_jobs: Array = tampered.get("jobs", []) + var first_job: Dictionary = tampered_jobs[0] + first_job["fish_coin"] = int(first_job.get("fish_coin", 0)) + 1 + tampered_jobs[0] = first_job + tampered["jobs"] = tampered_jobs + assert(PlayerJobService.validate_board(tampered)) + assert(not jobs.apply_remote_board(tampered)) + + +func _find_job(jobs: Array[Dictionary], kind: JobCatalog.Kind) -> Dictionary: + for job: Dictionary in jobs: + if int(job.get("kind", -1)) == int(kind): + return job + return {} diff --git a/tests/job_system_validation.gd.uid b/tests/job_system_validation.gd.uid new file mode 100644 index 0000000..d37784b --- /dev/null +++ b/tests/job_system_validation.gd.uid @@ -0,0 +1 @@ +uid://b2grposd30lkw diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index e7b8c18..c9b36cd 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,11 +143,14 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 6) + assert(int(migrated.get("save_version", -1)) == 7) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) assert(is_equal_approx(float(world_data.get("time_hours", -1.0)), 8.0)) + assert( + PlayerJobService.validate_save_data(migrated.get("jobs", {})) + ) manager.queue_free() diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 6aa89aa..2d82aea 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -38,6 +38,10 @@ func _run_host() -> void: world_weather.apply_authoritative_snapshot( WorldWeatherService.Weather.RAINY, 300.0 ) + assert( + world_weather.get_persistent_weather() + == WorldWeatherService.Weather.RAINY + ) assert(session.set_host_open(true)) var remote_peer_id: int = 0 @@ -65,6 +69,10 @@ func _run_host() -> void: world_weather.apply_authoritative_snapshot( WorldWeatherService.Weather.FOGGY, 300.0 ) + assert( + world_weather.get_persistent_weather() + == WorldWeatherService.Weather.FOGGY + ) var disconnect_deadline: int = Time.get_ticks_msec() + 12000 while ( Time.get_ticks_msec() < disconnect_deadline @@ -105,6 +113,10 @@ func _run_client() -> void: NetworkProtocol.WORLD_WEATHER_CAPABILITY )) assert(world_time.restore_persistent_time_hours(15.25)) + assert(world_weather.restore_persistent_state( + WorldWeatherService.Weather.CLOUDY, + 222.0, + )) var initial_deadline: int = Time.get_ticks_msec() + 8000 while ( @@ -162,6 +174,14 @@ func _run_client() -> void: await process_frame assert(world_weather.is_foggy()) assert(weather_icon.get_weather() == WorldWeatherService.Weather.FOGGY) + assert( + world_weather.get_persistent_weather() + == WorldWeatherService.Weather.CLOUDY + ) + assert(is_equal_approx( + world_weather.get_persistent_seconds_remaining(), + 222.0, + )) chat_ui.call("set_dock_right", true) await process_frame assert(clock_panel.position.x > 1000.0) diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd index 1908951..a5fde90 100644 --- a/tests/world_time_validation.gd +++ b/tests/world_time_validation.gd @@ -76,6 +76,20 @@ func _validate_clock_boundaries_and_duration() -> void: assert(is_equal_approx(clock.get_time_hours(), 8.0)) assert(not clock.is_night_period()) assert(clock.is_transition()) + + # Small high-refresh deltas must still advance the displayed minute. Using + # is_equal_approx() per frame used to suppress every clock notification. + var emitted_times: Array[float] = [] + clock.time_changed.connect( + func(time_hours: float, _phase: WorldTimeService.Phase) -> void: + emitted_times.append(time_hours) + ) + clock.begin_session(12.0 + 31.0 / 60.0) + emitted_times.clear() + for _frame: int in 1201: + clock.advance_time(1.0 / 240.0) + assert(clock.get_clock_text() == "12:33 pm") + assert(emitted_times.size() >= 2) clock.queue_free() diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index 1238fc4..a5838f8 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -24,6 +24,7 @@ func _initialize() -> void: func _run() -> void: _validate_weather_scheduler() + _validate_weather_persistence() _validate_snapshot_bounds() _validate_fishing_weather_seams() _validate_fishing_spot_context() @@ -73,6 +74,51 @@ func _duration_is_valid(weather: WorldWeatherServiceType) -> bool: return false +func _validate_weather_persistence() -> void: + var weather := WorldWeatherServiceType.new() + root.add_child(weather) + weather.set_persistence_tracking_enabled(true) + weather.begin_authoritative_session(20260802) + weather.apply_authoritative_snapshot( + WorldWeatherServiceType.Weather.RAINY, + 200.0, + ) + weather.advance_weather(12.5) + assert(weather.has_persistent_state()) + assert( + weather.get_persistent_weather() + == WorldWeatherServiceType.Weather.RAINY + ) + assert(is_equal_approx( + weather.get_persistent_seconds_remaining(), + 187.5, + )) + weather.set_persistence_tracking_enabled(false) + weather.begin_remote_session() + weather.apply_authoritative_snapshot( + WorldWeatherServiceType.Weather.FOGGY, + 45.0, + ) + assert(weather.is_foggy()) + assert( + weather.get_persistent_weather() + == WorldWeatherServiceType.Weather.RAINY + ) + assert(is_equal_approx( + weather.get_persistent_seconds_remaining(), + 187.5, + )) + weather.set_persistence_tracking_enabled(true) + weather.begin_authoritative_session(20260803) + assert(weather.is_raining()) + assert(is_equal_approx(weather.get_seconds_remaining(), 187.5)) + assert(not weather.restore_persistent_state( + WorldWeatherServiceType.Weather.RAINY, + WorldWeatherServiceType.MAX_PERSISTED_SECONDS + 1.0, + )) + weather.queue_free() + + func _validate_snapshot_bounds() -> void: assert(NetworkWorldWeatherServiceType.validate_snapshot({ "session_id": "session", @@ -187,6 +233,22 @@ func _validate_weather_presentation() -> void: assert(rain != null) assert(rain.emitting) assert(rain.amount_ratio > 0.99) + assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT) + var rain_material := rain.process_material as ParticleProcessMaterial + assert(rain_material != null) + assert(is_equal_approx( + rain_material.initial_velocity_min, + WorldTimeVisualControllerType.RAIN_VELOCITY_MIN, + )) + assert(is_equal_approx( + rain_material.initial_velocity_max, + WorldTimeVisualControllerType.RAIN_VELOCITY_MAX, + )) + var rain_mesh := rain.draw_pass_1 as BoxMesh + assert(rain_mesh != null) + assert(rain_mesh.size.is_equal_approx( + WorldTimeVisualControllerType.RAIN_DROP_SIZE + )) visuals.apply_weather_immediately( WorldWeatherServiceType.Weather.SUNNY ) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 0bcaa53..16ad69d 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -60,6 +60,7 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _status_label: Label = %StatusLabel @onready var _gameplay_transient_hud: Control = %GameplayTransientHUD +@onready var _experience_presentation: Control = %ExperiencePresentation @onready var _catch_track: Control = %CatchTrack @onready var _green_catch_progress: ProgressBar = %GreenCatchProgress @onready var _red_chase_progress: ProgressBar = %RedChaseProgress @@ -117,6 +118,15 @@ var _experience_panel_rest_y: float = 18.0 func _ready() -> void: + # Reward feedback must remain above full-screen canonical menus. Keeping the + # overlay as the final stage child makes that ownership explicit instead of + # relying on scene declaration order when another menu adds high-z children. + var presentation_parent: Node = _experience_presentation.get_parent() + if presentation_parent != null: + presentation_parent.move_child( + _experience_presentation, + presentation_parent.get_child_count() - 1, + ) _emote_radial_menu.emote_selected.connect(_on_emote_selected) _chat_ui.text_entry_ownership_changed.connect( _on_chat_text_entry_ownership_changed @@ -171,6 +181,7 @@ func setup( art_unlocks: PlayerArtUnlocks, world_time: WorldTimeServiceType, world_weather: WorldWeatherServiceType, + player_jobs: PlayerJobService, ) -> void: _player = player _fishing_spot = fishing_spot @@ -211,6 +222,7 @@ func setup( player, inventory, collection_log, + experience, wallet, sale_service, default_buyer, @@ -226,6 +238,8 @@ func setup( reservations, network_profile_service, network_player_list, + player_jobs, + world_time, ) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot, inventory) _fishing_shop.setup( @@ -386,6 +400,7 @@ func get_pause_menu() -> PauseMenuType: func set_gameplay_ui_enabled(enabled: bool) -> void: _gameplay_ui_enabled = enabled _gameplay_transient_hud.visible = enabled and not _player_menu_open + _experience_presentation.visible = enabled _refresh_chat_availability() if not enabled: if _surface_drawing != null: @@ -642,6 +657,7 @@ func _on_experience_awarded( "previous_level": previous_level, "new_level": new_level, }) + _start_next_experience_animation() func _start_next_experience_animation() -> void: @@ -785,6 +801,13 @@ func _update_experience_progress(total_experience: int) -> void: func _update_experience_bubble_position() -> void: if not _experience_animation_active or _player == null: return + if _player_menu_open: + _experience_bubble.show() + _experience_bubble.position = Vector2( + (_canonical_stage.size.x - _experience_bubble.size.x) * 0.5, + _experience_panel_rest_y + _experience_panel.size.y + 10.0, + ) + return var camera: Camera3D = _player.get_gameplay_camera() var anchor_position: Vector3 = _player.get_chat_anchor_position() if camera == null or camera.is_position_behind(anchor_position): diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 3d59a55..9d63e54 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -80,7 +80,18 @@ grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 -[node name="ExperienceProgressPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +[node name="ExperiencePresentation" type="Control" parent="UIRoot/CanonicalStage"] +unique_name_in_owner = true +z_index = 200 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="ExperienceProgressPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation"] unique_name_in_owner = true visible = false z_index = 63 @@ -96,40 +107,40 @@ mouse_filter = 2 theme = ExtResource("3_theme") theme_override_styles/panel = SubResource("StyleBox_experience_panel") -[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel"] +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel"] layout_mode = 2 theme_override_constants/margin_left = 14 theme_override_constants/margin_top = 7 theme_override_constants/margin_right = 14 theme_override_constants/margin_bottom = 9 -[node name="Layout" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin"] +[node name="Layout" type="VBoxContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin"] layout_mode = 2 theme_override_constants/separation = 4 -[node name="Header" type="HBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout"] +[node name="Header" type="HBoxContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout"] layout_mode = 2 -[node name="ExperienceLevelLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +[node name="ExperienceLevelLabel" type="Label" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 text = "level 1" theme_override_colors/font_color = Color(0.95, 0.98, 1, 1) theme_override_font_sizes/font_size = 16 -[node name="Spacer" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +[node name="Spacer" type="Control" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout/Header"] layout_mode = 2 size_flags_horizontal = 3 mouse_filter = 2 -[node name="ExperienceAwardLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +[node name="ExperienceAwardLabel" type="Label" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 text = "+0 xp" theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) theme_override_font_sizes/font_size = 16 -[node name="ExperienceProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout"] +[node name="ExperienceProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 20) layout_mode = 2 @@ -139,7 +150,7 @@ theme_override_styles/fill = SubResource("StyleBox_experience_fill") value = 0.0 show_percentage = false -[node name="ExperienceBubble" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +[node name="ExperienceBubble" type="PanelContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation"] unique_name_in_owner = true visible = false z_index = 63 @@ -149,14 +160,14 @@ mouse_filter = 2 theme = ExtResource("3_theme") theme_override_styles/panel = SubResource("StyleBox_experience_panel") -[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceBubble"] +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceBubble"] layout_mode = 2 theme_override_constants/margin_left = 10 theme_override_constants/margin_top = 5 theme_override_constants/margin_right = 10 theme_override_constants/margin_bottom = 5 -[node name="ExperienceBubbleLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceBubble/Margin"] +[node name="ExperienceBubbleLabel" type="Label" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceBubble/Margin"] unique_name_in_owner = true layout_mode = 2 text = "+0 xp!" diff --git a/ui/mail_page.gd b/ui/mail_page.gd index 746835a..c89d618 100644 --- a/ui/mail_page.gd +++ b/ui/mail_page.gd @@ -113,17 +113,7 @@ func set_interactive(value: bool) -> void: func _build_ui() -> void: - var paper := PanelContainer.new() - paper.position = Vector2(116, 126) - paper.size = Vector2(1048, 540) - paper.add_theme_stylebox_override("panel", UtilityPageStyle.panel_style()) - add_child(paper) - var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 34) - margin.add_theme_constant_override("margin_right", 34) - margin.add_theme_constant_override("margin_top", 28) - margin.add_theme_constant_override("margin_bottom", 28) - paper.add_child(margin) + var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self) var root := Control.new() margin.add_child(root) _inbox = _build_inbox() @@ -133,8 +123,8 @@ func _build_ui() -> void: page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.add_child(page) _status = Label.new() - _status.position = Vector2(18, 448) - _status.size = Vector2(900, 28) + _status.position = Vector2(18, 408) + _status.size = Vector2(1024, 28) _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY @@ -147,7 +137,7 @@ func _build_ui() -> void: func _build_inbox() -> Control: var page := Control.new() var title := Label.new() - title.text = "Mail" + title.text = "mail" title.position = Vector2(12, 0) title.size = Vector2(500, 42) title.add_theme_font_size_override("font_size", 30) @@ -170,16 +160,16 @@ func _build_inbox() -> Control: page.add_child(archive_view) var scroll := ScrollContainer.new() scroll.position = Vector2(12, 62) - scroll.size = Vector2(958, 374) + scroll.size = Vector2(1036, 334) page.add_child(scroll) _inbox_list = VBoxContainer.new() - _inbox_list.custom_minimum_size = Vector2(930, 0) + _inbox_list.custom_minimum_size = Vector2(1008, 0) _inbox_list.add_theme_constant_override("separation", 8) scroll.add_child(_inbox_list) _empty_label = Label.new() _empty_label.text = "No letters yet." _empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _empty_label.custom_minimum_size = Vector2(930, 80) + _empty_label.custom_minimum_size = Vector2(1008, 80) _inbox_list.add_child(_empty_label) return page @@ -422,7 +412,7 @@ func _refresh_inbox() -> void: empty.text = ( "No archived letters." if _showing_archive else "No letters yet." ) - empty.custom_minimum_size = Vector2(930, 80) + empty.custom_minimum_size = Vector2(1008, 80) empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _inbox_list.add_child(empty) return @@ -449,7 +439,7 @@ func _refresh_inbox() -> void: first_line.left(72), " · gift enclosed" if gift else "", ] - button.custom_minimum_size = Vector2(930, 54) + button.custom_minimum_size = Vector2(1008, 54) button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.pressed.connect(_open_letter.bind(str(letter["mail_id"]))) UtilityPageStyle.apply_ocean_button(button) diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 81dec27..e096f50 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -11,7 +11,8 @@ enum Mode { } const ADDRESS_FORMAT_HELP: String = ( - "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted." + "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; " + + "include the port shown by a host when it differs." ) const DIRECT_WORKFLOW_HELP: String = ( "%s\nJoin Now connects once; Save Server stores this address locally." @@ -435,10 +436,19 @@ func _refresh() -> void: and _network_session.state != NetworkSession.State.INACTIVE ) if _session_summary.visible: - _session_summary.text = "%d / %d players" % [ - _network_session.get_player_count(), - _network_session.get_session_max_players(), - ] + _session_summary.text = ( + "UDP %d • %d / %d players" + % [ + _network_session.get_host_port(), + _network_session.get_player_count(), + _network_session.get_session_max_players(), + ] + if _network_session.is_host() + else "%d / %d players" % [ + _network_session.get_player_count(), + _network_session.get_session_max_players(), + ] + ) if not direct: if selected: _details.text = _format_entry_details(_selected_entry) diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn index 0337d81..b2597a6 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -163,7 +163,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) theme_override_font_sizes/font_size = 13 -text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted.\nJoin Now connects once; Save Server stores this address locally." +text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; include the port shown by a host when it differs.\nJoin Now connects once; Save Server stores this address locally." horizontal_alignment = 1 autowrap_mode = 2 diff --git a/ui/player_menu.gd b/ui/player_menu.gd index dd13792..af8148d 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -27,6 +27,9 @@ const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const FishBatchSelectionType = preload( "res://ui/fish_batch_selection.gd" ) @@ -87,6 +90,7 @@ enum Section { BAG, TACKLE_BOX, LOGBOOK, + NET, MAIL, PROFILE, PLAYERS, @@ -197,6 +201,7 @@ const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) @onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData @onready var _logbook_page: Control = %LogbookPage @onready var _catalog_logbook: LogbookPage = %CatalogLogbook +@onready var _the_net_page: TheNetPage = %TheNetPage @onready var _mail_page: MailPage = %MailPage @onready var _profile_page: ProfilePage = %ProfilePage @onready var _players_page: PlayersPage = %PlayersPage @@ -215,6 +220,7 @@ const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) @onready var _logbook_page_status: BubbleStatusBubbleType = %LogbookPageStatus @onready var _inventory_tab: BubbleButtonType = %InventoryTab @onready var _logbook_tab: BubbleButtonType = %LogbookTab +@onready var _the_net_tab: BubbleButtonType = %TheNetTab @onready var _mail_tab: BubbleButtonType = %MailTab @onready var _profile_tab: BubbleButtonType = %ProfileTab @onready var _players_tab: BubbleButtonType = %PlayersTab @@ -319,6 +325,7 @@ var _cooler_rest_position: Vector2 = Vector2.ZERO var _bag_rest_position: Vector2 = Vector2.ZERO var _tackle_rest_position: Vector2 = Vector2.ZERO var _logbook_rest_position: Vector2 = Vector2.ZERO +var _the_net_rest_position: Vector2 = Vector2.ZERO var _mail_rest_position: Vector2 = Vector2.ZERO var _profile_rest_position: Vector2 = Vector2.ZERO var _page_outgoing_root: Control @@ -360,6 +367,7 @@ func _ready() -> void: _logbook_tab.pressed.connect( _show_section.bind(Section.LOGBOOK) ) + _the_net_tab.pressed.connect(_show_section.bind(Section.NET)) _mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) _profile_tab.pressed.connect(_show_section.bind(Section.PROFILE)) _players_tab.pressed.connect(_show_section.bind(Section.PLAYERS)) @@ -387,6 +395,7 @@ func _ready() -> void: _navigation_cluster.configure([ _inventory_tab, _logbook_tab, + _the_net_tab, _mail_tab, _profile_tab, _players_tab, @@ -477,6 +486,7 @@ func setup( player: PlayerType, inventory: FishInventoryType, collection_log: CollectionLogType, + experience: PlayerExperienceType, wallet: PlayerWalletType, sale_service: FishSaleServiceType, default_buyer: FishBuyerProfileType, @@ -492,6 +502,8 @@ func setup( reservations: PlayerAssetReservationService, network_profile_service: NetworkProfileService, network_player_list: NetworkPlayerListService, + player_jobs: PlayerJobService, + world_time: WorldTimeService, ) -> void: _player = player _inventory = inventory @@ -514,9 +526,10 @@ func setup( _mail_page.setup( network_mail_service, reservations, inventory, wallet, bag, item_catalog ) - _profile_page.setup(network_profile_service) + _profile_page.setup(network_profile_service, experience) _players_page.setup(network_player_list) _catalog_logbook.setup(collection_log, inventory, catalog) + _the_net_page.setup(player_jobs, world_time) _network_mail_service.unread_count_changed.connect( _on_mail_unread_count_changed ) @@ -898,6 +911,7 @@ func _show_section_immediate(section: Section) -> void: _tackle_box_page.visible = section == Section.TACKLE_BOX _inventory_sub_tabs.visible = _is_inventory_section(section) _logbook_page.visible = section == Section.LOGBOOK + _the_net_page.visible = section == Section.NET _mail_page.visible = section == Section.MAIL _profile_page.visible = section == Section.PROFILE _players_page.visible = section == Section.PLAYERS @@ -922,6 +936,10 @@ func _show_section_immediate(section: Section) -> void: _catalog_logbook.deactivate() else: _catalog_logbook.activate() + if section == Section.NET: + _the_net_page.activate() + else: + _the_net_page.deactivate() if section == Section.MAIL: _mail_page.activate() else: @@ -940,6 +958,7 @@ func _show_section_immediate(section: Section) -> void: _tackle_sub_tab.set_selected(section == Section.TACKLE_BOX) _refresh_inventory_organizer_tabs() _logbook_tab.button_pressed = section == Section.LOGBOOK + _the_net_tab.button_pressed = section == Section.NET _mail_tab.button_pressed = section == Section.MAIL _profile_tab.button_pressed = section == Section.PROFILE _players_tab.button_pressed = section == Section.PLAYERS @@ -1000,6 +1019,8 @@ func _focus_current_section() -> void: _tackle_sub_tab.grab_focus() elif _current_section == Section.LOGBOOK: _catalog_logbook.focus_initial() + elif _current_section == Section.NET: + _the_net_page.focus_initial() elif _current_section == Section.MAIL: _mail_tab.grab_focus() elif _current_section == Section.PLAYERS: @@ -1032,6 +1053,7 @@ func _configure_navigation_focus() -> void: var navigation: Array[BubbleButtonType] = [ _inventory_tab, _logbook_tab, + _the_net_tab, _mail_tab, _profile_tab, _players_tab, @@ -1122,6 +1144,8 @@ func _configure_active_page_focus() -> void: _bait_filter.grab_focus() Section.LOGBOOK: _catalog_logbook.focus_initial() + Section.NET: + _the_net_page.focus_initial() Section.MAIL: _mail_page.activate() Section.PROFILE: @@ -1349,6 +1373,7 @@ func _apply_navigation_styles() -> void: for bubble: BubbleButtonType in [ _inventory_tab, _logbook_tab, + _the_net_tab, _mail_tab, _profile_tab, _players_tab, @@ -1380,6 +1405,7 @@ func _apply_navigation_selection_presentation() -> void: for bubble: BubbleButtonType in [ _inventory_tab, _logbook_tab, + _the_net_tab, _mail_tab, _profile_tab, _players_tab, @@ -1465,6 +1491,7 @@ func _on_mail_unread_count_changed(count: int) -> void: func _set_navigation_target(section: Section) -> void: _inventory_tab.button_pressed = _is_inventory_section(section) _logbook_tab.button_pressed = section == Section.LOGBOOK + _the_net_tab.button_pressed = section == Section.NET _mail_tab.button_pressed = section == Section.MAIL _profile_tab.button_pressed = section == Section.PROFILE _players_tab.button_pressed = section == Section.PLAYERS @@ -1643,14 +1670,18 @@ func _update_shell_layout() -> void: _logbook_page.size = reference_size _logbook_page.position = Vector2.ZERO _logbook_rest_position = Vector2.ZERO + _the_net_page.set_anchors_preset(Control.PRESET_TOP_LEFT) + _the_net_page.size = reference_size + _the_net_page.position = Vector2.ZERO + _the_net_rest_position = Vector2.ZERO _mail_page.set_anchors_preset(Control.PRESET_TOP_LEFT) _mail_page.size = reference_size _mail_page.position = Vector2.ZERO _mail_rest_position = Vector2.ZERO _profile_page.set_anchors_preset(Control.PRESET_TOP_LEFT) - _profile_page.position = Vector2(42.0, 104.0) - _profile_page.size = Vector2(1196.0, 608.0) - _profile_rest_position = _profile_page.position + _profile_page.position = Vector2.ZERO + _profile_page.size = reference_size + _profile_rest_position = Vector2.ZERO _players_page.set_anchors_preset(Control.PRESET_TOP_LEFT) _players_page.position = Vector2.ZERO _players_page.size = reference_size @@ -1673,6 +1704,7 @@ func _update_shell_layout() -> void: _bag_page.position = _bag_rest_position _tackle_box_page.position = _tackle_rest_position _logbook_page.position = _logbook_rest_position + _the_net_page.position = _the_net_rest_position _mail_page.position = _mail_rest_position _profile_page.position = _profile_rest_position _players_page.position = Vector2.ZERO @@ -1680,6 +1712,7 @@ func _update_shell_layout() -> void: _bag_page.modulate.a = 1.0 _tackle_box_page.modulate.a = 1.0 _logbook_page.modulate.a = 1.0 + _the_net_page.modulate.a = 1.0 _mail_page.modulate.a = 1.0 _profile_page.modulate.a = 1.0 _players_page.modulate.a = 1.0 @@ -1805,6 +1838,8 @@ func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void: _settle_inventory_tabs_for_close() if _current_section == Section.LOGBOOK: _catalog_logbook.deactivate() + elif _current_section == Section.NET: + _the_net_page.deactivate() get_viewport().gui_release_focus() var generation: int = _transition_generation var closing_generation: int = _menu_generation @@ -1851,6 +1886,7 @@ func _finish_close( _cancel_presentation_tween() _cancel_page_tween() _cancel_logbook_page_transition(true) + _the_net_page.deactivate() _transitioning = false _page_transitioning = false _bag_drag_active = false @@ -1882,6 +1918,8 @@ func _get_section_root(section: Section) -> Control: return _tackle_box_page Section.LOGBOOK: return _logbook_page + Section.NET: + return _the_net_page Section.MAIL: return _mail_page Section.PROFILE: @@ -1900,6 +1938,8 @@ func _get_section_rest_position(section: Section) -> Vector2: return _tackle_rest_position Section.LOGBOOK: return _logbook_rest_position + Section.NET: + return _the_net_rest_position Section.MAIL: return _mail_rest_position Section.PROFILE: @@ -2002,6 +2042,7 @@ func _set_shell_interactive(interactive: bool) -> void: for bubble: BubbleButtonType in [ _inventory_tab, _logbook_tab, + _the_net_tab, _mail_tab, _profile_tab, _players_tab, @@ -2058,6 +2099,9 @@ func _set_content_interactive(interactive: bool) -> void: _catalog_logbook.set_interactive( interactive and _current_section == Section.LOGBOOK ) + _the_net_page.set_interactive( + interactive and _current_section == Section.NET + ) _mail_page.set_interactive( interactive and _current_section == Section.MAIL ) @@ -2133,6 +2177,7 @@ func _reset_page_transition_visuals() -> void: Section.BAG, Section.TACKLE_BOX, Section.LOGBOOK, + Section.NET, Section.MAIL, Section.PROFILE, Section.PLAYERS, diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index aac0884..e9693fd 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=25 format=3] +[gd_scene load_steps=26 format=3] [ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -23,6 +23,7 @@ [ext_resource type="PackedScene" path="res://ui/logbook_page.tscn" id="21_logbook_page"] [ext_resource type="Script" path="res://ui/components/inventory_notepad.gd" id="22_inventory_notepad"] [ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="23_organizer_tab"] +[ext_resource type="PackedScene" path="res://ui/the_net_page.tscn" id="24_the_net_page"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"] @@ -722,6 +723,10 @@ mouse_filter = 1 [node name="CatalogLogbook" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage" instance=ExtResource("21_logbook_page")] unique_name_in_owner = true +[node name="TheNetPage" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot" instance=ExtResource("24_the_net_page")] +unique_name_in_owner = true +visible = false + [node name="MailPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true layout_mode = 0 @@ -734,9 +739,8 @@ script = ExtResource("18_mail_page") unique_name_in_owner = true visible = false layout_mode = 0 -offset_top = 112.0 offset_right = 1280.0 -offset_bottom = 710.0 +offset_bottom = 720.0 [node name="PlayersPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] visible = false @@ -1390,15 +1394,16 @@ compact_reference_size = Vector2(840, 100) unique_name_in_owner = true layout_mode = 0 toggle_mode = true -text = "inventory" +text = "stuff" +accessibility_name = "stuff" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(112, 108) -desktop_anchor = Vector2(70, 60) -compact_anchor = Vector2(70, 73.3333) +desktop_anchor = Vector2(60, 60) +compact_anchor = Vector2(60, 73.3333) compact_minimum_size = Vector2(84, 82) -minimum_font_size = 20 -maximum_font_size = 22 +minimum_font_size = 25 +maximum_font_size = 25 horizontal_amplitude = 1.2 vertical_amplitude = 2.4 motion_period = 5.6 @@ -1414,8 +1419,8 @@ text = "logbook" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(112, 108) -desktop_anchor = Vector2(210, 60) -compact_anchor = Vector2(210, 73.3333) +desktop_anchor = Vector2(180, 60) +compact_anchor = Vector2(180, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1426,6 +1431,27 @@ motion_phase = 2.85 deformation_amplitude = 0.012 deformation_period = 6.7 +[node name="TheNetTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] +unique_name_in_owner = true +layout_mode = 0 +toggle_mode = true +text = "fishnet" +accessibility_name = "net" +script = ExtResource("6_bubble") +profile = ExtResource("4_profile") +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(300, 60) +compact_anchor = Vector2(300, 73.3333) +compact_minimum_size = Vector2(84, 82) +minimum_font_size = 25 +maximum_font_size = 25 +horizontal_amplitude = 1.2 +vertical_amplitude = 2.3 +motion_period = 5.75 +motion_phase = 3.2 +deformation_amplitude = 0.011 +deformation_period = 6.45 + [node name="MailTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] unique_name_in_owner = true layout_mode = 0 @@ -1434,8 +1460,8 @@ text = "mail" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(112, 108) -desktop_anchor = Vector2(350, 60) -compact_anchor = Vector2(350, 73.3333) +desktop_anchor = Vector2(420, 60) +compact_anchor = Vector2(420, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1470,8 +1496,8 @@ text = "profile" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(112, 108) -desktop_anchor = Vector2(490, 60) -compact_anchor = Vector2(490, 73.3333) +desktop_anchor = Vector2(540, 60) +compact_anchor = Vector2(540, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 23 maximum_font_size = 25 @@ -1486,12 +1512,13 @@ deformation_period = 6.5 unique_name_in_owner = true layout_mode = 0 toggle_mode = true -text = "players" +text = "online" +accessibility_name = "online" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(112, 108) -desktop_anchor = Vector2(630, 60) -compact_anchor = Vector2(630, 73.3333) +desktop_anchor = Vector2(660, 60) +compact_anchor = Vector2(660, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 22 maximum_font_size = 24 @@ -1509,8 +1536,8 @@ text = "close" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(96, 94) -desktop_anchor = Vector2(770, 57) -compact_anchor = Vector2(770, 73.3333) +desktop_anchor = Vector2(780, 57) +compact_anchor = Vector2(780, 73.3333) compact_minimum_size = Vector2(76, 74) minimum_font_size = 23 maximum_font_size = 23 diff --git a/ui/players_page.gd b/ui/players_page.gd index d834176..b5a62ee 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -2,7 +2,7 @@ class_name PlayersPage extends Control var _service: NetworkPlayerListService -var _header: Label +var _count_label: Label var _tabs: HBoxContainer var _list: VBoxContainer var _status: Label @@ -36,23 +36,17 @@ func deactivate() -> void: func _build() -> void: - var paper := PanelContainer.new() - paper.position = Vector2(58, 128) - paper.size = Vector2(1164, 538) - add_child(paper) - paper.add_theme_stylebox_override("panel", UtilityPageStyle.panel_style()) + var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self) var root := VBoxContainer.new() - root.add_theme_constant_override("separation", 10) - paper.add_child(root) - _header = Label.new() - _header.add_theme_font_size_override("font_size", 27) - _header.add_theme_color_override( - "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY - ) - root.add_child(_header) + root.add_theme_constant_override("separation", 9) + margin.add_child(root) + var tab_row := HBoxContainer.new() + tab_row.add_theme_constant_override("separation", 16) + root.add_child(tab_row) _tabs = HBoxContainer.new() + _tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL _tabs.add_theme_constant_override("separation", 10) - root.add_child(_tabs) + tab_row.add_child(_tabs) for index: int in 3: var button := Button.new() button.text = ["players", "relationships", "banned"][index] @@ -60,12 +54,20 @@ func _build() -> void: button.pressed.connect(_select_tab.bind(index)) UtilityPageStyle.apply_ocean_button(button) _tabs.add_child(button) + _count_label = Label.new() + _count_label.add_theme_font_size_override("font_size", 17) + _count_label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + _count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + tab_row.add_child(_count_label) var scroll := ScrollContainer.new() - scroll.custom_minimum_size = Vector2(0, 392) + scroll.custom_minimum_size = Vector2(0, 310) + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED root.add_child(scroll) _list = VBoxContainer.new() - _list.custom_minimum_size = Vector2(1080, 0) + _list.custom_minimum_size = Vector2(1032, 0) _list.add_theme_constant_override("separation", 7) scroll.add_child(_list) _status = Label.new() @@ -86,7 +88,7 @@ func _select_tab(index: int) -> void: func _refresh() -> void: if _service == null or _list == null: return - _header.text = "Players\n%d / %d connected" % [ + _count_label.text = "%d / %d connected" % [ _service.get_connected_count(), _service.get_max_players(), ] for index: int in _tabs.get_child_count(): @@ -168,7 +170,6 @@ func _build_active_rows() -> void: ban.pressed.connect(_confirm_ban.bind(entry)) UtilityPageStyle.apply_ocean_button(ban) row.add_child(ban) - _list.add_child(row) func _build_session_artwork_controls() -> void: @@ -195,7 +196,6 @@ func _build_session_artwork_controls() -> void: ) UtilityPageStyle.apply_ocean_button(reset) row.add_child(reset) - _list.add_child(row) func _build_relationship_rows() -> void: @@ -234,7 +234,6 @@ func _build_relationship_rows() -> void: ) UtilityPageStyle.apply_ocean_button(unmute) row.add_child(unmute) - _list.add_child(row) func _build_ban_rows() -> void: @@ -262,14 +261,18 @@ func _build_ban_rows() -> void: unban.pressed.connect(_confirm_unban.bind(fingerprint)) UtilityPageStyle.apply_ocean_button(unban) row.add_child(unban) - _list.add_child(row) func _make_row() -> HBoxContainer: + var panel := PanelContainer.new() + panel.add_theme_stylebox_override( + "panel", UtilityPageStyle.row_style(false) + ) + _list.add_child(panel) var row := HBoxContainer.new() row.custom_minimum_size.y = 58 row.add_theme_constant_override("separation", 8) - row.add_theme_constant_override("outline_size", 1) + panel.add_child(row) return row diff --git a/ui/profile_page.gd b/ui/profile_page.gd index eb434bc..dac52b3 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -4,6 +4,7 @@ extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 var _service: NetworkProfileService +var _experience: PlayerExperience var _draft_name: String = "" var _draft_appearance: Dictionary = {} var _persisted_name: String = "" @@ -25,6 +26,9 @@ var _confirmation_label: Label var _confirmation_confirm: Button var _confirmation_action: String = "" var _debounce: Timer +var _experience_level: Label +var _experience_progress: ProgressBar +var _experience_value: Label func _ready() -> void: @@ -37,12 +41,24 @@ func _ready() -> void: add_child(_debounce) -func setup(service: NetworkProfileService) -> void: +func setup( + service: NetworkProfileService, + experience: PlayerExperience, +) -> void: _service = service + _experience = experience if not _service.conflict_result.is_connected(_on_conflict_result): _service.conflict_result.connect(_on_conflict_result) _service.apply_finished.connect(_on_apply_finished) + if ( + _experience != null + and not _experience.experience_changed.is_connected( + _on_experience_changed + ) + ): + _experience.experience_changed.connect(_on_experience_changed) _load_persisted() + _refresh_experience() var identity_value := find_child("IdentityFingerprint", true, false) as Label if identity_value != null: var fingerprint := _service.get_identity_fingerprint() @@ -108,134 +124,200 @@ func has_modal_confirmation() -> bool: func _build_ui() -> void: - var paper := PanelContainer.new() - paper.set_anchors_preset(Control.PRESET_FULL_RECT) - paper.offset_left = 64.0 - paper.offset_top = 28.0 - paper.offset_right = -64.0 - paper.offset_bottom = -28.0 - paper.add_theme_stylebox_override( - "panel", UtilityPageStyle.panel_style() - ) - add_child(paper) UtilityPageStyle.apply_page(self) - - var margin := MarginContainer.new() - for side: String in ["left", "right", "top", "bottom"]: - margin.add_theme_constant_override("margin_%s" % side, 18) - paper.add_child(margin) + var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self) var layout := VBoxContainer.new() - layout.add_theme_constant_override("separation", 5) + layout.add_theme_constant_override("separation", 8) margin.add_child(layout) - var heading := Label.new() - heading.text = "Player Profile" - heading.add_theme_font_size_override("font_size", 25) - heading.add_theme_color_override( - "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY - ) - layout.add_child(heading) - - var name_row := HBoxContainer.new() - name_row.add_theme_constant_override("separation", 10) - layout.add_child(name_row) - var name_stack := VBoxContainer.new() - name_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL - name_row.add_child(name_stack) + var account_row := HBoxContainer.new() + account_row.add_theme_constant_override("separation", 14) + layout.add_child(account_row) + var account_stack := VBoxContainer.new() + account_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL + account_stack.add_theme_constant_override("separation", 2) + account_row.add_child(account_stack) + var name_line := HBoxContainer.new() + name_line.add_theme_constant_override("separation", 10) + account_stack.add_child(name_line) var name_label := Label.new() name_label.text = "player name" + name_label.custom_minimum_size.x = 98.0 + name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER name_label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) - name_stack.add_child(name_label) + name_line.add_child(name_label) _name_edit = LineEdit.new() _name_edit.max_length = NetworkProtocol.MAX_DISPLAY_NAME_LENGTH _name_edit.placeholder_text = "Player" - _name_edit.custom_minimum_size = Vector2(300, 40) + _name_edit.custom_minimum_size = Vector2(280, 34) UtilityPageStyle.apply_ocean_line_edit(_name_edit) + _name_edit.add_theme_stylebox_override( + "normal", UtilityPageStyle.ocean_button_style( + UtilityPageStyle.OCEAN_PANEL_MID + ) + ) _name_edit.text_changed.connect(_on_name_changed) - name_stack.add_child(_name_edit) + name_line.add_child(_name_edit) var helper := Label.new() helper.text = "Shown to other players in multiplayer." + helper.add_theme_font_size_override("font_size", 12) helper.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) - name_stack.add_child(helper) - _apply_button = Button.new() - _apply_button.text = "apply" - _apply_button.custom_minimum_size = Vector2(92, 40) - UtilityPageStyle.apply_ocean_button(_apply_button) - _apply_button.pressed.connect(_apply) - name_row.add_child(_apply_button) - _revert_button = Button.new() - _revert_button.text = "revert" - _revert_button.custom_minimum_size = Vector2(88, 40) - UtilityPageStyle.apply_ocean_button(_revert_button) - _revert_button.pressed.connect(_revert) - name_row.add_child(_revert_button) - var defaults_button := Button.new() - defaults_button.text = "defaults" - defaults_button.custom_minimum_size = Vector2(88, 40) - UtilityPageStyle.apply_ocean_button(defaults_button) - defaults_button.pressed.connect(_show_confirmation.bind("defaults")) - name_row.add_child(defaults_button) - + account_stack.add_child(helper) _name_status = Label.new() + _name_status.add_theme_font_size_override("font_size", 12) _name_status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) - layout.add_child(_name_status) + account_stack.add_child(_name_status) _suggestions = HBoxContainer.new() - _suggestions.add_theme_constant_override("separation", 8) - layout.add_child(_suggestions) + _suggestions.add_theme_constant_override("separation", 6) + account_stack.add_child(_suggestions) var identity_value := Label.new() identity_value.name = "IdentityFingerprint" - identity_value.text = "Identity • Stored on this device" + identity_value.text = "identity • stored on this device" identity_value.tooltip_text = ( "This identity helps other players recognize you between sessions." ) + identity_value.add_theme_font_size_override("font_size", 12) identity_value.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) - layout.add_child(identity_value) + account_stack.add_child(identity_value) + var experience_row := HBoxContainer.new() + experience_row.add_theme_constant_override("separation", 9) + account_stack.add_child(experience_row) + _experience_level = Label.new() + _experience_level.custom_minimum_size.x = 64.0 + _experience_level.add_theme_font_size_override("font_size", 14) + _experience_level.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + experience_row.add_child(_experience_level) + _experience_progress = ProgressBar.new() + _experience_progress.custom_minimum_size = Vector2(210.0, 14.0) + _experience_progress.max_value = 1.0 + _experience_progress.show_percentage = false + _experience_progress.add_theme_stylebox_override( + "background", UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_PANEL_MID, 7 + ) + ) + _experience_progress.add_theme_stylebox_override( + "fill", UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_SELECTED, 7 + ) + ) + experience_row.add_child(_experience_progress) + _experience_value = Label.new() + _experience_value.add_theme_font_size_override("font_size", 14) + _experience_value.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + experience_row.add_child(_experience_value) + + var actions := HBoxContainer.new() + actions.alignment = BoxContainer.ALIGNMENT_END + actions.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + actions.add_theme_constant_override("separation", 7) + account_row.add_child(actions) + _apply_button = Button.new() + _apply_button.text = "apply" + _apply_button.custom_minimum_size.x = 72.0 + UtilityPageStyle.apply_compact_ocean_button(_apply_button) + _apply_button.pressed.connect(_apply) + actions.add_child(_apply_button) + _revert_button = Button.new() + _revert_button.text = "revert" + _revert_button.custom_minimum_size.x = 76.0 + UtilityPageStyle.apply_compact_ocean_button(_revert_button) + _revert_button.pressed.connect(_revert) + actions.add_child(_revert_button) + var defaults_button := Button.new() + defaults_button.text = "defaults" + defaults_button.custom_minimum_size.x = 82.0 + UtilityPageStyle.apply_compact_ocean_button(defaults_button) + defaults_button.pressed.connect(_show_confirmation.bind("defaults")) + actions.add_child(defaults_button) + + var body_panel := PanelContainer.new() + body_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + body_panel.add_theme_stylebox_override( + "panel", UtilityPageStyle.row_style(false) + ) + layout.add_child(body_panel) + var body_margin := MarginContainer.new() + body_margin.add_theme_constant_override("margin_left", 14) + body_margin.add_theme_constant_override("margin_top", 10) + body_margin.add_theme_constant_override("margin_right", 14) + body_margin.add_theme_constant_override("margin_bottom", 10) + body_panel.add_child(body_margin) var body := HBoxContainer.new() - body.size_flags_vertical = Control.SIZE_EXPAND_FILL - body.add_theme_constant_override("separation", 12) - layout.add_child(body) + body.alignment = BoxContainer.ALIGNMENT_BEGIN + body.add_theme_constant_override("separation", 16) + body_margin.add_child(body) _category_list = VBoxContainer.new() - _category_list.custom_minimum_size = Vector2(140, 0) + _category_list.custom_minimum_size = Vector2(120, 0) + _category_list.add_theme_constant_override("separation", 5) body.add_child(_category_list) _option_list = VBoxContainer.new() - _option_list.custom_minimum_size = Vector2(190, 0) + _option_list.custom_minimum_size = Vector2(170, 0) + _option_list.add_theme_constant_override("separation", 5) body.add_child(_option_list) + var body_spacer := Control.new() + body_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + body.add_child(body_spacer) var preview_stack := VBoxContainer.new() - preview_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL + preview_stack.custom_minimum_size.x = 180.0 preview_stack.alignment = BoxContainer.ALIGNMENT_CENTER + preview_stack.add_theme_constant_override("separation", 7) body.add_child(preview_stack) + var preview_frame := PanelContainer.new() + preview_frame.custom_minimum_size = Vector2(180.0, 220.0) + preview_frame.add_theme_stylebox_override( + "panel", UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_FIELD, 18 + ) + ) + preview_stack.add_child(preview_frame) _preview = preload("res://ui/profile_preview.tscn").instantiate() - preview_stack.add_child(_preview) - var preview_actions := HBoxContainer.new() + _preview.custom_minimum_size = Vector2(180.0, 220.0) + preview_frame.add_child(_preview) + var preview_actions := VBoxContainer.new() preview_actions.alignment = BoxContainer.ALIGNMENT_CENTER - preview_actions.add_theme_constant_override("separation", 10) + preview_actions.add_theme_constant_override("separation", 4) preview_stack.add_child(preview_actions) var preview_note := Label.new() - preview_note.text = "Current player • drag or use left / right" + preview_note.text = "drag or use left / right" + preview_note.custom_minimum_size.x = 180.0 + preview_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + preview_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + preview_note.add_theme_font_size_override("font_size", 12) preview_note.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) preview_actions.add_child(preview_note) var reset_view := Button.new() reset_view.text = "reset view" + reset_view.size_flags_horizontal = Control.SIZE_SHRINK_CENTER reset_view.pressed.connect(_preview.reset_view) - UtilityPageStyle.apply_ocean_button(reset_view) + UtilityPageStyle.apply_compact_ocean_button(reset_view) preview_actions.add_child(reset_view) _discard_confirmation = PanelContainer.new() _discard_confirmation.visible = false _discard_confirmation.set_anchors_and_offsets_preset(Control.PRESET_CENTER) _discard_confirmation.custom_minimum_size = Vector2(430, 190) + _discard_confirmation.add_theme_stylebox_override( + "panel", UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_PANEL_MID, 14 + ) + ) add_child(_discard_confirmation) var confirm_stack := VBoxContainer.new() confirm_stack.alignment = BoxContainer.ALIGNMENT_CENTER @@ -249,6 +331,7 @@ func _build_ui() -> void: confirm_stack.add_child(confirm_buttons) _confirmation_confirm = Button.new() _confirmation_confirm.pressed.connect(_confirm_pending_action) + UtilityPageStyle.apply_ocean_button(_confirmation_confirm) confirm_buttons.add_child(_confirmation_confirm) var keep := Button.new() keep.name = "KeepEditing" @@ -258,6 +341,7 @@ func _build_ui() -> void: _discard_confirmation.visible = false _name_edit.grab_focus() ) + UtilityPageStyle.apply_ocean_button(keep) confirm_buttons.add_child(keep) _build_categories() @@ -270,10 +354,10 @@ func _build_categories() -> void: var button := Button.new() button.text = CharacterCustomizationCatalog.category_label(category_id) button.toggle_mode = true - button.custom_minimum_size = Vector2(0, 34) + button.custom_minimum_size.x = 120.0 button.button_pressed = category_id == _category_id button.pressed.connect(_select_category.bind(category_id)) - UtilityPageStyle.apply_ocean_button(button) + UtilityPageStyle.apply_compact_ocean_button(button) _category_list.add_child(button) _refresh_options() @@ -294,7 +378,7 @@ func _refresh_options() -> void: child.queue_free() var title := Label.new() title.text = CharacterCustomizationCatalog.category_label(_category_id) - title.add_theme_font_size_override("font_size", 22) + title.add_theme_font_size_override("font_size", 18) title.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -311,10 +395,10 @@ func _refresh_options() -> void: var button := Button.new() button.text = str(option["label"]) button.toggle_mode = true - button.custom_minimum_size = Vector2(0, 34) + button.custom_minimum_size.x = 170.0 button.button_pressed = _draft_appearance.get(_category_id) == option_id button.pressed.connect(_select_option.bind(_category_id, option_id)) - UtilityPageStyle.apply_ocean_button(button) + UtilityPageStyle.apply_compact_ocean_button(button) _option_list.add_child(button) @@ -358,6 +442,7 @@ func _on_conflict_result( var button := Button.new() button.text = suggestion button.pressed.connect(_use_suggestion.bind(suggestion)) + UtilityPageStyle.apply_compact_ocean_button(button) _suggestions.add_child(button) var anyway := Button.new() anyway.text = "use anyway" @@ -365,9 +450,33 @@ func _on_conflict_result( _allow_duplicate = true _name_status.text = "Duplicate name allowed for this apply." ) + UtilityPageStyle.apply_compact_ocean_button(anyway) _suggestions.add_child(anyway) +func _on_experience_changed(_total_experience: int, _level: int) -> void: + _refresh_experience() + + +func _refresh_experience() -> void: + if _experience_level == null: + return + if _experience == null: + _experience_level.text = "level 1" + _experience_progress.value = 0.0 + _experience_value.text = "0 / 100 xp" + return + var level: int = _experience.get_level() + var current: int = _experience.get_experience_in_level() + var required: int = _experience.get_experience_for_next_level() + _experience_level.text = "level %d" % level + _experience_progress.value = _experience.get_level_progress() + _experience_value.text = "%d / %d xp" % [current, required] + _experience_value.tooltip_text = "%d total xp" % ( + _experience.get_total_experience() + ) + + func _use_suggestion(value: String) -> void: _name_edit.text = value _draft_name = value diff --git a/ui/profile_preview.tscn b/ui/profile_preview.tscn index 37be6fa..79cd8fe 100644 --- a/ui/profile_preview.tscn +++ b/ui/profile_preview.tscn @@ -18,7 +18,7 @@ stretch = true script = ExtResource("1") [node name="Viewport" type="SubViewport" parent="."] -transparent_bg = false +transparent_bg = true size = Vector2i(320, 330) render_target_update_mode = 4 world_3d = SubResource("PreviewWorld") diff --git a/ui/the_net_page.gd b/ui/the_net_page.gd new file mode 100644 index 0000000..3ee735a --- /dev/null +++ b/ui/the_net_page.gd @@ -0,0 +1,489 @@ +class_name TheNetPage +extends Control + +enum View { + DAILY, + LIFETIME, + PAYMENTS, +} + +var _jobs: PlayerJobService +var _world_time: WorldTimeService +var _header: Label +var _refresh_label: Label +var _forecast_list: HBoxContainer +var _tabs: HBoxContainer +var _list: VBoxContainer +var _status: Label +var _current_view: View = View.DAILY +var _forecast_start_index: int = -1 +var _active: bool = false +var _interactive: bool = false + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + UtilityPageStyle.apply_page(self) + _build_laptop() + set_process(false) + + +func setup(jobs: PlayerJobService, world_time: WorldTimeService) -> void: + _jobs = jobs + _world_time = world_time + _jobs.changed.connect(_refresh) + _jobs.status_changed.connect(_on_status_changed) + _refresh() + + +func activate() -> void: + _active = true + set_process(true) + _refresh() + focus_initial() + + +func deactivate() -> void: + _active = false + set_process(false) + set_interactive(false) + _status.text = "" + + +func set_interactive(interactive: bool) -> void: + _interactive = interactive + mouse_filter = ( + Control.MOUSE_FILTER_PASS + if interactive + else Control.MOUSE_FILTER_IGNORE + ) + if _tabs != null: + for child: Node in _tabs.get_children(): + var button := child as Button + if button != null: + button.focus_mode = ( + Control.FOCUS_ALL + if interactive + else Control.FOCUS_NONE + ) + button.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if interactive + else Control.MOUSE_FILTER_IGNORE + ) + _refresh() + + +func focus_initial() -> void: + if _interactive and _tabs != null and _tabs.get_child_count() > 0: + var button := _tabs.get_child(int(_current_view)) as Button + if button != null: + button.grab_focus() + + +func _process(_delta: float) -> void: + if _active and _jobs != null: + _refresh_label.text = _daily_refresh_text() + _refresh_forecast(false) + + +func _build_laptop() -> void: + var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self) + + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 9) + margin.add_child(layout) + + var title_row := HBoxContainer.new() + title_row.add_theme_constant_override("separation", 16) + layout.add_child(title_row) + var header_left := VBoxContainer.new() + header_left.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_left.add_theme_constant_override("separation", 0) + title_row.add_child(header_left) + _header = Label.new() + _header.text = "fishnet" + _header.add_theme_font_size_override("font_size", 32) + _header.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + header_left.add_child(_header) + var header_spacer := Control.new() + header_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + header_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + header_left.add_child(header_spacer) + _tabs = HBoxContainer.new() + _tabs.add_theme_constant_override("separation", 8) + header_left.add_child(_tabs) + for view_index: int in 3: + var tab := Button.new() + tab.text = ["daily jobs", "lifetime jobs", "payments"][view_index] + tab.toggle_mode = true + tab.pressed.connect(_select_view.bind(view_index as View)) + UtilityPageStyle.apply_ocean_button(tab) + _tabs.add_child(tab) + var refresh_alignment_spacer := Control.new() + refresh_alignment_spacer.custom_minimum_size.y = 20.0 + refresh_alignment_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + header_left.add_child(refresh_alignment_spacer) + var forecast_column := VBoxContainer.new() + forecast_column.custom_minimum_size.x = 292.0 + forecast_column.size_flags_horizontal = Control.SIZE_SHRINK_END + forecast_column.add_theme_constant_override("separation", 3) + title_row.add_child(forecast_column) + var forecast_panel := PanelContainer.new() + forecast_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + forecast_panel.add_theme_stylebox_override( + "panel", UtilityPageStyle.row_style(false) + ) + forecast_column.add_child(forecast_panel) + var forecast_margin := MarginContainer.new() + forecast_margin.add_theme_constant_override("margin_left", 10) + forecast_margin.add_theme_constant_override("margin_top", 8) + forecast_margin.add_theme_constant_override("margin_right", 10) + forecast_margin.add_theme_constant_override("margin_bottom", 8) + forecast_panel.add_child(forecast_margin) + var forecast_stack := VBoxContainer.new() + forecast_stack.add_theme_constant_override("separation", 7) + forecast_margin.add_child(forecast_stack) + var forecast_heading := Label.new() + forecast_heading.text = "weather forecast" + forecast_heading.add_theme_font_size_override("font_size", 20) + forecast_heading.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + forecast_stack.add_child(forecast_heading) + _forecast_list = HBoxContainer.new() + _forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER + _forecast_list.add_theme_constant_override("separation", 6) + forecast_stack.add_child(_forecast_list) + _refresh_label = Label.new() + _refresh_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _refresh_label.add_theme_font_size_override("font_size", 14) + _refresh_label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + forecast_column.add_child(_refresh_label) + + var jobs_column := VBoxContainer.new() + jobs_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + jobs_column.size_flags_vertical = Control.SIZE_EXPAND_FILL + jobs_column.add_theme_constant_override("separation", 8) + layout.add_child(jobs_column) + + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + jobs_column.add_child(scroll) + _list = VBoxContainer.new() + _list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _list.add_theme_constant_override("separation", 7) + scroll.add_child(_list) + + _status = Label.new() + _status.add_theme_font_size_override("font_size", 16) + _status.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + jobs_column.add_child(_status) + +func _select_view(view: View) -> void: + _current_view = view + _refresh() + focus_initial() + + +func _refresh() -> void: + if _jobs == null or _list == null: + return + _refresh_label.text = _daily_refresh_text() + _refresh_forecast(true) + for index: int in _tabs.get_child_count(): + var tab := _tabs.get_child(index) as Button + if tab != null: + tab.button_pressed = index == int(_current_view) + for child: Node in _list.get_children(): + _list.remove_child(child) + child.queue_free() + match _current_view: + View.DAILY: + _build_job_rows(_jobs.get_daily_jobs(), "no daily jobs available") + View.LIFETIME: + _build_job_rows( + _jobs.get_lifetime_jobs(), "all lifetime jobs complete" + ) + View.PAYMENTS: + _build_payment_rows() + + +func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void: + if jobs.is_empty(): + _add_empty(empty_text) + return + for job: Dictionary in jobs: + var row := PanelContainer.new() + row.add_theme_stylebox_override( + "panel", UtilityPageStyle.row_style(false) + ) + var content := HBoxContainer.new() + content.add_theme_constant_override("separation", 14) + row.add_child(content) + + var text_column := VBoxContainer.new() + text_column.custom_minimum_size.x = 245.0 + text_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_child(text_column) + var title := Label.new() + title.text = str(job.get("title", "job")) + title.add_theme_font_size_override("font_size", 20) + title.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + text_column.add_child(title) + var description := Label.new() + description.text = str(job.get("description", "")) + description.add_theme_font_size_override("font_size", 16) + description.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + text_column.add_child(description) + + var target: int = int(job.get("target", 1)) + var progress: int = int(job.get("progress", 0)) + var progress_bar := ProgressBar.new() + progress_bar.custom_minimum_size = Vector2(100.0, 24.0) + progress_bar.max_value = float(target) + progress_bar.value = float(progress) + progress_bar.show_percentage = false + progress_bar.add_theme_stylebox_override( + "background", UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_PANEL_DEEP, 9 + ) + ) + progress_bar.add_theme_stylebox_override( + "fill", UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_SELECTED, 9 + ) + ) + content.add_child(progress_bar) + + var count := Label.new() + count.custom_minimum_size.x = 54.0 + count.text = "%d / %d" % [progress, target] + count.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + count.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + content.add_child(count) + + var reward := Label.new() + reward.custom_minimum_size.x = 98.0 + reward.text = "$%d • %d xp" % [ + int(job.get("fish_coin", 0)), int(job.get("experience", 0)), + ] + reward.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + reward.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + content.add_child(reward) + + var claim := Button.new() + claim.custom_minimum_size = Vector2(76.0, 36.0) + var claimable: bool = bool(job.get("claimable", false)) + claim.text = "claim" if claimable else ( + "done" if int(job.get("completed_count", 0)) > 0 else "working" + ) + claim.disabled = not claimable or not _interactive + claim.focus_mode = ( + Control.FOCUS_ALL if not claim.disabled else Control.FOCUS_NONE + ) + claim.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if not claim.disabled + else Control.MOUSE_FILTER_IGNORE + ) + var claim_id: String = str( + job.get("claim_id", job.get("id", "")) + ) + claim.pressed.connect(_claim.bind(claim_id)) + UtilityPageStyle.apply_compact_ocean_button(claim) + content.add_child(claim) + _list.add_child(row) + + +func _build_payment_rows() -> void: + var rewards: Array[Dictionary] = _jobs.get_pending_rewards() + if rewards.is_empty(): + _add_empty("no completed payments waiting") + return + for reward: Dictionary in rewards: + var row := PanelContainer.new() + row.add_theme_stylebox_override( + "panel", UtilityPageStyle.row_style(false) + ) + var content := HBoxContainer.new() + content.add_theme_constant_override("separation", 14) + row.add_child(content) + var label := Label.new() + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.text = "%s\n$%d • %d xp" % [ + str(reward.get("title", "completed job")), + int(reward.get("fish_coin", 0)), + int(reward.get("experience", 0)), + ] + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + content.add_child(label) + var claim := Button.new() + claim.text = "claim" + claim.custom_minimum_size = Vector2(110.0, 42.0) + claim.disabled = not _interactive + claim.focus_mode = ( + Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE + ) + claim.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if _interactive + else Control.MOUSE_FILTER_IGNORE + ) + claim.pressed.connect( + _claim.bind(str(reward.get("claim_id", ""))) + ) + UtilityPageStyle.apply_ocean_button(claim) + content.add_child(claim) + _list.add_child(row) + + +func _claim(claim_id: String) -> void: + if not _jobs.claim(claim_id): + _status.text = "payment could not be completed" + _refresh() + + +func _add_empty(message: String) -> void: + var empty := Label.new() + empty.text = message + empty.custom_minimum_size = Vector2(0.0, 92.0) + empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + empty.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + empty.add_theme_font_size_override("font_size", 20) + empty.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + _list.add_child(empty) + + +func _refresh_forecast(force: bool) -> void: + if _forecast_list == null: + return + var current_index: int = _current_forecast_index() + if not force and current_index == _forecast_start_index: + return + _forecast_start_index = current_index + for child: Node in _forecast_list.get_children(): + _forecast_list.remove_child(child) + child.queue_free() + if _jobs == null or _world_time == null: + _add_forecast_empty("forecast unavailable") + return + var forecast: Array[Dictionary] = _jobs.get_forecast() + if forecast.is_empty(): + _add_forecast_empty("forecast unavailable") + return + current_index = clampi(current_index, 0, forecast.size() - 1) + var visible_count: int = mini(4, forecast.size()) + for offset: int in visible_count: + var index: int = (current_index + offset) % forecast.size() + var entry: Dictionary = forecast[index] + var start_hour: float = float(entry.get("start_hour", 0.0)) + var weather: WorldWeatherService.Weather = ( + int(entry.get("weather", 0)) as WorldWeatherService.Weather + ) + var slot := Control.new() + slot.custom_minimum_size = Vector2(63.0, 66.0) + slot.mouse_filter = Control.MOUSE_FILTER_IGNORE + _forecast_list.add_child(slot) + var icon := WeatherIcon.new() + icon.position = Vector2(1.0, 7.0) + icon.size = Vector2(56.0, 56.0) + icon.custom_minimum_size = Vector2(56.0, 56.0) + icon.set_weather(weather) + icon.set_nighttime( + WorldTimeService.phase_for_hour(start_hour) + == WorldTimeService.Phase.NIGHT + ) + slot.add_child(icon) + var time_label := Label.new() + time_label.position = Vector2(34.0, 0.0) + time_label.size = Vector2(29.0, 18.0) + time_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + time_label.text = _forecast_exponent_time(start_hour) + time_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + time_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + time_label.add_theme_font_size_override("font_size", 10) + time_label.add_theme_constant_override("outline_size", 3) + time_label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + time_label.add_theme_color_override( + "font_outline_color", UtilityPageStyle.OCEAN_PANEL_DEEP + ) + slot.add_child(time_label) + + +func _current_forecast_index() -> int: + if _world_time == null: + return -1 + var elapsed: float = fposmod( + _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR, + WorldTimeService.HOURS_PER_DAY, + ) + return floori(elapsed / JobCatalog.WEATHER_SEGMENT_HOURS) + + +func _compact_clock_time(time_hours: float) -> String: + var normalized: float = fposmod( + time_hours, WorldTimeService.HOURS_PER_DAY + ) + var total_minutes: int = floori(normalized * 60.0) + var hour_24: int = floori(float(total_minutes) / 60.0) + var minute: int = total_minutes % 60 + var hour_12: int = hour_24 % 12 + if hour_12 == 0: + hour_12 = 12 + var suffix: String = "AM" if hour_24 < 12 else "PM" + if minute == 0: + return "%d%s" % [hour_12, suffix] + return "%d:%02d%s" % [hour_12, minute, suffix] + + +func _forecast_exponent_time(time_hours: float) -> String: + return _compact_clock_time(time_hours).to_lower() + + +func _daily_refresh_text() -> String: + if _jobs == null: + return "daily jobs unavailable" + var countdown: String = _jobs.get_time_until_refresh_text() + if countdown.begins_with("refreshes in "): + return "daily jobs refresh in %s" % countdown.trim_prefix( + "refreshes in " + ) + return countdown + + +func _add_forecast_empty(message: String) -> void: + var label := Label.new() + label.text = message + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + _forecast_list.add_child(label) + + +func _on_status_changed(message: String) -> void: + _status.text = message diff --git a/ui/the_net_page.gd.uid b/ui/the_net_page.gd.uid new file mode 100644 index 0000000..614db5e --- /dev/null +++ b/ui/the_net_page.gd.uid @@ -0,0 +1 @@ +uid://couoolj844sj3 diff --git a/ui/the_net_page.tscn b/ui/the_net_page.tscn new file mode 100644 index 0000000..db0bb43 --- /dev/null +++ b/ui/the_net_page.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/the_net_page.gd" id="1_net"] + +[node name="TheNetPage" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_net") diff --git a/ui/utility_page_style.gd b/ui/utility_page_style.gd index a0b40be..9045cbc 100644 --- a/ui/utility_page_style.gd +++ b/ui/utility_page_style.gd @@ -26,12 +26,65 @@ const GREEN: Color = Color("31594d") const LIGHT_TEXT: Color = Color("f5eed9") const DISABLED_TEXT: Color = Color(0.33, 0.36, 0.35, 0.72) const MOTION_TWEEN_META: StringName = &"utility_page_motion_tween" +const LAPTOP_RECT: Rect2 = Rect2(66.0, 132.0, 1148.0, 520.0) static func apply_page(root: Control) -> void: root.add_theme_font_override("font", TuffyFont) +static func rounded_style(color: Color, radius: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.set_border_width_all(0) + style.set_corner_radius_all(radius) + return style + + +static func build_laptop_screen( + root: Control, + laptop_rect: Rect2 = LAPTOP_RECT, +) -> MarginContainer: + var laptop := PanelContainer.new() + laptop.position = laptop_rect.position + laptop.size = laptop_rect.size + laptop.add_theme_stylebox_override( + "panel", rounded_style(OCEAN_PANEL_MID, 24) + ) + root.add_child(laptop) + + var shell_margin := MarginContainer.new() + shell_margin.add_theme_constant_override("margin_left", 20) + shell_margin.add_theme_constant_override("margin_top", 18) + shell_margin.add_theme_constant_override("margin_right", 20) + shell_margin.add_theme_constant_override("margin_bottom", 24) + laptop.add_child(shell_margin) + + var screen := PanelContainer.new() + screen.add_theme_stylebox_override( + "panel", rounded_style(OCEAN_FIELD, 16) + ) + shell_margin.add_child(screen) + + var content_margin := MarginContainer.new() + content_margin.add_theme_constant_override("margin_left", 24) + content_margin.add_theme_constant_override("margin_top", 18) + content_margin.add_theme_constant_override("margin_right", 24) + content_margin.add_theme_constant_override("margin_bottom", 18) + screen.add_child(content_margin) + + var base := ColorRect.new() + base.position = Vector2( + laptop_rect.position.x + 82.0, + laptop_rect.end.y - 6.0, + ) + base.size = Vector2(laptop_rect.size.x - 164.0, 14.0) + base.color = OCEAN_PANEL_MID + base.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(base) + return content_margin + + static func panel_style() -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = OCEAN_PANEL_DEEP @@ -120,6 +173,22 @@ static func apply_ocean_button(button: BaseButton) -> void: button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, 40.0) +static func apply_compact_ocean_button(button: BaseButton) -> void: + apply_ocean_button(button) + for state: StringName in [ + &"normal", &"hover", &"pressed", &"focus", &"disabled", + ]: + var style := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat + if style == null: + continue + style.content_margin_left = 10.0 + style.content_margin_right = 10.0 + style.content_margin_top = 4.0 + style.content_margin_bottom = 4.0 + button.add_theme_stylebox_override(state, style) + button.custom_minimum_size.y = 34.0 + + static func apply_line_edit(edit: LineEdit) -> void: edit.add_theme_font_override("font", TuffyFont) edit.add_theme_color_override("font_color", LIGHT_TEXT) diff --git a/world/world_time_service.gd b/world/world_time_service.gd index 378fcd1..1285f3e 100644 --- a/world/world_time_service.gd +++ b/world/world_time_service.gd @@ -28,6 +28,7 @@ var _persistent_time_hours: float = DEFAULT_START_HOUR var _persistence_tracking_enabled: bool = false var _running: bool = false var _phase: Phase = Phase.DAWN +var _last_emitted_clock_minute: int = floori(DEFAULT_START_HOUR * 60.0) func _ready() -> void: @@ -140,14 +141,18 @@ func _set_time_hours(time_hours: float, force_emit: bool) -> void: var normalized: float = _normalized_hour(time_hours) var next_phase: Phase = phase_for_hour(normalized) var phase_was_changed: bool = next_phase != _phase - var time_was_changed: bool = not is_equal_approx(normalized, _time_hours) + var clock_minute: int = floori(normalized * 60.0) + var clock_minute_changed: bool = ( + clock_minute != _last_emitted_clock_minute + ) _time_hours = normalized _phase = next_phase if _persistence_tracking_enabled: _persistent_time_hours = normalized if phase_was_changed: phase_changed.emit(_phase) - if force_emit or time_was_changed or phase_was_changed: + if force_emit or clock_minute_changed or phase_was_changed: + _last_emitted_clock_minute = clock_minute time_changed.emit(_time_hours, _phase) diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index 611e60b..b7b1839 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -5,6 +5,10 @@ const UPDATE_INTERVAL_SECONDS: float = 0.1 const SUN_YAW_DEGREES: float = -32.0 const WEATHER_TRANSITION_SECONDS: float = 10.0 const RAIN_EMITTER_OFFSET := Vector3(0.0, 7.0, 0.0) +const RAIN_PARTICLE_AMOUNT: int = 560 +const RAIN_VELOCITY_MIN: float = 16.0 +const RAIN_VELOCITY_MAX: float = 20.0 +const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) const DAY_SKY_TOP := Color(0.204, 0.498, 0.643) const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847) @@ -145,7 +149,7 @@ func _prepare_runtime_environment() -> bool: func _prepare_rain() -> void: _rain = GPUParticles3D.new() _rain.name = "LocalRain" - _rain.amount = 480 + _rain.amount = RAIN_PARTICLE_AMOUNT _rain.amount_ratio = 0.0 _rain.lifetime = 1.25 _rain.fixed_fps = 30 @@ -160,12 +164,12 @@ func _prepare_rain() -> void: process_material.emission_box_extents = Vector3(6.5, 1.0, 6.5) process_material.direction = Vector3.DOWN process_material.spread = 5.0 - process_material.initial_velocity_min = 11.0 - process_material.initial_velocity_max = 15.0 + process_material.initial_velocity_min = RAIN_VELOCITY_MIN + process_material.initial_velocity_max = RAIN_VELOCITY_MAX process_material.gravity = Vector3(0.0, -2.0, 0.0) _rain.process_material = process_material var drop_mesh := BoxMesh.new() - drop_mesh.size = Vector3(0.018, 0.42, 0.018) + drop_mesh.size = RAIN_DROP_SIZE var drop_material := StandardMaterial3D.new() drop_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA drop_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED diff --git a/world/world_weather_service.gd b/world/world_weather_service.gd index d01728c..fd09d60 100644 --- a/world/world_weather_service.gd +++ b/world/world_weather_service.gd @@ -15,11 +15,21 @@ const SUNNY_DURATION_RANGE := Vector2(480.0, 900.0) const CLOUDY_DURATION_RANGE := Vector2(300.0, 720.0) const RAINY_DURATION_RANGE := Vector2(300.0, 600.0) const FOGGY_DURATION_RANGE := Vector2(300.0, 600.0) +const MAX_PERSISTED_SECONDS: float = 1800.0 +const DAILY_PLAN_SEGMENT_HOURS: float = 2.0 +const DAILY_PLAN_SEGMENT_COUNT: int = 12 var _weather: Weather = DEFAULT_WEATHER var _seconds_remaining: float = SUNNY_DURATION_RANGE.x +var _persistent_weather: Weather = DEFAULT_WEATHER +var _persistent_seconds_remaining: float = SUNNY_DURATION_RANGE.x +var _has_persistent_state: bool = false +var _persistence_tracking_enabled: bool = false var _running_authority: bool = false var _rng := RandomNumberGenerator.new() +var _daily_plan_id: String = "" +var _daily_schedule: Array[Dictionary] = [] +var _world_time: WorldTimeService func _ready() -> void: @@ -34,7 +44,21 @@ func begin_authoritative_session(seed_value: int) -> void: _rng.seed = seed_value _running_authority = true set_process(true) - _set_weather(DEFAULT_WEATHER, _roll_duration(DEFAULT_WEATHER), true) + if _daily_schedule.is_empty() or _world_time == null: + if _has_persistent_state: + _set_weather( + _persistent_weather, + _persistent_seconds_remaining, + true, + ) + else: + _set_weather( + DEFAULT_WEATHER, + _roll_duration(DEFAULT_WEATHER), + true, + ) + else: + _update_scheduled_weather(true) func begin_remote_session() -> void: @@ -52,12 +76,17 @@ func end_session() -> void: func advance_weather(real_seconds: float) -> void: if not _running_authority or real_seconds <= 0.0: return + if not _daily_schedule.is_empty() and _world_time != null: + _update_scheduled_weather(false) + return _seconds_remaining -= real_seconds while _seconds_remaining <= 0.0: var overrun: float = -_seconds_remaining var next_weather: Weather = _choose_next_weather() _set_weather(next_weather, _roll_duration(next_weather), true) _seconds_remaining -= overrun + if _persistence_tracking_enabled: + _store_persistent_state() func apply_authoritative_snapshot( @@ -69,6 +98,56 @@ func apply_authoritative_snapshot( _set_weather(weather, maxf(seconds_remaining, 0.0), false) +func set_persistence_tracking_enabled(enabled: bool) -> void: + if _persistence_tracking_enabled == enabled: + return + if _persistence_tracking_enabled: + _store_persistent_state() + _persistence_tracking_enabled = enabled + + +func restore_persistent_state( + weather: Weather, + seconds_remaining: float, +) -> bool: + if not is_valid_persistent_state(weather, seconds_remaining): + return false + _persistent_weather = weather + _persistent_seconds_remaining = seconds_remaining + _has_persistent_state = true + if _persistence_tracking_enabled: + _set_weather( + _persistent_weather, + _persistent_seconds_remaining, + true, + ) + return true + + +func reset_persistent_state() -> void: + _has_persistent_state = false + _persistent_weather = DEFAULT_WEATHER + _persistent_seconds_remaining = SUNNY_DURATION_RANGE.x + if _persistence_tracking_enabled and _running_authority: + _set_weather( + DEFAULT_WEATHER, + _roll_duration(DEFAULT_WEATHER), + true, + ) + + +func has_persistent_state() -> bool: + return _has_persistent_state + + +func get_persistent_weather() -> Weather: + return _persistent_weather + + +func get_persistent_seconds_remaining() -> float: + return _persistent_seconds_remaining + + func get_weather() -> Weather: return _weather @@ -89,10 +168,95 @@ func get_weather_name() -> String: return weather_name(_weather) +func configure_daily_plan( + plan_id: String, + schedule: Array, + world_time: WorldTimeService, +) -> bool: + if ( + plan_id.is_empty() + or not is_valid_daily_plan_schedule(schedule) + or world_time == null + ): + return false + _daily_plan_id = plan_id + _daily_schedule.clear() + for value: Variant in schedule: + _daily_schedule.append((value as Dictionary).duplicate(true)) + _world_time = world_time + if _running_authority: + _update_scheduled_weather(true) + return true + + +func clear_daily_plan() -> void: + _daily_plan_id = "" + _daily_schedule.clear() + _world_time = null + + +func get_daily_plan_id() -> String: + return _daily_plan_id + + static func is_valid_weather(value: int) -> bool: return value >= Weather.SUNNY and value <= Weather.FOGGY +static func is_valid_persistent_state( + weather: Weather, + seconds_remaining: float, +) -> bool: + return ( + is_valid_weather(int(weather)) + and is_finite(seconds_remaining) + and seconds_remaining >= 0.0 + and seconds_remaining <= MAX_PERSISTED_SECONDS + ) + + +static func is_valid_daily_plan_schedule(value: Variant) -> bool: + if typeof(value) != TYPE_ARRAY: + return false + var schedule: Array = value + if schedule.size() != DAILY_PLAN_SEGMENT_COUNT: + return false + for entry_value: Variant in schedule: + if typeof(entry_value) != TYPE_DICTIONARY: + return false + var entry: Dictionary = entry_value + if ( + typeof(entry.get("start_hour")) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(entry.get("start_hour", -1.0))) + or float(entry.get("start_hour", -1.0)) < 0.0 + or float(entry.get("start_hour", -1.0)) >= 24.0 + or not _is_bounded_integer( + entry.get("weather"), Weather.SUNNY, Weather.FOGGY + ) + or not is_valid_weather(int(entry.get("weather", -1))) + ): + return false + return true + + +static func _is_bounded_integer( + value: Variant, + minimum: int, + maximum: int, +) -> bool: + if typeof(value) == TYPE_INT: + return int(value) >= minimum and int(value) <= maximum + if typeof(value) != TYPE_FLOAT: + return false + var number: float = float(value) + return ( + is_finite(number) + and number >= float(minimum) + and number <= float(maximum) + and is_equal_approx(number, round(number)) + ) + + static func weather_name(weather: Weather) -> String: match weather: Weather.SUNNY: @@ -114,10 +278,18 @@ func _set_weather( var changed: bool = weather != _weather _weather = weather _seconds_remaining = maxf(seconds_remaining, 0.0) + if _persistence_tracking_enabled: + _store_persistent_state() if force_emit or changed: weather_changed.emit(_weather, _seconds_remaining) +func _store_persistent_state() -> void: + _persistent_weather = _weather + _persistent_seconds_remaining = _seconds_remaining + _has_persistent_state = true + + func _choose_next_weather() -> Weather: match _weather: Weather.SUNNY: @@ -150,3 +322,26 @@ func _duration_range(weather: Weather) -> Vector2: Weather.FOGGY: return FOGGY_DURATION_RANGE return SUNNY_DURATION_RANGE + + +func _update_scheduled_weather(force_emit: bool) -> void: + if _daily_schedule.is_empty() or _world_time == null: + return + var elapsed_hours: float = fposmod( + _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR, + WorldTimeService.HOURS_PER_DAY, + ) + var segment_hours: float = DAILY_PLAN_SEGMENT_HOURS + var segment_index: int = clampi( + floori(elapsed_hours / segment_hours), + 0, + _daily_schedule.size() - 1, + ) + var entry: Dictionary = _daily_schedule[segment_index] + var weather: Weather = int(entry.get("weather", DEFAULT_WEATHER)) as Weather + var next_boundary: float = float(segment_index + 1) * segment_hours + var hours_remaining: float = maxf(next_boundary - elapsed_hours, 0.0) + var seconds_remaining: float = ( + hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND + ) + _set_weather(weather, seconds_remaining, force_emit)