Add catch experience and persistent world time

This commit is contained in:
Alexander Sellite 2026-08-02 19:01:16 -04:00
parent 35d00e8035
commit 291cc4a713
23 changed files with 1027 additions and 27 deletions

View file

@ -30,6 +30,9 @@ func _run() -> void:
await process_frame
var player := main.get("_player") as Player
var world_time := main.get_node("%WorldTimeService") as WorldTimeService
world_time.synchronize_time(19.75)
assert(player.experience.award_experience(125))
var fish_catalog := main.get("fish_catalog") as FishPool
var service := main.get_node(
"%NetworkFishShowcaseService"
@ -100,7 +103,17 @@ 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"]) == 5)
assert(int((parsed as Dictionary)["save_version"]) == 6)
assert(
int((parsed as Dictionary)["experience"]["total_experience"])
== 125
)
assert(
is_equal_approx(
float((parsed as Dictionary)["world"]["time_hours"]),
19.75,
)
)
var saved_catches: Array = (parsed as Dictionary)["inventory"]["catches"]
assert(int((saved_catches[0] as Dictionary)["quality"]) == fish_catch.quality)
var saved_masks: Dictionary = (
@ -112,7 +125,12 @@ func _run() -> void:
)
assert(player.hotbar.clear_slot(1))
assert(player.experience.restore_total_experience(0))
world_time.synchronize_time(8.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(player.experience.get_level() == 2)
assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id)
assert(player.hotbar.get_selected_slot() == 1)
assert(

View file

@ -242,7 +242,14 @@ func _validate_version_four_migration() -> void:
version_four,
4,
)
assert(int(migrated.get("save_version", -1)) == 5)
assert(int(migrated.get("save_version", -1)) == 6)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert(
is_equal_approx(
float((migrated["world"] as Dictionary)["time_hours"]),
8.0,
)
)
var catches: Array = migrated["inventory"]["catches"]
assert(int((catches[0] as Dictionary)["quality"]) == 0)
var collection: Dictionary = migrated["collection"]

View file

@ -239,18 +239,29 @@ func _validate_bite_wait_distribution() -> void:
var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType
root.add_child(fishing_spot)
await process_frame
var bite_rng := fishing_spot.get("_bite_rng") as RandomNumberGenerator
bite_rng.seed = 84219
var quick_count: int = 0
var typical_or_long_count: int = 0
var very_long_count: int = 0
var total_wait_seconds: float = 0.0
for _sample_index: int in 10000:
var wait_seconds: float = fishing_spot.roll_bite_wait_time()
assert(wait_seconds >= 10.0)
assert(wait_seconds <= 240.0)
total_wait_seconds += wait_seconds
if wait_seconds < 30.0:
quick_count += 1
else:
typical_or_long_count += 1
assert(quick_count > 0)
if wait_seconds >= 180.0:
very_long_count += 1
var average_wait_seconds: float = total_wait_seconds / 10000.0
assert(quick_count >= 2300 and quick_count <= 2700)
assert(typical_or_long_count > quick_count)
assert(very_long_count >= 100 and very_long_count <= 300)
assert(average_wait_seconds >= 57.0)
assert(average_wait_seconds <= 61.0)
fishing_spot.queue_free()
await process_frame

View file

@ -0,0 +1,47 @@
extends SceneTree
const GameUIScene: PackedScene = preload("res://ui/game_ui.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var game_ui := GameUIScene.instantiate() as GameUI
root.add_child(game_ui)
await process_frame
game_ui.set("_gameplay_ui_enabled", true)
game_ui.call(
"_on_experience_awarded",
50,
0,
50,
1,
1,
)
var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer
var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer
var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label
var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label
assert(panel != null and bubble != null)
assert(not panel.visible and not bubble.visible)
game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true)
await process_frame
assert(not panel.visible and not bubble.visible)
game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false)
await process_frame
await process_frame
assert(panel.visible and bubble.visible)
assert(award_label.text == "+50 xp")
assert(bubble_label.text == "+50 xp!")
await create_timer(1.6).timeout
var progress := game_ui.get_node("%ExperienceProgress") as ProgressBar
var level_label := game_ui.get_node("%ExperienceLevelLabel") as Label
assert(is_equal_approx(progress.value, 50.0))
assert(level_label.text == "level 1")
await create_timer(1.2).timeout
assert(not panel.visible and not bubble.visible)
game_ui.queue_free()
print("Player experience UI validation: PASS")
quit()

