feat: expand growth, equipment, and mail

- add character promotion and costume potential flows
- add equipment management and enhancement
- harden development mail and save tools
This commit is contained in:
2026-09-21 22:57:53 +08:00
parent a7c065eaa5
commit f55a71ac52
47 changed files with 6237 additions and 97 deletions
+313
View File
@@ -7,8 +7,10 @@ import sys
import tempfile
import unittest
import zipfile
from unittest import mock
from Crypto.Cipher import AES
from google.protobuf import descriptor_pb2
TOOLS = Path(__file__).resolve().parents[1]
@@ -17,6 +19,15 @@ sys.path.insert(0, str(TOOLS))
import gamedata_db
import import_seed
import save_checkpoint
import deobfuscate_client_source
import extract_client_proto
import set_first_gacha_state
import repair_equipment_ranks
import repair_failed_collection_growth
import migrate_costume_potential_state
import migrate_equipment_upgrade_attempts
import dev_mail_grant
import repair_invalid_dev_mail_resources
class Arguments:
@@ -100,6 +111,93 @@ class ImportToolTests(unittest.TestCase):
class CheckpointToolTests(unittest.TestCase):
def test_invalid_dev_mail_resource_repair_preserves_grant_ledger(self):
identity = "mail:13043056739:items"
original = {
"version": "2.34.13",
"items": [
{"inven_index": 10, "id": 90045, "type": 8, "count": 1},
{"inven_index": 11, "id": 127, "type": 8, "count": 99},
],
"granted": {identity: True},
"grant_items": {identity: [10]},
}
repaired, removed = repair_invalid_dev_mail_resources.repair(original)
self.assertEqual(removed, [10])
self.assertEqual([item["inven_index"] for item in repaired["items"]], [11])
self.assertEqual(repaired["granted"], original["granted"])
self.assertEqual(repaired["grant_items"], original["grant_items"])
repeated, removed = repair_invalid_dev_mail_resources.repair(repaired)
self.assertEqual(removed, [])
self.assertEqual(repeated, repaired)
def test_process_check_fails_closed_when_tasklist_is_unavailable(self):
failed = mock.Mock(returncode=1, stdout="", stderr="ERROR: Access denied")
with mock.patch.object(save_checkpoint.os, "name", "nt"), mock.patch.object(save_checkpoint.subprocess, "run", return_value=failed):
with self.assertRaisesRegex(RuntimeError, "cannot verify"):
save_checkpoint.running_processes()
def test_equipment_upgrade_attempt_migration_is_explicit_and_idempotent(self):
original = {"version": "2.34.13", "equipment": [{"inven_index": 1, "id": 10010}]}
migrated, changed = migrate_equipment_upgrade_attempts.migrate(original)
self.assertEqual(changed, [1])
self.assertEqual(migrated["equipment"][0]["upgrade_attempts"], 0)
self.assertNotIn("upgrade_attempts", original["equipment"][0])
repeated, changed = migrate_equipment_upgrade_attempts.migrate(migrated)
self.assertEqual(changed, [])
self.assertEqual(repeated, migrated)
def test_costume_potential_state_migration_is_final_and_idempotent(self):
original = {"version": "2.34.13", "costumes": [{"inven_index": 1, "id": 1001}]}
migrated, changed = migrate_costume_potential_state.migrate(original)
self.assertTrue(changed)
self.assertEqual(migrated["costume_potential"], {})
self.assertNotIn("costume_potential", original)
repeated, changed = migrate_costume_potential_state.migrate(migrated)
self.assertFalse(changed)
self.assertEqual(repeated, migrated)
with self.assertRaises(ValueError):
migrate_costume_potential_state.migrate({"version": "2.34.13", "costumes": [{"potential_id": 1}]})
def test_failed_collection_growth_repair_requires_exact_six_unchanged_requests(self):
module = repair_failed_collection_growth
items = {"version": "2.34.13", "grant_items": {}, "next_index": 900000061, "items": [
{"inven_index": index, "id": value[0], "type": 8, "count": value[1]}
for index, value in module.CONSUMED.items()
] + [
{"inven_index": index, "id": value[0], "type": 8, "count": value[1]}
for index, value in module.REFUNDS.items()
]}
wallet = {"version": "2.34.13", "gold": 56050, "spent": {module.IDENTITY: True}}
collection = {"version": "2.34.13", "characters": [
{"inven_index": 920000054, "id": 6510, "level": 1},
]}
line = f"WARN session packet rejected path=/CharGrowth error={module.ERROR}\n"
corrected_items, corrected_wallet = module.repair(items, wallet, collection, line * 6)
self.assertEqual(corrected_wallet["gold"], 66050)
self.assertNotIn(module.IDENTITY, corrected_wallet["spent"])
self.assertEqual(len(corrected_items["items"]), len(module.CONSUMED))
self.assertEqual(next(item["count"] for item in corrected_items["items"] if item["inven_index"] == 900000042), 99256)
self.assertEqual(items["items"][2]["count"], 94738)
with self.assertRaises(ValueError):
module.repair(items, wallet, collection, line * 5)
def test_equipment_rank_repair_only_fills_missing_arrays(self):
original = {"version": "2.34.13", "equipment": [
{"inven_index": 1, "id": 10010},
{"inven_index": 2, "id": 943619, "rank": [0, 2, 0]},
]}
repaired, indices = repair_equipment_ranks.repair(original)
self.assertEqual(indices, [1])
self.assertEqual(repaired["equipment"][0]["rank"], [0, 0, 0])
self.assertEqual(repaired["equipment"][1]["rank"], [0, 2, 0])
self.assertNotIn("rank", original["equipment"][0])
repeated, indices = repair_equipment_ranks.repair(repaired)
self.assertEqual(indices, [])
self.assertEqual(repeated, repaired)
with self.assertRaises(ValueError):
repair_equipment_ranks.repair({"version": "2.34.13", "equipment": [{"inven_index": 3, "rank": [1, 2]}]})
def test_checkpoint_hash_manifest(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -111,6 +209,221 @@ class CheckpointToolTests(unittest.TestCase):
created = save_checkpoint.create(state, target, "test")
self.assertEqual(save_checkpoint.verify(target), created)
def test_first_gacha_state_edit_is_explicit_and_idempotent(self):
original = {"version": "2.34.13", "grants": {"draw": {}}}
completed, changed = set_first_gacha_state.updated_collection(original, True)
self.assertTrue(changed)
self.assertEqual(completed["grants"][set_first_gacha_state.IDENTITY], {})
repeated, changed = set_first_gacha_state.updated_collection(completed, True)
self.assertFalse(changed)
pending, changed = set_first_gacha_state.updated_collection(repeated, False)
self.assertTrue(changed)
self.assertNotIn(set_first_gacha_state.IDENTITY, pending["grants"])
class DevelopmentMailGrantToolTests(unittest.TestCase):
def test_deterministic_random_box_is_replaced_by_direct_material(self):
items = [
{"id": 400131, "element_type": 9, "name": "装备制作所需材料", "category": "随机箱"},
{"id": 127, "element_type": 8, "name": "<未找到本地化文本 #32127>", "category": "资源"},
{"id": 999, "element_type": 9, "name": "真正随机箱", "category": "随机箱"},
]
mapped = dev_mail_grant.map_fixed_boxes_to_direct_items(
items,
{400131: (8, 127, 1)},
{400131: {"女神之泪": 1}},
)
self.assertNotIn((9, 400131), {(item["element_type"], item["id"]) for item in mapped})
self.assertNotIn((9, 999), {(item["element_type"], item["id"]) for item in mapped})
material = next(item for item in mapped if item["element_type"] == 8 and item["id"] == 127)
self.assertEqual(material["name"], "女神之泪")
self.assertIn("无需开箱", material["details"])
self.assertIn("400131", material["details"])
def test_internal_lost_resource_is_not_mail_safe(self):
self.assertFalse(dev_mail_grant._safe_direct_mail_item({
"id": 90045, "element_type": 8, "name": "金币遗失物品",
"source_table": "ResourceTable", "resource_type": 2,
}))
def test_gold_currency_mail_uses_type_four_id_zero_and_requested_count(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "mail.json"
output = root / "generated.json"
source.write_text(json.dumps({
"version": "2.34.13", "mails": [], "mail_count": 1, "max_mail_id": 0,
}), encoding="utf-8")
gold = {"id": 0, "element_type": 4, "name": "金币"}
store = dev_mail_grant.MailGrantStore(source, output, [gold], 365)
result = store.grant({"item_id": 0, "element_type": 4, "count": 123456789})
self.assertEqual(result["mail"]["reward_types"], [4])
self.assertEqual(result["mail"]["reward_ids"], [0])
self.assertEqual(result["mail"]["reward_counts"], [123456789])
def test_packed_varints_accepts_repeated_and_packed_fields(self):
self.assertEqual(dev_mail_grant.packed_varints({4: [3, b"\x80\x01\x02"]}, 4), [3, 128, 2])
with self.assertRaises(ValueError):
dev_mail_grant.packed_varints({4: [b"\x80"]}, 4)
def test_item_picker_is_collapsible_and_scrolls_its_list(self):
self.assertIn('<details id=item-picker>', dev_mail_grant.PAGE)
self.assertIn('<div class=item-list>', dev_mail_grant.PAGE)
self.assertIn('max-height:min(40vh,28rem)', dev_mail_grant.PAGE)
self.assertIn('overflow:auto', dev_mail_grant.PAGE)
self.assertIn("$('item-picker').open=false", dev_mail_grant.PAGE)
def test_picker_search_includes_random_box_aliases(self):
self.assertIn("x.category+' '+(x.details||'')", dev_mail_grant.PAGE)
def test_grant_writes_complete_seed_without_state_mutation(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source-mail.json"
output = root / "generated-mail.json"
source.write_text(json.dumps({
"version": "2.34.13",
"mails": [{
"mail_id": 100, "mail_type": 2, "title": "base", "body": "base",
"expires_at": 200, "reward_types": [8], "reward_ids": [7],
"reward_counts": [1], "sent_at": 100,
}],
"mail_count": 2, "max_mail_id": 100,
}), encoding="utf-8")
original_source = source.read_text(encoding="utf-8")
store = dev_mail_grant.MailGrantStore(source, output, [{
"id": 9, "element_type": 8, "name": "slime",
}], 365)
self.assertTrue(output.is_file())
self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["mail_count"], 2)
result = store.grant({"item_id": 9, "element_type": 8, "count": 123, "title": "test", "body": "body"})
written = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(source.read_text(encoding="utf-8"), original_source)
self.assertEqual(written["mail_count"], 3)
self.assertEqual(written["max_mail_id"], 101)
self.assertEqual(written["mails"][-1]["reward_types"], [8])
self.assertEqual(written["mails"][-1]["reward_ids"], [9])
self.assertEqual(written["mails"][-1]["reward_counts"], [123])
self.assertFalse(result["restart_required"])
with self.assertRaises(ValueError):
store.grant({"item_id": 999, "element_type": 8, "count": 1})
with self.assertRaises(ValueError):
store.grant({"item_id": 9, "element_type": 8, "count": dev_mail_grant.MAX_INT32 + 1})
class ClientSourceToolTests(unittest.TestCase):
def test_deobfuscates_code_not_comments_or_literals_and_records_collisions(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "Assembly-CSharp"
source.mkdir()
mapping = root / "translation.obfuscate"
mapping.write_text(
"#ReverseOrder\n"
"α⇨Net.Player/User-Info\n"
"β⇨Other.User Info\n"
"γ⇨class\n"
"δ⇨Meaning.D\n",
encoding="utf-8",
)
(source / "α.cs").write_text(
"// α β γ δ\n"
"class α { string v = \"α β γ δ\"; char x = 'α'; α f; β g; γ h; δ i; }\n",
encoding="utf-8",
)
(source / "asset.bin").write_bytes(b"\x00a")
output = root / "mirror"
manifest = deobfuscate_client_source.deobfuscate(source, mapping, output)
text = (output / "User_Info.cs").read_text(encoding="utf-8")
self.assertIn("// α β γ δ", text)
self.assertIn('"α β γ δ"', text)
self.assertIn("char x = 'α'", text)
self.assertIn("class User_Info", text)
self.assertIn("User_Info__from_u03B2 g", text)
self.assertIn("_class h", text)
self.assertIn("D i", text)
self.assertEqual((output / "asset.bin").read_bytes(), b"\x00a")
self.assertEqual(manifest["statistics"]["identifier_replacements"], 5)
self.assertTrue((output / deobfuscate_client_source.MANIFEST).is_file())
def test_deobfuscator_skips_ambiguous_scoped_symbol(self):
entries = [
{"line": 1, "source": "α", "meaning": "One.Value"},
{"line": 2, "source": "α", "meaning": "Two.Value"},
]
replacements, report, warnings = deobfuscate_client_source.build_replacements(entries)
self.assertNotIn("α", replacements)
self.assertEqual(report, [])
self.assertTrue(any("ambiguous" in warning for warning in warnings))
def test_deobfuscator_refuses_output_inside_source_or_unmanaged_output(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
mapping = root / "map"
mapping.write_text("a⇨Name\n", encoding="utf-8")
with self.assertRaises(ValueError):
deobfuscate_client_source.deobfuscate(source, mapping, source / "out")
output = root / "output"
output.mkdir()
(output / "someone.txt").write_text("keep", encoding="utf-8")
with self.assertRaises(FileExistsError):
deobfuscate_client_source.deobfuscate(source, mapping, output)
def test_reconstructs_proto_and_lossless_descriptor_set(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "Assembly-CSharp"
net = source / "Proto" / "Net"
net.mkdir(parents=True)
descriptor = descriptor_pb2.FileDescriptorProto(
name="Request/Login.proto", package="proto.net", syntax="proto3"
)
message = descriptor.message_type.add(name="LoginRequest")
message.field.add(
name="seq", number=1,
label=descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL,
type=descriptor_pb2.FieldDescriptorProto.TYPE_INT32,
)
encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii")
(net / "LoginRequestReflection.cs").write_text(
"private static FileDescriptor descriptor = "
"FileDescriptor.FromGeneratedCode(Convert.FromBase64String("
f"string.Concat(new string[] {{ \"{encoded}\" }})), new FileDescriptor[0], info);",
encoding="utf-8",
)
output = root / "proto-view"
manifest = extract_client_proto.reconstruct(source, output)
proto = (output / "Request" / "Login.proto").read_text(encoding="utf-8")
self.assertIn('syntax = "proto3";', proto)
self.assertIn("package proto.net;", proto)
self.assertIn("message LoginRequest", proto)
self.assertIn("int32 seq = 1;", proto)
saved = descriptor_pb2.FileDescriptorSet()
saved.ParseFromString((output / extract_client_proto.DESCRIPTOR_SET).read_bytes())
self.assertEqual(saved.file[0], descriptor)
self.assertEqual(manifest["statistics"]["proto_files"], 1)
def test_proto_extractor_rejects_unmanaged_or_nested_outputs(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source"
(source / "Proto" / "Net").mkdir(parents=True)
with self.assertRaises(ValueError):
extract_client_proto.reconstruct(source, source / "view")
descriptor = descriptor_pb2.FileDescriptorProto(name="X.proto", syntax="proto3")
encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii")
(source / "Proto" / "Net" / "XReflection.cs").write_text(
"Convert.FromBase64String(string.Concat(new string[] {"
f"\"{encoded}\"" + "})), new FileDescriptor[0], info);",
encoding="utf-8",
)
output = root / "view"
output.mkdir()
with self.assertRaises(FileExistsError):
extract_client_proto.reconstruct(source, output)
if __name__ == "__main__":
unittest.main()