Expand character customization assets

This commit is contained in:
Alexander Sellite 2026-09-02 17:20:50 -04:00
parent 23cf53fa14
commit df2a4d7d77
31 changed files with 2423 additions and 215 deletions

View file

@ -59,7 +59,7 @@ func _validate_player_save_migration() -> void:
{"save_version": 11},
11,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(int(migrated.get("save_version", -1)) == 13)
assert(
migrated.get("message_bottles", {})
== PlayerBottleMessageStateType.default_save_data()

View file

@ -0,0 +1,278 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_catalog_and_legacy_names()
_validate_version_12_migration()
_validate_appearance_profile_migration_and_strictness()
_validate_signatures_cover_independent_accessories()
await _validate_presenter_selection()
print("Character customization expansion validation: PASS")
quit()
func _validate_catalog_and_legacy_names() -> void:
var defaults := CharacterCustomizationCatalog.default_snapshot()
assert(CharacterCustomizationCatalog.validate_snapshot(defaults))
assert(defaults.size() == CharacterCustomizationCatalog.SNAPSHOT_IDS.size())
assert(CharacterCustomizationCatalog.SNAPSHOT_IDS.size() == 23)
assert(CharacterCustomizationCatalog.CATEGORY_IDS.count("special") == 1)
assert("special" not in CharacterCustomizationCatalog.SNAPSHOT_IDS)
assert(CharacterCustomizationCatalog.SPECIAL_SLOT_IDS == PackedStringArray([
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]))
var expected_counts := {
"species": 21,
"arms": 3,
"ears": 21,
"tail": 17,
"special_back": 1,
"special_beak": 2,
"special_head": 1,
"special_horns": 2,
"special_tusks": 2,
"special_wings": 3,
}
var all_mesh_names: Array[String] = []
for category_id: String in expected_counts:
var options := CharacterCustomizationCatalog.options_for(category_id)
var option_ids: Array[String] = []
var mesh_names: Array[String] = []
for option: Dictionary in options:
var option_id := str(option.get("id", ""))
assert(not option_id.is_empty())
assert(option_id not in option_ids)
option_ids.append(option_id)
var mesh_name := CharacterCustomizationCatalog.model_mesh_name(
category_id,
option_id,
)
if option_id == "none":
assert(mesh_name.is_empty())
continue
assert(not mesh_name.is_empty())
assert(mesh_name not in mesh_names)
assert(mesh_name not in all_mesh_names)
mesh_names.append(mesh_name)
all_mesh_names.append(mesh_name)
assert(mesh_names.size() == int(expected_counts[category_id]))
assert(
CharacterCustomizationCatalog.model_mesh_names(category_id).size()
== int(expected_counts[category_id])
)
assert(
CharacterCustomizationCatalog.model_mesh_names("special").size()
== 11
)
assert(
CharacterCustomizationCatalog.model_mesh_name("arms", "body_arms")
== "arms_mammal"
)
assert(
CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_long")
== "ears_long"
)
assert(
CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_short")
== "ears_cat"
)
assert(
CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_wide")
== "ears_fox"
)
var legacy := defaults.duplicate(true)
legacy.erase("arms")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
legacy.erase(special_slot_id)
legacy["ears"] = "pointy_wide"
var sanitized := CharacterCustomizationCatalog.sanitized_snapshot(legacy)
assert(CharacterCustomizationCatalog.validate_snapshot(sanitized))
assert(sanitized["arms"] == "mammal")
assert(sanitized["ears"] == "fox")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
assert(sanitized[special_slot_id] == "none")
func _validate_version_12_migration() -> void:
var legacy_appearance := CharacterCustomizationCatalog.default_snapshot()
legacy_appearance.erase("arms")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
legacy_appearance.erase(special_slot_id)
legacy_appearance["ears"] = "pointy_short"
var manager := PlayerSaveManager.new()
root.add_child(manager)
var migrated: Dictionary = manager.call(
"_migrate_save",
{
"save_version": 12,
"character": {
"appearance": legacy_appearance,
},
},
12,
)
assert(int(migrated.get("save_version", -1)) == 13)
var appearance: Dictionary = migrated["character"]["appearance"]
assert(CharacterCustomizationCatalog.validate_snapshot(appearance))
assert(appearance["arms"] == "mammal")
assert(appearance["ears"] == "cat")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
assert(appearance[special_slot_id] == "none")
manager.queue_free()
func _validate_appearance_profile_migration_and_strictness() -> void:
var legacy := CharacterCustomizationCatalog.default_snapshot()
legacy.erase("arms")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
legacy.erase(special_slot_id)
legacy["ears"] = "pointy_wide"
var legacy_path := "user://appearance-v1-%d.json" % Time.get_ticks_usec()
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
assert(legacy_file != null)
legacy_file.store_string(JSON.stringify({
"format_version": 1,
"appearance": legacy,
}))
legacy_file.close()
var legacy_store := PlayerAppearanceStore.new()
root.add_child(legacy_store)
legacy_store.configure_storage(legacy_path, null)
assert(legacy_store.load_preferences())
var migrated := legacy_store.get_snapshot()
assert(CharacterCustomizationCatalog.validate_snapshot(migrated))
assert(migrated["arms"] == "mammal")
assert(migrated["ears"] == "fox")
legacy_store.queue_free()
DirAccess.remove_absolute(ProjectSettings.globalize_path(legacy_path))
var malformed := CharacterCustomizationCatalog.default_snapshot()
malformed["species"] = "definitely_not_a_head"
var current_path := "user://appearance-v2-%d.json" % Time.get_ticks_usec()
var current_file := FileAccess.open(current_path, FileAccess.WRITE)
assert(current_file != null)
current_file.store_string(JSON.stringify({
"format_version": PlayerAppearanceStore.FORMAT_VERSION,
"appearance": malformed,
}))
current_file.close()
var current_store := PlayerAppearanceStore.new()
root.add_child(current_store)
current_store.configure_storage(current_path, null)
assert(not current_store.load_preferences())
assert(not current_store.restore_from_save_data(malformed, false))
current_store.queue_free()
DirAccess.remove_absolute(ProjectSettings.globalize_path(current_path))
func _validate_signatures_cover_independent_accessories() -> void:
var baseline := CharacterCustomizationCatalog.default_snapshot()
var previous_signature := NetworkProfileProtocol.signature_fields({
"appearance": baseline,
})
var selections := {
"special_back": "shell",
"special_beak": "bird",
"special_head": "fin",
"special_horns": "goat",
"special_tusks": "boar",
"special_wings": "bat",
}
for category_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
var changed := baseline.duplicate(true)
changed[category_id] = selections[category_id]
assert(CharacterCustomizationCatalog.validate_snapshot(changed))
var changed_signature := NetworkProfileProtocol.signature_fields({
"appearance": changed,
})
assert(changed_signature != previous_signature)
previous_signature = changed_signature
func _validate_presenter_selection() -> void:
var visuals := PlayerVisualPresenter.instantiate_visuals()
root.add_child(visuals)
var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
assert(skeleton != null)
var snapshot := CharacterCustomizationCatalog.default_snapshot()
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
_assert_exact_selection(skeleton, "species", "head_round")
_assert_exact_selection(skeleton, "arms", "arms_mammal")
_assert_exact_selection(skeleton, "ears", "")
_assert_exact_selection(skeleton, "tail", "")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
_assert_exact_selection(skeleton, special_slot_id, "")
for species_option: Dictionary in CharacterCustomizationCatalog.options_for(
"species"
):
var species_id := str(species_option.get("id", ""))
snapshot["species"] = species_id
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
var selected_head := CharacterCustomizationCatalog.model_mesh_name(
"species",
species_id,
)
_assert_exact_selection(skeleton, "species", selected_head)
for feature_name: String in ["eyes", "nose", "mouth"]:
var feature := skeleton.get_node_or_null(
"%s_%s" % [selected_head, feature_name]
) as MeshInstance3D
assert(feature != null and feature.visible)
var feature_material := feature.material_override as StandardMaterial3D
assert(feature_material != null)
assert(feature_material.albedo_texture != null)
snapshot["arms"] = "avian"
snapshot["ears"] = "owl"
snapshot["tail"] = "donkey"
snapshot["special_back"] = "shell"
snapshot["special_beak"] = "bird"
snapshot["special_head"] = "fin"
snapshot["special_horns"] = "goat"
snapshot["special_tusks"] = "elephant"
snapshot["special_wings"] = "moth"
assert(CharacterCustomizationCatalog.validate_snapshot(snapshot))
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
_assert_exact_selection(skeleton, "arms", "arms_avian")
_assert_exact_selection(skeleton, "ears", "ears_owl")
_assert_exact_selection(skeleton, "tail", "tail_donkey")
_assert_exact_selection(skeleton, "special_back", "back_shell")
_assert_exact_selection(skeleton, "special_beak", "beak_bird")
_assert_exact_selection(skeleton, "special_head", "head_fin")
_assert_exact_selection(skeleton, "special_horns", "horns_goat")
_assert_exact_selection(skeleton, "special_tusks", "tusks_elephant")
_assert_exact_selection(skeleton, "special_wings", "wings_moth")
var wings := skeleton.get_node("wings_moth") as MeshInstance3D
var cached_material := wings.material_override
assert(cached_material != null)
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
assert(wings.material_override == cached_material)
visuals.queue_free()
await process_frame
func _assert_exact_selection(
skeleton: Skeleton3D,
category_id: String,
expected_mesh_name: String,
) -> void:
for mesh_name: String in CharacterCustomizationCatalog.model_mesh_names(
category_id
):
var mesh := skeleton.get_node_or_null(mesh_name) as Node3D
assert(mesh != null)
assert(mesh.visible == (mesh_name == expected_mesh_name))

View file

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

View file

@ -0,0 +1,211 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var page := ProfilePage.new()
page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(page)
await process_frame
await process_frame
page.set(
"_draft_appearance",
CharacterCustomizationCatalog.default_snapshot(),
)
var preview := page.get("_preview") as ProfilePreview
var default_appearance := (
CharacterCustomizationCatalog.default_snapshot()
)
preview.apply_appearance_profile(default_appearance)
var visible_bounds_result: Dictionary = preview.call(
"_visible_mesh_bounds"
)
assert(bool(visible_bounds_result.get("has_bounds", false)))
var visible_bounds: AABB = visible_bounds_result.get("bounds", AABB())
assert(
(preview.get("_camera_target") as Vector3).is_equal_approx(
visible_bounds.get_center()
)
)
var stable_fit := float(preview.get("_camera_fit_distance"))
preview.apply_appearance_profile(default_appearance)
assert(is_equal_approx(
float(preview.get("_camera_fit_distance")), stable_fit
))
var shell := page.find_child("UtilityMainBox", true, false) as Control
assert(shell != null)
var shell_position := shell.position
var shell_size := shell.size
for category_id: String in ["species", "arms", "ears", "tail"]:
page.call("_select_category", category_id)
await process_frame
await process_frame
var scroll := page.find_child(
"ModelOptionScroll", true, false
) as ScrollContainer
var grid := page.find_child(
"ModelOptionGrid", true, false
) as GridContainer
assert(scroll != null)
assert(grid != null)
assert(grid.columns == ProfilePage.MODEL_OPTION_GRID_COLUMNS)
assert(
grid.get_child_count()
== CharacterCustomizationCatalog.options_for(category_id).size()
)
assert(
scroll.horizontal_scroll_mode
== ScrollContainer.SCROLL_MODE_DISABLED
)
assert(
scroll.vertical_scroll_mode
== ScrollContainer.SCROLL_MODE_AUTO
)
assert(shell.position.is_equal_approx(shell_position))
assert(shell.size.is_equal_approx(shell_size))
page.call("_select_category", "species")
await process_frame
await process_frame
var species_scroll := page.find_child(
"ModelOptionScroll", true, false
) as ScrollContainer
assert(species_scroll != null)
var species_scroll_max := int(
maxf(
species_scroll.get_v_scroll_bar().max_value
- species_scroll.get_v_scroll_bar().page,
0.0,
)
)
assert(species_scroll_max > 0)
var saved_position := mini(species_scroll_max, 36)
species_scroll.scroll_vertical = saved_position
page.call("_select_category", "arms")
await process_frame
page.call("_select_category", "species")
await process_frame
await process_frame
species_scroll = page.find_child(
"ModelOptionScroll", true, false
) as ScrollContainer
assert(species_scroll != null)
assert(species_scroll.scroll_vertical == saved_position)
page.call("activate")
page.call("set_interactive", true)
var category_scroll := page.find_child(
"CategoryScroll", true, false
) as ScrollContainer
assert(category_scroll != null and category_scroll.follow_focus)
page.set("_controller_zone", ProfilePage.ControllerZone.CATEGORIES)
page.call("_apply_controller_zone_focus")
var category_list := page.get("_category_list") as VBoxContainer
var last_category_button := category_list.get_child(
category_list.get_child_count() - 1
) as Button
assert(last_category_button != null)
last_category_button.grab_focus()
await process_frame
assert(category_scroll.scroll_vertical > 0)
page.set("_controller_zone", ProfilePage.ControllerZone.OPTIONS)
page.call("_apply_controller_zone_focus")
var species_grid := page.find_child(
"ModelOptionGrid", true, false
) as GridContainer
var last_species_button := species_grid.get_child(
species_grid.get_child_count() - 1
) as Button
assert(last_species_button != null)
last_species_button.grab_focus()
await process_frame
assert(species_scroll.scroll_vertical > saved_position)
page.call("_select_category", "special")
await process_frame
await process_frame
var special_scroll := page.find_child(
"SpecialOptionScroll", true, false
) as ScrollContainer
assert(special_scroll != null)
for field_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
var slot_name := "SpecialSlot_%s" % field_id.trim_prefix("special_")
var slot := page.find_child(slot_name, true, false) as HBoxContainer
assert(slot != null)
assert(slot.get_child_count() == 2)
var slot_label := slot.get_child(0) as Label
var choices := slot.get_child(1) as HBoxContainer
assert(slot_label != null and choices != null)
assert(
slot_label.text
== CharacterCustomizationCatalog.special_slot_label(field_id)
)
assert(
choices.get_child_count()
== CharacterCustomizationCatalog.options_for(field_id).size()
)
assert(shell.position.is_equal_approx(shell_position))
assert(shell.size.is_equal_approx(shell_size))
var back_slot := page.find_child(
"SpecialSlot_back", true, false
) as HBoxContainer
var back_choices := back_slot.get_child(1) as HBoxContainer
var shell_button := back_choices.get_child(1) as Button
assert(shell_button != null)
shell_button.pressed.emit()
await process_frame
await process_frame
var draft: Dictionary = page.get("_draft_appearance")
assert(str(draft.get("special_back", "")) == "shell")
for other_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
if other_id != "special_back":
assert(str(draft.get(other_id, "")) == "none")
var wings_slot := page.find_child(
"SpecialSlot_wings", true, false
) as HBoxContainer
var wings_choices := wings_slot.get_child(1) as HBoxContainer
var moth_button := wings_choices.get_child(
wings_choices.get_child_count() - 1
) as Button
assert(moth_button != null)
moth_button.grab_focus()
await process_frame
moth_button.pressed.emit()
await process_frame
await process_frame
draft = page.get("_draft_appearance")
assert(str(draft.get("special_back", "")) == "shell")
assert(str(draft.get("special_wings", "")) == "moth")
var restored_focus := page.get_viewport().gui_get_focus_owner() as Button
assert(restored_focus != null)
assert(
str(restored_focus.get_meta(&"appearance_focus_key", ""))
== "special_wings:moth"
)
var ordinary_bounds := AABB(Vector3.ZERO, Vector3(1.0, 2.0, 1.0))
var wide_bounds := AABB(Vector3.ZERO, Vector3(4.0, 2.0, 1.0))
var tall_bounds := AABB(Vector3.ZERO, Vector3(1.0, 4.0, 1.0))
var viewport_size := Vector2(320.0, 330.0)
var ordinary_fit := ProfilePreview.calculate_camera_fit_distance(
ordinary_bounds, viewport_size, 75.0
)
assert(
ProfilePreview.calculate_camera_fit_distance(
wide_bounds, viewport_size, 75.0
) > ordinary_fit
)
assert(
ProfilePreview.calculate_camera_fit_distance(
tall_bounds, viewport_size, 75.0
) > ordinary_fit
)
print("Character customizer UI validation: PASS")
page.queue_free()
await process_frame
quit()

View file

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

View file

@ -41,6 +41,116 @@ const EXPECTED_ANIMATIONS: Array[StringName] = [
&"walking",
&"walking_show",
]
const EXPECTED_HEAD_MESHES: PackedStringArray = [
"head_round",
"head_pointy",
"head_anteater",
"head_axolotl",
"head_bat",
"head_bird",
"head_boar",
"head_butterfly",
"head_donkey",
"head_elephant",
"head_goat",
"head_hamster",
"head_horse",
"head_moth",
"head_opossum",
"head_owl",
"head_panda",
"head_pig",
"head_shark",
"head_sheep",
"head_snail",
]
const EXPECTED_ARM_MESHES: PackedStringArray = [
"arms_mammal", "arms_fish", "arms_avian",
]
const EXPECTED_EAR_MESHES: PackedStringArray = [
"ears_cat",
"ears_long",
"ears_fox",
"ears_bunny",
"ears_antlers_round",
"ears_bear",
"ears_anteater",
"ears_bat",
"ears_boar",
"ears_butterfly",
"ears_donkey",
"ears_axolotl",
"ears_elephant",
"ears_goat",
"ears_hamster",
"ears_horse",
"ears_opossum",
"ears_moth",
"ears_pig",
"ears_owl",
"ears_panda",
]
const EXPECTED_TAIL_MESHES: PackedStringArray = [
"tails_gator",
"tails_fox",
"tails_bunny",
"tails_cat",
"tails_bear",
"tails_pointy",
"tails_anteater",
"tails_boar",
"tails_butterfly",
"tail_donkey",
"tails_axolotl",
"tails_horse",
"tails_opossum",
"tails_moth",
"tails_pig",
"tails_shark",
"tails_bird",
]
const EXPECTED_SPECIAL_MESHES: PackedStringArray = [
"tusks_boar",
"tusks_elephant",
"wings_butterfly",
"wings_bat",
"wings_moth",
"horns_goat",
"horns_sheep",
"back_shell",
"head_fin",
"beak_owl",
"beak_bird",
]
const FACE_FEATURES: PackedStringArray = ["eyes", "nose", "mouth"]
const EXCLUDED_AUTHORING_MESHES: PackedStringArray = [
"frog_eyes_1",
"frog_eyes_2",
"frog_head_1",
"frog_head_2",
"rod_socket_preview-noimp",
]
const EXPECTED_BONE_PARENTS: Dictionary = {
"root": "",
"hips": "root",
"spine": "hips",
"clavicle.L": "spine",
"upper_arm.L": "clavicle.L",
"forearm.L": "upper_arm.L",
"hand.L": "forearm.L",
"clavicle.R": "spine",
"upper_arm.R": "clavicle.R",
"forearm.R": "upper_arm.R",
"hand.R": "forearm.R",
"rod_socket": "hand.R",
"rod_socket.001": "rod_socket",
"neck": "spine",
"head": "neck",
"thigh.L": "hips",
"shin.L": "thigh.L",
"thigh.R": "hips",
"shin.R": "thigh.R",
}
func _initialize() -> void:
@ -57,6 +167,21 @@ func _run() -> void:
) as AnimationPlayer
assert(skeleton != null)
assert(animation_player != null)
assert(character.get_path_to(skeleton) == ^"CharacterRig/Skeleton3D")
assert(skeleton.get_bone_count() == EXPECTED_BONE_PARENTS.size())
for bone_name: String in EXPECTED_BONE_PARENTS:
var bone_index := skeleton.find_bone(bone_name)
assert(bone_index >= 0, "Missing bone: %s" % bone_name)
var parent_index := skeleton.get_bone_parent(bone_index)
var actual_parent := (
str(skeleton.get_bone_name(parent_index))
if parent_index >= 0
else ""
)
assert(
actual_parent == str(EXPECTED_BONE_PARENTS[bone_name]),
"Unexpected parent for bone %s: %s" % [bone_name, actual_parent],
)
var socket_index := skeleton.find_bone("rod_socket")
assert(socket_index >= 0)
@ -79,26 +204,65 @@ func _run() -> void:
"Animation %s lost its authored rod socket pose." % expected,
)
var arms := character.find_child(
"body_arms",
true,
false,
) as MeshInstance3D
assert(arms != null)
var blended_arm_vertices := _validate_mesh_weights(arms)
assert(blended_arm_vertices > 0, "body_arms has no blended joint weights.")
var body := character.find_child(
"body_main",
true,
false,
) as MeshInstance3D
assert(body != null)
_validate_mesh_weights(body)
var expected_mesh_names := _expected_runtime_mesh_names()
assert(expected_mesh_names.size() == 137)
var face_decal_count := 0
for head_name: String in EXPECTED_HEAD_MESHES:
for feature_name: String in FACE_FEATURES:
assert(expected_mesh_names.has(
"%s_%s" % [head_name, feature_name]
))
face_decal_count += 1
assert(face_decal_count == 63)
var runtime_meshes: Array[MeshInstance3D] = []
_collect_mesh_instances(character, runtime_meshes)
assert(runtime_meshes.size() == expected_mesh_names.size())
var actual_mesh_names: Dictionary = {}
var total_blended_vertices := 0
for mesh_instance: MeshInstance3D in runtime_meshes:
assert(not actual_mesh_names.has(mesh_instance.name))
actual_mesh_names[mesh_instance.name] = true
assert(
expected_mesh_names.has(mesh_instance.name),
"Unexpected runtime character mesh: %s" % mesh_instance.name,
)
total_blended_vertices += _validate_mesh_weights(mesh_instance)
assert(actual_mesh_names.size() == expected_mesh_names.size())
for mesh_name: String in expected_mesh_names:
assert(actual_mesh_names.has(mesh_name), "Missing mesh: %s" % mesh_name)
for mesh_name: String in EXCLUDED_AUTHORING_MESHES:
assert(not actual_mesh_names.has(mesh_name))
var blended_arm_vertices := 0
for mesh_name: String in EXPECTED_ARM_MESHES:
var arms := character.find_child(
mesh_name,
true,
false,
) as MeshInstance3D
assert(arms != null)
var blended_vertices := _validate_mesh_weights(arms)
assert(
blended_vertices > 0,
"%s has no blended joint weights." % mesh_name,
)
blended_arm_vertices += blended_vertices
assert(total_blended_vertices >= blended_arm_vertices)
print(
"Character rig validation: PASS "
+ "(animations=%d, blended arm vertices=%d)"
% [EXPECTED_ANIMATIONS.size(), blended_arm_vertices]
(
"Character rig validation: PASS "
+ "(meshes=%d, face decals=%d, bones=%d, animations=%d, "
+ "blended arm vertices=%d)"
)
% [
runtime_meshes.size(),
face_decal_count,
skeleton.get_bone_count(),
EXPECTED_ANIMATIONS.size(),
blended_arm_vertices,
]
)
character.queue_free()
quit()
@ -125,12 +289,39 @@ func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int:
if weight > 0.000001:
influence_count += 1
assert(absf(total - 1.0) <= 0.0001)
assert(influence_count <= 2)
if influence_count == 2:
assert(influence_count <= 4)
if influence_count > 1:
blended_vertices += 1
return blended_vertices
func _expected_runtime_mesh_names() -> Dictionary:
var result: Dictionary = {"body_main": true}
for mesh_name: String in EXPECTED_ARM_MESHES:
result[mesh_name] = true
for head_name: String in EXPECTED_HEAD_MESHES:
result[head_name] = true
for feature_name: String in FACE_FEATURES:
result["%s_%s" % [head_name, feature_name]] = true
for mesh_name: String in EXPECTED_EAR_MESHES:
result[mesh_name] = true
for mesh_name: String in EXPECTED_TAIL_MESHES:
result[mesh_name] = true
for mesh_name: String in EXPECTED_SPECIAL_MESHES:
result[mesh_name] = true
return result
func _collect_mesh_instances(
parent: Node,
result: Array[MeshInstance3D],
) -> void:
if parent is MeshInstance3D:
result.append(parent as MeshInstance3D)
for child: Node in parent.get_children():
_collect_mesh_instances(child, result)
func _find_first(parent: Node, class_name_to_find: String) -> Node:
if parent.is_class(class_name_to_find):
return parent

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"]) == 12)
assert(int(parsed["save_version"]) == 13)
assert(not PlayerHomeState.sanitize_save_data(
parsed.get("home", {})
).is_empty())
@ -204,7 +204,7 @@ func _run() -> void:
var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY
assert(protocol_version == 13)
assert(protocol_version == 14)
assert(enet_channel_count == 18)
assert(
fish_quality_capability == "fish_quality_v1"

View file

@ -38,7 +38,7 @@ func _run() -> void:
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
)
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
assert(protocol_version == 13)
assert(protocol_version == 14)
assert(enet_channel_count == 18)
assert(movement_animation_channel == 11)
assert(
@ -505,7 +505,7 @@ func _validate_version_four_migration() -> void:
version_four,
4,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(int(migrated.get("save_version", -1)) == 13)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert(
is_equal_approx(

View file

@ -156,9 +156,9 @@ func _run() -> void:
)
var body_texture := _assert_pattern_texture("calico", "body_main")
var arms_texture := _assert_pattern_texture("paws", "body_arms")
var arms_texture := _assert_pattern_texture("paws", "arms_mammal")
var head_texture := _assert_pattern_texture("tiger", "head_round")
var ears_texture := _assert_pattern_texture("fox", "ears_pointy_short")
var ears_texture := _assert_pattern_texture("fox", "ears_cat")
var tail_texture := _assert_pattern_texture("fox", "tails_fox")
_assert_pattern_texture("tummy", "body_main")
_assert_pattern_texture("stripes", "body_main")
@ -186,13 +186,13 @@ func _run() -> void:
skeleton, "body_main", body_texture
)
var arms_material := _assert_mesh_pattern(
skeleton, "body_arms", arms_texture
skeleton, "arms_mammal", arms_texture
)
var head_material := _assert_mesh_pattern(
skeleton, "head_round", head_texture
)
var ears_material := _assert_mesh_pattern(
skeleton, "ears_pointy_short", ears_texture
skeleton, "ears_cat", ears_texture
)
var tail_material := _assert_mesh_pattern(
skeleton, "tails_fox", tail_texture

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)) == 12)
assert(int(save_data.get("save_version", -1)) == 13)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
assert(not PlayerHomeState.sanitize_save_data(
save_data.get("home", {})

View file

@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
version_five,
5,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(int(migrated.get("save_version", -1)) == 13)
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

@ -177,6 +177,11 @@ func _run_client() -> void:
var service := main.get_node(
"%NetworkProfileService"
) as NetworkProfileService
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
var player := spawn.get_local_player() as Player
var host_avatar := spawn.get_avatar(1) as Player
assert(player != null)
assert(host_avatar != null)
var original: Dictionary = service.get_persisted_appearance()
var changed: Dictionary = _changed_appearance(original)
assert(service.preview_appearance(changed))
@ -189,6 +194,11 @@ func _run_client() -> void:
service.apply_finished.connect(func(accepted: bool, message: String) -> void:
apply_results.append([accepted, message])
)
# Generated spawn points can be farther apart than audible speech range.
# Establish proximity after initial replication settles so this voice test
# does not depend on the terrain seed's peer spawn spacing.
player.global_position = host_avatar.global_position + Vector3.RIGHT
assert(player.global_position.distance_to(host_avatar.global_position) < 4.0)
assert(service.apply_profile(
service.get_persisted_name(),
changed,
@ -213,7 +223,6 @@ func _run_client() -> void:
== "trail guide"
)
assert(Dictionary(session.get("_local_appearance_snapshot")) == changed)
var player := main.get_node("%PlayerSpawnService").get_local_player() as Player
assert(player.get_animalese_voice_id() == "deep")
assert(player.get_animalese_sample_set_id() == "kim")
var host_voice_messages: Array[Dictionary] = []
@ -265,18 +274,17 @@ func _run_client() -> void:
func _changed_appearance(original: Dictionary) -> Dictionary:
var changed := original.duplicate(true)
for category_id: String in CharacterCustomizationCatalog.CATEGORY_IDS:
if category_id in CharacterCustomizationCatalog.FUR_STYLE_IDS:
continue
var options: Array = CharacterCustomizationCatalog.options_for(category_id)
for option: Dictionary in options:
var option_id: Variant = option.get("id")
if option_id != changed.get(category_id):
changed[category_id] = option_id
if CharacterCustomizationCatalog.validate_snapshot(changed):
return changed
changed[category_id] = original.get(category_id)
assert(false, "No alternate appearance option is available.")
changed["species"] = "snail"
changed["arms"] = "avian"
changed["ears"] = "owl"
changed["tail"] = "donkey"
changed["special_back"] = "shell"
changed["special_beak"] = "bird"
changed["special_head"] = "fin"
changed["special_horns"] = "goat"
changed["special_tusks"] = "elephant"
changed["special_wings"] = "moth"
assert(CharacterCustomizationCatalog.validate_snapshot(changed))
return changed

View file

@ -55,6 +55,33 @@ func _validate_network_payloads() -> void:
var invalid_hello := hello.duplicate(true)
invalid_hello["player_title"] = ""
assert(not NetworkProtocol.validate_client_hello(invalid_hello).is_empty())
var missing_appearance_field := hello.duplicate(true)
missing_appearance_field["cosmetic_snapshot"] = appearance.duplicate(true)
missing_appearance_field["cosmetic_snapshot"].erase("special_wings")
assert(not NetworkProtocol.validate_client_hello(
missing_appearance_field
).is_empty())
var extra_appearance_field := hello.duplicate(true)
extra_appearance_field["cosmetic_snapshot"] = appearance.duplicate(true)
extra_appearance_field["cosmetic_snapshot"]["unexpected"] = "value"
assert(not NetworkProtocol.validate_client_hello(
extra_appearance_field
).is_empty())
var invalid_appearance_option := hello.duplicate(true)
invalid_appearance_option["cosmetic_snapshot"] = appearance.duplicate(true)
invalid_appearance_option["cosmetic_snapshot"]["species"] = "unknown"
assert(not NetworkProtocol.validate_client_hello(
invalid_appearance_option
).is_empty())
var noncanonical_appearance := hello.duplicate(true)
noncanonical_appearance["cosmetic_snapshot"] = appearance.duplicate(true)
noncanonical_appearance["cosmetic_snapshot"]["ears"] = "pointy_wide"
assert(CharacterCustomizationCatalog.validate_snapshot(
noncanonical_appearance["cosmetic_snapshot"]
))
assert(not NetworkProtocol.validate_client_hello(
noncanonical_appearance
).is_empty())
var check_request := {
"request_id": "check",

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"]) == 12)
assert(int((decoded["data"] as Dictionary)["save_version"]) == 13)
var saved_character: Dictionary = (
(decoded["data"] as Dictionary).get("character", {})
)
@ -292,6 +292,55 @@ func _run() -> void:
assert(bool(unchanged_migration.get("ok", false)))
assert(not bool(unchanged_migration.get("changed", true)))
assert(data_root.root_path == unchanged_root)
var appearance_path: String = data_root.root_path.path_join(
"player/player_appearance.json"
)
assert(appearance_store.save_snapshot(appearance_store.get_snapshot()))
var appearance_file := FileAccess.open(appearance_path, FileAccess.READ)
assert(appearance_file != null)
var parsed_appearance_data: Variant = JSON.parse_string(
appearance_file.get_as_text()
)
appearance_file.close()
assert(typeof(parsed_appearance_data) == TYPE_DICTIONARY)
var current_appearance_data := parsed_appearance_data as Dictionary
assert(
int(current_appearance_data.get("format_version", -1))
== PlayerAppearanceStore.FORMAT_VERSION
)
assert(PortableDataMigration._valid_owned_data(
"player_appearance.json", current_appearance_data
))
var legacy_appearance_data: Dictionary = current_appearance_data.duplicate(
true
)
legacy_appearance_data["format_version"] = 1
var legacy_appearance: Dictionary = legacy_appearance_data["appearance"]
for added_id: String in [
"arms",
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]:
legacy_appearance.erase(added_id)
assert(PortableDataMigration._valid_owned_data(
"player_appearance.json", legacy_appearance_data
))
var future_appearance_data := current_appearance_data.duplicate(true)
future_appearance_data["format_version"] = (
PlayerAppearanceStore.FORMAT_VERSION + 1
)
assert(not PortableDataMigration._valid_owned_data(
"player_appearance.json", future_appearance_data
))
var malformed_appearance_data := current_appearance_data.duplicate(true)
malformed_appearance_data["format_version"] = "2"
assert(not PortableDataMigration._valid_owned_data(
"player_appearance.json", malformed_appearance_data
))
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
"progression-migrated-data"

View file

@ -32,7 +32,7 @@ func _run() -> void:
var snapshot_entities_per_envelope: int = (
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
)
assert(protocol_version == 13)
assert(protocol_version == 14)
assert(
world_spawn_capability == protocol_world_spawn_capability
)