View file

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

View file

@ -0,0 +1,178 @@
extends SceneTree
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishExperienceType = preload("res://fish/fish_experience.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const PlayerExperienceType = preload(
"res://progression/player_experience.gd"
)
const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_level_curve()
_validate_catch_awards()
_validate_player_experience_state()
_validate_save_migration()
print("Player experience validation: PASS")
quit()
func _validate_level_curve() -> void:
assert(PlayerExperienceType.experience_required_for_next_level(1) == 100)
assert(PlayerExperienceType.experience_required_for_next_level(2) == 130)
assert(PlayerExperienceType.experience_required_for_next_level(3) == 170)
assert(PlayerExperienceType.total_experience_for_level(1) == 0)
assert(PlayerExperienceType.total_experience_for_level(2) == 100)
assert(PlayerExperienceType.total_experience_for_level(3) == 230)
assert(PlayerExperienceType.total_experience_for_level(4) == 400)
assert(PlayerExperienceType.level_for_total_experience(99) == 1)
assert(PlayerExperienceType.level_for_total_experience(100) == 2)
assert(PlayerExperienceType.level_for_total_experience(399) == 3)
assert(PlayerExperienceType.level_for_total_experience(400) == 4)
assert(
is_equal_approx(
PlayerExperienceType.progress_for_total_experience(50),
0.5,
)
)
func _validate_catch_awards() -> void:
var minimum_boring: FishCatch = _make_catch(
FishQualityType.Tier.BORING,
false,
)
assert(
FishExperienceType.calculate_catch_experience(
minimum_boring,
false,
0,
) == 50
)
var maximum_shiny: FishCatch = _make_catch(
FishQualityType.Tier.SHINY,
true,
)
var boring_mask: int = FishQualityType.bit_for(
FishQualityType.Tier.BORING
)
assert(
FishExperienceType.calculate_catch_experience(
maximum_shiny,
true,
boring_mask,
) == 46
)
var almost_mastered: int = (
FishQualityType.ALL_TIERS_MASK
& ~FishQualityType.bit_for(FishQualityType.Tier.SHINY)
)
assert(
FishExperienceType.calculate_catch_experience(
maximum_shiny,
true,
almost_mastered,
) == 146
)
assert(
FishExperienceType.calculate_catch_experience(
maximum_shiny,
true,
FishQualityType.ALL_TIERS_MASK,
) == 31
)
func _validate_player_experience_state() -> void:
var experience := PlayerExperienceType.new()
root.add_child(experience)
var awards: Array[Array] = []
experience.experience_awarded.connect(
func(
amount: int,
previous_total: int,
new_total: int,
previous_level: int,
new_level: int,
) -> void:
awards.append([
amount,
previous_total,
new_total,
previous_level,
new_level,
])
)
assert(experience.get_level() == 1)
assert(experience.award_experience(125))
assert(experience.get_total_experience() == 125)
assert(experience.get_level() == 2)
assert(experience.get_experience_in_level() == 25)
assert(awards.size() == 1)
assert(awards[0] == [125, 0, 125, 1, 2])
assert(experience.restore_total_experience(400))
assert(experience.get_level() == 4)
assert(not experience.restore_total_experience(-1))
experience.queue_free()
func _validate_save_migration() -> void:
var manager := PlayerSaveManager.new()
root.add_child(manager)
var version_five: Dictionary = {
"save_version": 5,
"wallet": {"balance": 0},
"collection": {
"discovered_fish_ids": [],
"discovered_quality_masks": {},
},
"inventory": {"next_catch_sequence": 1, "catches": []},
"bag": {"items": []},
"hotbar": {"selected_slot": 0, "slots": []},
"upgrades": {"reel_speed_level": 0, "barrier_power_level": 0},
"cooler": {"capacity_level": 0},
"art": {"unlock_mask": 0},
}
var migrated: Dictionary = manager.call(
"_migrate_save",
version_five,
5,
)
assert(int(migrated.get("save_version", -1)) == 6)
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))
manager.queue_free()
func _make_catch(quality: int, maximum_weight: bool) -> FishCatch:
var fish: FishData = Catalog.get_fish_by_id(&"bluegill")
assert(fish != null)
var fish_catch := FishCatchType.new()
fish_catch.fish = fish
fish_catch.fish_id = fish.id
fish_catch.catch_id = StringName("bluegill:xp-%d-%s" % [
quality,
"max" if maximum_weight else "min",
])
fish_catch.catch_sequence = 1
fish_catch.weight_lb = (
fish.get_maximum_weight()
if maximum_weight
else fish.get_minimum_weight()
)
fish_catch.display_scale = fish.get_display_scale_for_weight(
fish_catch.weight_lb
)
fish_catch.quality = quality
fish_catch.sale_value = FishQualityType.apply_sale_value(
fish.get_sale_value_for_weight(fish_catch.weight_lb),
quality,
)
return fish_catch

