- 桥梁与静态物体高度采样修复: - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程 - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题 - 新增 test_bridge_height_parity.gd 自动化对拍测试 - 40250 怪物击杀经验动效: - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附 - 40250 客户端全系统功能对齐(Batches 1-31): - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试 - 文档沉淀: - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
249 lines
9.7 KiB
GDScript
249 lines
9.7 KiB
GDScript
# test_save_snapshot_parity.gd —— 40250 角色全量快照存读档系统 1:1 回归测试套件
|
|
extends SceneTree
|
|
|
|
const SaveSnapshotSystem = preload("res://save_snapshot_system.gd")
|
|
|
|
var _passed := 0
|
|
var _failed := 0
|
|
|
|
func _assert(cond: bool, msg: String) -> void:
|
|
if cond:
|
|
_passed += 1
|
|
print(" [PASS] %s" % msg)
|
|
else:
|
|
_failed += 1
|
|
printerr(" [FAIL] %s" % msg)
|
|
|
|
func _init() -> void:
|
|
print("\n=== Running test_save_snapshot_parity (Direction 4: Save Snapshot System 1:1) ===")
|
|
_test_snapshot_roundtrip()
|
|
_test_disk_save_load_delete()
|
|
_test_slot_summaries()
|
|
_test_validation_and_errors()
|
|
_finish()
|
|
|
|
func _create_sample_player() -> Dictionary:
|
|
var inv := []
|
|
inv.resize(90)
|
|
for i in range(90):
|
|
inv[i] = null
|
|
inv[0] = {
|
|
"vnum": 10,
|
|
"name": "巨剑+9",
|
|
"count": 1,
|
|
"sockets": [28430, 28431, 28432],
|
|
"attrs": [[1, 2000], [5, 10]]
|
|
}
|
|
inv[1] = {
|
|
"vnum": 27003,
|
|
"name": "红色药水(特大)",
|
|
"count": 200
|
|
}
|
|
|
|
var belt_inv := []
|
|
belt_inv.resize(16)
|
|
for i in range(16):
|
|
belt_inv[i] = null
|
|
belt_inv[0] = {
|
|
"vnum": 27102,
|
|
"name": "绿色药水(大)",
|
|
"count": 50
|
|
}
|
|
|
|
return {
|
|
"name": "剑圣东方",
|
|
"job": 0, # 战士
|
|
"level": 75,
|
|
"exp": 1500000,
|
|
"hp": 12500,
|
|
"max_hp": 12500,
|
|
"sp": 3200,
|
|
"max_sp": 3200,
|
|
"stamina": 1000,
|
|
"alignment": 12000, # 骑士精神
|
|
"gold": 98765432,
|
|
"str": 90,
|
|
"dex": 70,
|
|
"con": 90,
|
|
"int_stat": 30,
|
|
"stat_points": 5,
|
|
"skill_points": 2,
|
|
"pos": Vector3(456.7, 12.3, 789.0),
|
|
"map_name": "sohan",
|
|
"playtime": 7200,
|
|
"equipment": {
|
|
0: {"vnum": 189, "name": "毒剑+9", "refine": 9},
|
|
1: {"vnum": 11299, "name": "黑铁战甲+9", "refine": 9},
|
|
11: {"vnum": 18009, "name": "灵魂腰带+9", "refine_level": 9}
|
|
},
|
|
"inventory": inv,
|
|
"belt_item": {"vnum": 18009, "name": "灵魂腰带+9", "refine_level": 9},
|
|
"belt_inventory": belt_inv,
|
|
"costumes": {
|
|
0: {"vnum": 41001, "name": "沙漠武士服(男)", "costume_type": "body", "remaining_time": 500000},
|
|
1: {"vnum": 45001, "name": "武士发型", "costume_type": "hair", "remaining_time": 500000}
|
|
},
|
|
"skills": {
|
|
1: {"name": "三连斩", "level": 40, "master_type": 3}, # P
|
|
2: {"name": "剑气", "level": 40, "master_type": 3}
|
|
},
|
|
"alchemy": {
|
|
0: {"vnum": 111001, "name": "巨龙钻石", "grade": 5}
|
|
},
|
|
"monster_cards": [101, 102, 103, 1093],
|
|
"achievements": ["FIRST_METIN_KILL", "TOWER_FLOOR_9"],
|
|
"active_title": "屠龙尊者"
|
|
}
|
|
|
|
func _test_snapshot_roundtrip() -> void:
|
|
print("\n--- Test 1: In-Memory Snapshot Roundtrip & Full Schema Parity ---")
|
|
var sys := SaveSnapshotSystem.new()
|
|
var original := _create_sample_player()
|
|
|
|
var snapshot := sys.create_snapshot(original, 1)
|
|
_assert(snapshot.has("meta") and snapshot.has("stats") and snapshot.has("inventory"), "Snapshot contains main root keys")
|
|
_assert(snapshot["meta"]["schema_version"] == SaveSnapshotSystem.SCHEMA_VERSION, "Schema version is 40250.1")
|
|
_assert(snapshot["summary"]["name"] == "剑圣东方", "Summary name matches")
|
|
_assert(snapshot["summary"]["job_name"] == "战士", "Summary job translated to 战士")
|
|
_assert(snapshot["inventory"].size() == 90, "Inventory has exact 90 slots")
|
|
_assert(snapshot["belt_system"]["belt_inventory"].size() == 16, "Belt inventory has exact 16 slots")
|
|
|
|
# Restore
|
|
var restore_res := sys.restore_snapshot(snapshot)
|
|
_assert(restore_res["ok"] == true, "Restore succeeded")
|
|
|
|
var restored: Dictionary = restore_res["player_data"]
|
|
_assert(restored["name"] == original["name"], "Player name preserved")
|
|
_assert(restored["level"] == original["level"], "Level preserved (75)")
|
|
_assert(restored["gold"] == original["gold"], "Gold preserved (98765432)")
|
|
_assert(restored["alignment"] == original["alignment"], "Alignment preserved (12000)")
|
|
_assert(restored["pos"] is Vector3 and restored["pos"].is_equal_approx(Vector3(456.7, 12.3, 789.0)), "Vector3 position accurately restored")
|
|
|
|
# Inventory restoration
|
|
_assert(restored["inventory"].size() == 90, "Restored inventory size is 90")
|
|
_assert(restored["inventory"][0]["vnum"] == 10, "Slot 0 item vnum 10 preserved")
|
|
_assert(restored["inventory"][0]["sockets"] == [28430, 28431, 28432], "Sockets preserved")
|
|
_assert(restored["inventory"][1]["count"] == 200, "Potion count 200 preserved")
|
|
_assert(restored["inventory"][2] == null, "Empty inventory slots remain null")
|
|
|
|
# Equipment restoration
|
|
_assert(restored["equipment"].has(0) and restored["equipment"][0]["name"] == "毒剑+9", "Equipment slot 0 (WEAPON) preserved")
|
|
_assert(restored["equipment"].has(1) and restored["equipment"][1]["name"] == "黑铁战甲+9", "Equipment slot 1 (ARMOR) preserved")
|
|
|
|
# Belt, Costumes, Progression
|
|
_assert(restored["belt_inventory"][0]["vnum"] == 27102, "Belt potion in slot 0 preserved")
|
|
_assert(restored["costumes"].has(0) and restored["costumes"][0]["vnum"] == 41001, "Costume body preserved")
|
|
_assert(restored["skills"].has(1) and restored["skills"][1]["level"] == 40, "Skill P level preserved")
|
|
_assert(restored["monster_cards"].size() == 4, "Monster card collection preserved")
|
|
_assert(restored["active_title"] == "屠龙尊者", "Active title preserved")
|
|
|
|
func _test_disk_save_load_delete() -> void:
|
|
print("\n--- Test 2: Disk IO - Save, Load, and Delete Slots ---")
|
|
var sys := SaveSnapshotSystem.new()
|
|
var test_dir := OS.get_cache_dir().path_join("metin2_snapshot_test_%d" % int(Time.get_unix_time_from_system()))
|
|
var player := _create_sample_player()
|
|
|
|
var save_signal := {"fired": false, "slot": 0, "path": ""}
|
|
sys.snapshot_saved.connect(func(s: int, p: String):
|
|
save_signal["fired"] = true
|
|
save_signal["slot"] = s
|
|
save_signal["path"] = p
|
|
)
|
|
|
|
var load_signal := {"fired": false, "slot": 0, "path": ""}
|
|
sys.snapshot_loaded.connect(func(s: int, p: String):
|
|
load_signal["fired"] = true
|
|
load_signal["slot"] = s
|
|
load_signal["path"] = p
|
|
)
|
|
|
|
var delete_signal := {"fired": false, "slot": 0}
|
|
sys.snapshot_deleted.connect(func(s: int):
|
|
delete_signal["fired"] = true
|
|
delete_signal["slot"] = s
|
|
)
|
|
|
|
# Save to slot 1
|
|
var save_res = sys.save_slot(1, player, test_dir)
|
|
_assert(save_res["ok"] == true, "Save slot 1 returned ok")
|
|
_assert(save_signal["fired"] == true and save_signal["slot"] == 1, "snapshot_saved signal fired")
|
|
_assert(FileAccess.file_exists(save_res["path"]), "Save file exists on disk")
|
|
|
|
# Load from slot 1
|
|
var load_res = sys.load_slot(1, test_dir)
|
|
_assert(load_res["ok"] == true, "Load slot 1 returned ok")
|
|
_assert(load_signal["fired"] == true and load_signal["slot"] == 1, "snapshot_loaded signal fired")
|
|
_assert(load_res["player_data"]["name"] == "剑圣东方", "Loaded player name matches")
|
|
_assert(load_res["player_data"]["gold"] == 98765432, "Loaded player gold matches")
|
|
|
|
# Delete slot 1
|
|
var del_res = sys.delete_slot(1, test_dir)
|
|
_assert(del_res["ok"] == true, "Delete slot 1 returned ok")
|
|
_assert(delete_signal["fired"] == true and delete_signal["slot"] == 1, "snapshot_deleted signal fired")
|
|
_assert(FileAccess.file_exists(save_res["path"]) == false, "Save file removed from disk")
|
|
|
|
# Loading after deletion should fail gracefully
|
|
var reload_fail = sys.load_slot(1, test_dir)
|
|
_assert(reload_fail["ok"] == false and reload_fail["reason"] == "FILE_NOT_FOUND", "Loading deleted slot fails with FILE_NOT_FOUND")
|
|
|
|
# Clean up directory
|
|
DirAccess.remove_absolute(test_dir)
|
|
|
|
func _test_slot_summaries() -> void:
|
|
print("\n--- Test 3: Fast Multi-Slot Summaries ---")
|
|
var sys := SaveSnapshotSystem.new()
|
|
var test_dir := OS.get_cache_dir().path_join("metin2_summary_test_%d" % int(Time.get_unix_time_from_system()))
|
|
var player1 := _create_sample_player()
|
|
var player2 := _create_sample_player()
|
|
player2["name"] = "幻影刺客"
|
|
player2["job"] = 1 # 刺客
|
|
player2["level"] = 60
|
|
|
|
# Save slot 1 and slot 3 (leave slot 2, 4, 5 empty)
|
|
sys.save_slot(1, player1, test_dir)
|
|
sys.save_slot(3, player2, test_dir)
|
|
|
|
var summaries := sys.get_slot_summaries(test_dir, 5)
|
|
_assert(summaries.size() == 5, "Summaries has 5 entries")
|
|
_assert(summaries[0]["is_empty"] == false and summaries[0]["name"] == "剑圣东方" and summaries[0]["level"] == 75, "Slot 1 summary accurate")
|
|
_assert(summaries[1]["is_empty"] == true, "Slot 2 summary shows is_empty == true")
|
|
_assert(summaries[2]["is_empty"] == false and summaries[2]["name"] == "幻影刺客" and summaries[2]["job_name"] == "刺客", "Slot 3 summary accurate")
|
|
_assert(summaries[3]["is_empty"] == true, "Slot 4 summary is_empty")
|
|
_assert(summaries[4]["is_empty"] == true, "Slot 5 summary is_empty")
|
|
|
|
# Clean up
|
|
sys.delete_slot(1, test_dir)
|
|
sys.delete_slot(3, test_dir)
|
|
DirAccess.remove_absolute(test_dir)
|
|
|
|
func _test_validation_and_errors() -> void:
|
|
print("\n--- Test 4: Validation & Error Handling ---")
|
|
var sys := SaveSnapshotSystem.new()
|
|
|
|
var invalid_meta := sys.validate_snapshot({"foo": "bar"})
|
|
_assert(invalid_meta["ok"] == false and invalid_meta["reason"] == "MISSING_META", "Missing meta rejected")
|
|
|
|
var bad_version := sys.validate_snapshot({"meta": {"schema_version": "999.0"}})
|
|
_assert(bad_version["ok"] == false and bad_version["reason"] == "VERSION_MISMATCH", "Version mismatch rejected")
|
|
|
|
var bad_json_res := sys.import_json("This is not JSON!")
|
|
_assert(bad_json_res["ok"] == false and bad_json_res["reason"] == "INVALID_JSON_ROOT", "Invalid JSON string rejected")
|
|
|
|
var player := _create_sample_player()
|
|
var json_str := sys.export_json(player)
|
|
_assert(json_str.length() > 0, "export_json produces non-empty JSON")
|
|
var parsed_res := sys.import_json(json_str)
|
|
_assert(parsed_res["ok"] == true and parsed_res["player_data"]["name"] == "剑圣东方", "import_json parses valid JSON accurately")
|
|
|
|
func _finish() -> void:
|
|
print("\n==========================================")
|
|
print("Save Snapshot Parity Tests Finished:")
|
|
print("Passed: %d, Failed: %d" % [_passed, _failed])
|
|
print("==========================================")
|
|
if _failed > 0:
|
|
printerr("FAILED: Some tests did not pass.")
|
|
quit(1)
|
|
else:
|
|
print("SUCCESS: All Save Snapshot parity tests passed!")
|
|
quit(0)
|