Files
mtgodot-poc/project/test_save_snapshot_parity.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

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)