View file

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

View file

@ -32,6 +32,9 @@ func _run_host() -> void:
)
assert(session.start_private_host(TEST_PORT))
world_time.synchronize_time(INITIAL_HOST_TIME)
assert(is_equal_approx(
world_time.get_persistent_time_hours(), INITIAL_HOST_TIME
))
world_weather.apply_authoritative_snapshot(
WorldWeatherService.Weather.RAINY, 300.0
)
@ -56,6 +59,9 @@ func _run_host() -> void:
await create_timer(1.0).timeout
world_time.synchronize_time(UPDATED_HOST_TIME)
assert(is_equal_approx(
world_time.get_persistent_time_hours(), UPDATED_HOST_TIME
))
world_weather.apply_authoritative_snapshot(
WorldWeatherService.Weather.FOGGY, 300.0
)
@ -98,6 +104,7 @@ func _run_client() -> void:
assert(session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
))
assert(world_time.restore_persistent_time_hours(15.25))
var initial_deadline: int = Time.get_ticks_msec() + 8000
while (
@ -145,6 +152,7 @@ func _run_client() -> void:
world_time.get_time_hours(), UPDATED_HOST_TIME
) <= TIME_TOLERANCE_HOURS)
assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT)
assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25))
assert(clock_label.text == world_time.get_clock_text())
var fog_deadline: int = Time.get_ticks_msec() + 8000
while (

View file

@ -25,6 +25,7 @@ func _initialize() -> void:
func _run() -> void:
_validate_clock_boundaries_and_duration()
_validate_persistent_host_clock()
_validate_fishing_availability()
_validate_fishing_spot_context()
_validate_network_snapshot_bounds()
@ -78,6 +79,24 @@ func _validate_clock_boundaries_and_duration() -> void:
clock.queue_free()
func _validate_persistent_host_clock() -> void:
var clock := WorldTimeServiceType.new()
root.add_child(clock)
assert(clock.restore_persistent_time_hours(18.75))
clock.set_persistence_tracking_enabled(true)
clock.begin_session(clock.get_persistent_time_hours())
assert(is_equal_approx(clock.get_time_hours(), 18.75))
clock.synchronize_time(19.25)
assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25))
clock.set_persistence_tracking_enabled(false)
clock.synchronize_time(6.5)
assert(is_equal_approx(clock.get_time_hours(), 6.5))
assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25))
assert(not clock.restore_persistent_time_hours(-1.0))
assert(not clock.restore_persistent_time_hours(24.0))
clock.queue_free()
func _validate_fishing_availability() -> void:
var day_context := FishingContextType.new()
day_context.location_tags = [&"starter_pond"]