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 _daily_clock_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 _daily_clock_hour = _world_time.get_time_hours() _world_time.natural_time_advanced.connect(_on_natural_time_advanced) _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 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 has_active_board() -> bool: return validate_board(_active_board) 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( _daily_clock_hour - 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: var board := _host_board.duplicate(true) if not board.is_empty(): board["daily_clock_hour"] = _daily_clock_hour return board 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) if board.has("daily_clock_hour"): _daily_clock_hour = float(board["daily_clock_hour"]) 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, "daily_clock_hour": _daily_clock_hour, "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) _daily_clock_hour = float(data.get( "daily_clock_hour", _world_time.get_time_hours() if _world_time != null else WorldTimeService.DEFAULT_START_HOUR, )) _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 _daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR changed.emit() static func default_save_data() -> Dictionary: return { "format_version": FORMAT_VERSION, "host_board": {}, "active_plan_id": "", "daily_progress": {}, "daily_completions": {}, "daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR, "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 if ( data.has("daily_clock_hour") and ( typeof(data["daily_clock_hour"]) not in [TYPE_FLOAT, TYPE_INT] or not is_finite(float(data["daily_clock_hour"])) or float(data["daily_clock_hour"]) < 0.0 or float(data["daily_clock_hour"]) >= WorldTimeService.HOURS_PER_DAY ) ): 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_natural_time_advanced(hours: float) -> void: if not is_finite(hours) or hours <= 0.0: return var previous_hour := _daily_clock_hour _daily_clock_hour = fposmod( _daily_clock_hour + hours, WorldTimeService.HOURS_PER_DAY, ) if ( _progression_ready and _session != null and _session.is_host() and _crossed_daily_refresh(previous_hour, _daily_clock_hour) ): var cycle: int = int(_host_board.get("cycle", -1)) + 1 _generate_host_board(maxi(cycle, 0)) 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 xp" % 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, ) )