Add rare beach message bottles
This commit is contained in:
parent
687568eb38
commit
2b5b0a63ec
38 changed files with 2422 additions and 18 deletions
202
tests/beach_bottle_service_validation.gd
Normal file
202
tests/beach_bottle_service_validation.gd
Normal 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()
|
||||
1
tests/beach_bottle_service_validation.gd.uid
Normal file
1
tests/beach_bottle_service_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dadlfrooej5k0
|
||||
187
tests/bottle_message_state_validation.gd
Normal file
187
tests/bottle_message_state_validation.gd
Normal 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()
|
||||
1
tests/bottle_message_state_validation.gd.uid
Normal file
1
tests/bottle_message_state_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cjkesykq1lrfg
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
204
tests/message_bottle_reader_validation.gd
Normal file
204
tests/message_bottle_reader_validation.gd
Normal 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
|
||||
1
tests/message_bottle_reader_validation.gd.uid
Normal file
1
tests/message_bottle_reader_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://otelx0xjr05u
|
||||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue