Add rare beach message bottles

This commit is contained in:
Alexander Sellite 2026-09-02 12:39:23 -04:00
parent 687568eb38
commit 2b5b0a63ec
38 changed files with 2422 additions and 18 deletions

View file

@ -0,0 +1,202 @@
extends SceneTree
const ServiceType = preload("res://messages/beach_bottle_service.gd")
const StateType = preload("res://messages/player_bottle_message_state.gd")
const Catalog: BottleMessageCatalog = preload(
"res://messages/catalog/bottle_message_catalog.tres"
)
const ItemCatalogResource: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
class FakeWorld:
extends Node
func is_world_ready() -> bool:
return true
func get_saltwater_surface_height() -> float:
return -0.45
func get_spawn_surface_triangles(
_materials: Array[StringName],
_minimum_y: float,
_minimum_up_dot: float,
_maximum_y: float,
) -> Array[PackedVector3Array]:
return [PackedVector3Array([
Vector3(8.0, 0.0, -4.0),
Vector3(16.0, 0.0, -4.0),
Vector3(12.0, 0.0, 4.0),
])]
class FakeSaveManager:
extends PlayerSaveManager
var allow_save: bool = true
var save_count: int = 0
func save_if_dirty() -> bool:
save_count += 1
return allow_save
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var root := Node.new()
get_root().add_child(root)
var world := FakeWorld.new()
var player := Node3D.new()
var presentation_root := Node3D.new()
var bag := PlayerBag.new()
var state := StateType.new()
var saves := FakeSaveManager.new()
var service := ServiceType.new()
root.add_child(world)
root.add_child(player)
root.add_child(presentation_root)
root.add_child(bag)
root.add_child(state)
root.add_child(saves)
root.add_child(service)
bag.setup(ItemCatalogResource)
var now_unix: int = int(Time.get_unix_time_from_system())
assert(state.restore_from_save_data({
"schema_version": 1,
"received_message_ids": [],
"pending_message_id": "",
"pending_bottle_picked_up": false,
"next_bottle_at_unix": now_unix + 300000,
"last_observed_unix": now_unix,
}))
service.setup(
world,
player,
bag,
state,
Catalog,
saves,
presentation_root,
false,
)
service.force_spawn_for_testing = true
service.set_gameplay_active(true)
assert(service.call("_try_spawn_due_bottle"))
assert(service.has_active_bottle())
assert(state.has_pending_message())
assert(not state.is_pending_bottle_picked_up())
var presentation := service.get("_presentation") as BeachBottlePresentation
var spawned_message: BottleMessageData = Catalog.get_message(
presentation.message_id
)
assert(spawned_message != null)
var bottle_sprite := presentation.get_node("%BottleSprite") as Sprite3D
assert(bottle_sprite.texture.resource_path.ends_with(
"message_in_a_bottle_sand.png"
))
assert(bottle_sprite.texture.get_size() == Vector2(64.0, 64.0))
assert(bottle_sprite.position.y > 0.0)
assert(bottle_sprite.offset == Vector2.ZERO)
assert(
bottle_sprite.billboard == BaseMaterial3D.BILLBOARD_FIXED_Y
)
var bottle_image: Image = bottle_sprite.texture.get_image()
if bottle_image.is_compressed():
assert(bottle_image.decompress() == OK)
var visible_bounds: Rect2i = bottle_image.get_used_rect()
assert(
visible_bounds.position.y + visible_bounds.size.y
== int(presentation.AUTHORED_VISIBLE_BOTTOM_PIXEL)
)
var texture_height: float = float(bottle_sprite.texture.get_height())
var visible_bottom_pixel: float = presentation.AUTHORED_VISIBLE_BOTTOM_PIXEL
var expected_sprite_height: float = (
visible_bottom_pixel
- texture_height * 0.5
- presentation.VISIBLE_BASE_TUCK_PIXELS
) * bottle_sprite.pixel_size
assert(
is_equal_approx(bottle_sprite.position.y, expected_sprite_height)
)
var first_position: Vector3 = service.get_active_bottle_position()
assert(first_position.is_finite())
assert(is_equal_approx(first_position.y, ServiceType.SURFACE_OFFSET))
var visible_base_y: float = (
first_position.y
+ bottle_sprite.position.y
+ (
texture_height * 0.5
- visible_bottom_pixel
) * bottle_sprite.pixel_size
)
assert(is_equal_approx(visible_base_y, -bottle_sprite.pixel_size))
assert(
Vector2(first_position.x, first_position.z).length()
>= ServiceType.MINIMUM_SPAWN_DISTANCE
)
assert(
Vector2(first_position.x, first_position.z).length()
<= ServiceType.MAXIMUM_SPAWN_DISTANCE
)
player.global_position = first_position
assert(service.is_local_player_in_range())
assert(service.get_prompt_anchor_position().is_finite())
service.call("_process", 1.0)
assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
assert(service.has_active_bottle())
saves.allow_save = false
assert(not service.try_collect_nearby())
assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
assert(state.has_pending_message())
assert(not state.is_pending_bottle_picked_up())
assert(service.has_active_bottle())
saves.allow_save = true
# A failed disk transaction remains blocked until the player leaves pickup
# range, preventing a save-conflict failure from being retried every frame.
assert(not service.try_collect_nearby())
player.global_position = first_position + Vector3.RIGHT * 2.0
service.call("_process", 0.1)
assert(not service.is_local_player_in_range())
player.global_position = first_position
assert(service.try_collect_nearby())
assert(bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
assert(state.is_pending_bottle_picked_up())
assert(not service.has_active_bottle())
saves.allow_save = false
var failed_read: Dictionary = service.read_held_bottle()
assert(not bool(failed_read.get("ok", false)))
assert(bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
assert(state.is_pending_bottle_picked_up())
saves.allow_save = true
var read_result: Dictionary = service.read_held_bottle()
assert(bool(read_result.get("ok", false)))
assert(
str(read_result.get("text", ""))
== spawned_message.body
)
assert(
str(read_result.get("message_id", ""))
== String(spawned_message.message_id)
)
assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
assert(not state.has_pending_message())
var point := ServiceType.sample_triangle(
PackedVector3Array([
Vector3.ZERO,
Vector3(1.0, 0.0, 0.0),
Vector3(0.0, 0.0, 1.0),
]),
0.0,
0.75,
)
assert(point.is_equal_approx(Vector3.ZERO))
assert(not ServiceType.sample_triangle(PackedVector3Array(), 0.5, 0.5).is_finite())
print("Beach bottle service validation: PASS")
root.queue_free()
await process_frame
quit()

View file

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

View file

@ -0,0 +1,187 @@
extends SceneTree
const BottleMessageCatalogType = preload(
"res://messages/bottle_message_catalog.gd"
)
const BottleMessageDataType = preload(
"res://messages/bottle_message_data.gd"
)
const PlayerBottleMessageStateType = preload(
"res://messages/player_bottle_message_state.gd"
)
const CATALOG_PATH: String = (
"res://messages/catalog/bottle_message_catalog.tres"
)
const BASE_TIME: int = 1788282000
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_shipped_catalog()
_validate_player_save_migration()
_validate_deterministic_unseen_selection()
_validate_cooldown_and_assignment()
_validate_save_round_trip_and_unknown_ids()
_validate_backward_clock_tolerance()
print("Bottle message state validation: PASS")
quit()
func _validate_shipped_catalog() -> void:
var catalog := load(CATALOG_PATH) as BottleMessageCatalogType
assert(catalog != null)
var message: BottleMessageDataType = catalog.get_message(
&"pushing_squares"
)
assert(message != null)
assert(message.active)
assert(message.body == "it's as simple as pushing squares")
var message_2: BottleMessageDataType = catalog.get_message(
&"message_2"
)
assert(message_2 != null)
assert(message_2.active)
assert(
message_2.body
== "01101001 00100000 01101100 01101111 01110110 01100101 00100000 01111001 01101111 01110101"
)
func _validate_player_save_migration() -> void:
var manager := PlayerSaveManager.new()
root.add_child(manager)
var migrated: Dictionary = manager.call(
"_migrate_save",
{"save_version": 11},
11,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(
migrated.get("message_bottles", {})
== PlayerBottleMessageStateType.default_save_data()
)
manager.queue_free()
func _validate_deterministic_unseen_selection() -> void:
var catalog := BottleMessageCatalogType.new()
for message_id: StringName in [&"third", &"first", &"second"]:
var message := BottleMessageDataType.new()
message.message_id = message_id
message.body = String(message_id)
catalog.entries.append(message)
var first_rng := RandomNumberGenerator.new()
first_rng.seed = 4412
var second_rng := RandomNumberGenerator.new()
second_rng.seed = 4412
var received: Array[StringName] = [&"second"]
assert(
catalog.choose_random_unseen_message(received, first_rng).message_id
== catalog.choose_random_unseen_message(received, second_rng).message_id
)
catalog.get_message(&"first").active = false
assert(
catalog.choose_random_unseen_message(received, first_rng).message_id
== &"third"
)
func _validate_cooldown_and_assignment() -> void:
var catalog := load(CATALOG_PATH) as BottleMessageCatalogType
var state := PlayerBottleMessageStateType.new()
root.add_child(state)
var rng := RandomNumberGenerator.new()
rng.seed = 915
assert(state.ensure_cooldown_scheduled(BASE_TIME, rng))
var due_at: int = state.get_next_bottle_at_unix()
assert(
due_at >= BASE_TIME + state.MINIMUM_COOLDOWN_SECONDS
and due_at <= BASE_TIME + state.MAXIMUM_COOLDOWN_SECONDS
)
assert(state.try_assign_due_message(catalog, rng, due_at - 1) == null)
var message: BottleMessageDataType = state.try_assign_due_message(
catalog, rng, due_at
)
assert(message != null)
assert(state.has_pending_message())
assert(not state.is_pending_bottle_picked_up())
assert(state.has_received_message(message.message_id))
assert(state.get_next_bottle_at_unix() == 0)
assert(not state.ensure_cooldown_scheduled(due_at, rng))
assert(state.try_assign_due_message(catalog, rng, due_at) == null)
assert(state.record_bottle_picked_up(due_at + 10, rng))
assert(state.is_pending_bottle_picked_up())
assert(
state.get_next_bottle_at_unix()
>= due_at + 10 + state.MINIMUM_COOLDOWN_SECONDS
)
assert(not state.record_bottle_picked_up(due_at + 20, rng))
assert(state.consume_pending_message() == message.message_id)
assert(not state.has_pending_message())
var second_due_at: int = state.get_next_bottle_at_unix()
var second_message: BottleMessageDataType = state.try_assign_due_message(
catalog,
rng,
second_due_at,
)
assert(second_message != null)
assert(second_message.message_id != message.message_id)
assert(state.record_bottle_picked_up(second_due_at + 10, rng))
assert(state.consume_pending_message() == second_message.message_id)
var exhausted_due_at: int = state.get_next_bottle_at_unix()
assert(
state.try_assign_due_message(
catalog,
rng,
exhausted_due_at,
) == null
)
state.queue_free()
func _validate_save_round_trip_and_unknown_ids() -> void:
var state := PlayerBottleMessageStateType.new()
root.add_child(state)
var data := {
"schema_version": state.SAVE_SCHEMA_VERSION,
"received_message_ids": ["retired_message"],
"pending_message_id": "pending_from_future_catalog",
"pending_bottle_picked_up": true,
"next_bottle_at_unix": BASE_TIME + 50,
"last_observed_unix": BASE_TIME,
}
assert(state.restore_from_save_data(data))
assert(state.has_received_message(&"retired_message"))
assert(state.has_received_message(&"pending_from_future_catalog"))
assert(state.get_pending_message_id() == &"pending_from_future_catalog")
assert(state.is_pending_bottle_picked_up())
var saved: Dictionary = state.to_save_data()
assert(
"retired_message" in (saved["received_message_ids"] as Array)
)
assert(
"pending_from_future_catalog"
in (saved["received_message_ids"] as Array)
)
state.queue_free()
func _validate_backward_clock_tolerance() -> void:
var state := PlayerBottleMessageStateType.new()
root.add_child(state)
var rng := RandomNumberGenerator.new()
rng.seed = 122
assert(state.ensure_cooldown_scheduled(BASE_TIME, rng))
var original_due_at: int = state.get_next_bottle_at_unix()
assert(not state.observe_wall_clock(BASE_TIME - 60))
assert(state.get_last_observed_unix() == BASE_TIME)
assert(state.get_next_bottle_at_unix() == original_due_at)
var rollback: int = state.BACKWARD_CLOCK_TOLERANCE_SECONDS + 1
assert(state.observe_wall_clock(BASE_TIME - rollback))
assert(state.get_last_observed_unix() == BASE_TIME - rollback)
assert(state.get_next_bottle_at_unix() == original_due_at - rollback)
state.queue_free()

View file

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

View file

@ -53,6 +53,12 @@ func _run() -> void:
"_decor_shop_interaction"
) as DecorShopInteraction
var game_ui := main.get("_game_ui") as GameUI
var bottle_service := main.get_node(
"%BeachBottleService"
) as BeachBottleService
var bottle_state := main.get_node(
"%PlayerBottleMessageState"
) as PlayerBottleMessageState
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
@ -60,13 +66,74 @@ func _run() -> void:
_expect(interaction != null, "shop interaction is unavailable")
_expect(decor_interaction != null, "decor interaction is unavailable")
_expect(game_ui != null, "game UI is unavailable")
_expect(bottle_service != null, "beach bottle service is unavailable")
_expect(bottle_state != null, "bottle message state is unavailable")
_expect(chat_service != null, "network chat service is unavailable")
if (
player != null
and interaction != null
and game_ui != null
and bottle_service != null
and bottle_state != null
and chat_service != null
):
# A nearby beach bottle owns the same compact interaction glyph as an
# unengaged villager. Controller Y must yield its character-call alias and
# collect only after the explicit interaction press.
var now_unix: int = int(Time.get_unix_time_from_system())
bottle_service.call("_clear_presentation", false)
_expect(bottle_state.restore_from_save_data({
"schema_version": 1,
"received_message_ids": ["pushing_squares"],
"pending_message_id": "pushing_squares",
"pending_bottle_picked_up": false,
"next_bottle_at_unix": 0,
"last_observed_unix": now_unix,
}), "bottle interaction fixture could not be restored")
var bottle_position := (
interaction.global_position + Vector3(12.0, 0.0, 12.0)
)
player.global_position = bottle_position
bottle_service.call(
"_spawn_presentation",
&"pushing_squares",
bottle_position,
)
main.call("_process", 0.0)
var bottle_prompt := game_ui.get_node("%ShopPrompt") as Control
_expect(
bottle_service.is_local_player_in_range()
and bottle_prompt.visible
and bottle_prompt.size == Vector2(30.0, 30.0),
"nearby bottle did not use the villager interaction glyph",
)
var bottle_calls: Array[String] = []
var capture_bottle_call := func(
_peer_id: int,
call_id: String,
_pitch_scale: float,
) -> void:
bottle_calls.append(call_id)
chat_service.character_call_received.connect(capture_bottle_call)
var bottle_press := InputEventJoypadButton.new()
bottle_press.device = 0
bottle_press.button_index = JOY_BUTTON_Y
bottle_press.pressed = true
game_ui.call("_input", bottle_press)
_expect(
bottle_calls.is_empty(),
"bottle interaction Y press played a character call",
)
main.call("_unhandled_input", bottle_press)
await process_frame
_expect(
player.bag.owns_item(BeachBottleService.BOTTLE_ITEM_ID)
and not bottle_service.has_active_bottle()
and main.get("_engaged_shop_npc_interaction") == null,
"explicit bottle interaction did not collect exactly the bottle",
)
chat_service.character_call_received.disconnect(capture_bottle_call)
player.global_position = interaction.global_position
for _frame: int in 6:
await physics_frame

View file

@ -104,7 +104,7 @@ func _run() -> void:
var hotbar_data: Dictionary = parsed["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int(parsed["save_version"]) == 11)
assert(int(parsed["save_version"]) == 12)
assert(not PlayerHomeState.sanitize_save_data(
parsed.get("home", {})
).is_empty())

View file

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

View file

@ -209,7 +209,7 @@ func _run() -> void:
)
assert(bool(decoded.get("ok", false)))
var save_data: Dictionary = decoded["data"]
assert(int(save_data.get("save_version", -1)) == 11)
assert(int(save_data.get("save_version", -1)) == 12)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
assert(not PlayerHomeState.sanitize_save_data(
save_data.get("home", {})

View file

@ -0,0 +1,204 @@
extends SceneTree
const ReaderScene: PackedScene = preload(
"res://ui/message_bottle_reader.tscn"
)
const PlayerMenuScene: PackedScene = preload("res://ui/player_menu.tscn")
const ItemCatalogResource: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
const BottleCatalog: BottleMessageCatalog = preload(
"res://messages/catalog/bottle_message_catalog.tres"
)
class FakeSaveManager:
extends PlayerSaveManager
func save_if_dirty() -> bool:
return true
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1280, 720)
var prior_button := Button.new()
prior_button.text = "inventory slot"
root.add_child(prior_button)
prior_button.grab_focus()
var reader := ReaderScene.instantiate() as MessageBottleReader
root.add_child(reader)
await process_frame
assert(not reader.is_open())
assert(reader.mouse_filter == Control.MOUSE_FILTER_STOP)
assert(reader.find_children("*", "ColorRect", true, false).is_empty())
assert(reader.find_child("Title", true, false) == null)
var parchment := reader.get_node("%ParchmentSurface") as Control
var parchment_art := reader.get_node("%ParchmentArt") as TextureRect
var fallback_panel := reader.get_node("%FallbackPanel") as Control
assert(parchment_art.texture != null)
assert(parchment_art.texture.resource_path.ends_with("parchment.png"))
assert(parchment_art.texture.get_size() == Vector2(256.0, 192.0))
assert(parchment_art.visible)
assert(not fallback_panel.visible)
assert(is_equal_approx(
parchment.custom_minimum_size.x / parchment.custom_minimum_size.y,
4.0 / 3.0,
))
var observed := {"closed_count": 0}
reader.closed.connect(
func() -> void: observed["closed_count"] += 1
)
reader.present("it's as simple as pushing squares")
await process_frame
assert(reader.is_open())
var message := reader.get_node("%MessageLabel") as Label
var close_button := reader.get_node("%CloseButton") as Button
assert(message.text == "it's as simple as pushing squares")
assert(root.gui_get_focus_owner() == close_button)
reader.present("a replacement message")
await process_frame
assert(message.text == "a replacement message")
assert(reader.consume_escape())
await process_frame
assert(not reader.is_open())
assert(int(observed["closed_count"]) == 1)
assert(root.gui_get_focus_owner() == prior_button)
assert(not reader.consume_escape())
root.size = Vector2i(480, 270)
reader.present("small viewport")
await process_frame
assert(parchment.size.x <= 448.0)
assert(parchment.size.y <= 238.0)
assert(absf(parchment.size.x / parchment.size.y - 4.0 / 3.0) < 0.01)
reader.queue_free()
prior_button.queue_free()
await process_frame
await _validate_player_menu_controller_open_and_close()
print("Message bottle reader validation: PASS")
quit(0)
func _validate_player_menu_controller_open_and_close() -> void:
root.size = Vector2i(1280, 720)
var host := Node.new()
root.add_child(host)
var menu := PlayerMenuScene.instantiate() as PlayerMenu
var bag := PlayerBag.new()
var catches := FishInventory.new()
var capacity := PlayerCoolerCapacity.new()
var layout := PlayerInventoryLayout.new()
var hotbar := PlayerHotbar.new()
var state := PlayerBottleMessageState.new()
var saves := FakeSaveManager.new()
var service := BeachBottleService.new()
for node: Node in [
menu,
bag,
catches,
capacity,
layout,
hotbar,
state,
saves,
service,
]:
host.add_child(node)
await process_frame
bag.setup(ItemCatalogResource)
var bottle_item: ItemData = ItemCatalogResource.get_item_by_id(&"message_bottle")
assert(bottle_item != null)
assert(bottle_item.icon != null)
assert(bottle_item.icon.resource_path.ends_with("message_in_a_bottle.png"))
assert(bottle_item.icon.get_size() == Vector2(64.0, 64.0))
layout.setup(bag, catches, ItemCatalogResource, capacity)
bag.set_inventory_layout(layout)
catches.set_inventory_layout(layout)
hotbar.setup(bag, ItemCatalogResource, catches, layout)
assert(bag.add_item(&"message_bottle"))
var now_unix: int = int(Time.get_unix_time_from_system())
assert(state.restore_from_save_data({
"schema_version": 1,
"received_message_ids": ["pushing_squares"],
"pending_message_id": "pushing_squares",
"pending_bottle_picked_up": true,
"next_bottle_at_unix": now_unix + 300000,
"last_observed_unix": now_unix,
}))
service.set("_bag", bag)
service.set("_state", state)
service.set("_catalog", BottleCatalog)
service.set("_save_manager", saves)
menu.set("_bag", bag)
menu.set("_inventory", catches)
menu.set("_hotbar", hotbar)
menu.set("_inventory_layout", layout)
menu.set("_item_catalog", ItemCatalogResource)
menu.setup_message_bottles(service)
var inventory_grid := menu.get("_general_inventory_grid") as GeneralInventoryGrid
inventory_grid.setup(
layout,
bag,
catches,
hotbar,
ItemCatalogResource,
PlayerInventoryLayout.InventoryContainer.INVENTORY,
)
menu.visible = true
menu.call("_show_section_immediate", PlayerMenu.Section.BAG)
menu.call("_set_shell_interactive", true)
menu.set(
"_controller_ownership",
PlayerMenu.ControllerOwnership.ITEM_LIST,
)
menu.call("_apply_inventory_controller_zone_focus_modes")
var bottle_slot: GeneralInventorySlot
for slot: GeneralInventorySlot in inventory_grid.get_slots():
if slot.entry_identity == &"message_bottle":
bottle_slot = slot
break
assert(bottle_slot != null)
bottle_slot.grab_focus()
var accept := InputEventJoypadButton.new()
accept.button_index = JOY_BUTTON_A
accept.pressed = true
assert(bool(menu.call("_handle_controller_ownership_input", accept)))
accept.pressed = false
assert(bool(menu.call("_handle_controller_ownership_input", accept)))
await process_frame
var menu_reader := menu.get_node("%MessageBottleReader") as MessageBottleReader
assert(menu_reader.is_open())
assert(
(menu_reader.get_node("%MessageLabel") as Label).text
== "it's as simple as pushing squares"
)
assert(not bag.owns_item(&"message_bottle"))
assert(not state.has_pending_message())
assert(
(menu.get_node("%InventoryTab") as Button).focus_mode
== Control.FOCUS_NONE
)
var cancel := InputEventJoypadButton.new()
cancel.button_index = JOY_BUTTON_B
cancel.pressed = true
menu.call("_input", cancel)
await process_frame
assert(not menu_reader.is_open())
assert(
(menu.get_node("%InventoryTab") as Button).focus_mode
== Control.FOCUS_ALL
)
host.queue_free()
await process_frame

View file

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

View file

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

View file

@ -72,7 +72,7 @@ func _run() -> void:
_assert_opaque(save_path)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 11)
assert(int((decoded["data"] as Dictionary)["save_version"]) == 12)
var saved_character: Dictionary = (
(decoded["data"] as Dictionary).get("character", {})
)
@ -89,6 +89,39 @@ func _run() -> void:
(saved_character["appearance"] as Dictionary)
== first_appearance
)
# Cross-model recovery must not merely repair runtime state. Mark the loaded
# save dirty so the next save removes the orphan permanently on disk.
var orphaned_bottle_save: Dictionary = (
(decoded["data"] as Dictionary).duplicate(true)
)
var orphaned_bag: Dictionary = (
orphaned_bottle_save["bag"] as Dictionary
)
(orphaned_bag["items"] as Array).append({
"item_id": "message_bottle",
"quantity": 1,
"storage_slot": -1,
})
var orphan_write: Dictionary = save_manager.call(
"_write_current_save_data",
orphaned_bottle_save,
save_manager.get("_expected_hash"),
)
assert(bool(orphan_write.get("ok", false)))
assert(save_manager.load_player_data())
assert(not player.bag.owns_item(&"message_bottle"))
assert(save_manager.is_dirty())
assert(save_manager.save_now())
var repaired_bottle_save: Dictionary = (
ProgressionSaveCodec.read_local_save(save_path)["data"]
)
for value: Variant in (
repaired_bottle_save["bag"]["items"] as Array
):
assert(
StringName(str((value as Dictionary).get("item_id", "")))
!= &"message_bottle"
)
# Starting another save must feel like a new character, then loading this
# archive must restore only this slot's name, voice, and appearance.
assert(save_manager.initialize_new_game(97531))