Added Mystery Box Support
Added the event specific Mystery Box support to the exporter. Closes #7
This commit is contained in:
parent
2b33ea63af
commit
9eae9b4a15
24 changed files with 627 additions and 19 deletions
1
tests/fixtures/nte_assets/DataTable/GashaponLottery/DT_GashaponLotteryGlobal.json
vendored
Normal file
1
tests/fixtures/nte_assets/DataTable/GashaponLottery/DT_GashaponLotteryGlobal.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
[{"Rows":{}}]
|
||||
1
tests/fixtures/nte_assets/DataTable/Vehicle/DT_VehicleItemData.json
vendored
Normal file
1
tests/fixtures/nte_assets/DataTable/Vehicle/DT_VehicleItemData.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
[{"Rows":{}}]
|
||||
|
|
@ -72,6 +72,51 @@ class MappingUpdateTests(unittest.TestCase):
|
|||
self.assertNotIn("UnusedItem", result.mappings["items.json"])
|
||||
self.assertFalse(any(key.startswith("Characterawaken_") for key in result.mappings["items.json"]))
|
||||
|
||||
def test_future_mystery_box_pool_is_discovered_without_hard_coded_event_id(self):
|
||||
assets = load_assets(assets_root=SAMPLE_ASSETS)
|
||||
tables = deepcopy(assets.tables)
|
||||
tables["mystery_box_pools"]["MangHe_wowzers"] = {
|
||||
"GiftList": [
|
||||
{"ItemID": "WowTicket"},
|
||||
{"ItemID": "Frame_Wowzers"},
|
||||
{"ItemID": "VehicleWow"},
|
||||
]
|
||||
}
|
||||
tables["inventory"]["WowTicket"] = {
|
||||
"ItemName": {"TableId": "/Game/Text/ST_Item.ST_Item", "Key": "wow_ticket"},
|
||||
"ItemQuality": "EItemQuality::ITEM_QUALITY_PURPLE",
|
||||
}
|
||||
tables["inventory"]["Frame_Wowzers"] = {
|
||||
"ItemName": {"TableId": "/Game/Text/ST_Item.ST_Item", "Key": "wow_frame"},
|
||||
"ItemQuality": "EItemQuality::ITEM_QUALITY_BLUE",
|
||||
}
|
||||
tables["vehicle_inventory"]["vehiclewow"] = {
|
||||
"ItemName": {
|
||||
"TableId": "/Game/Text/ST_VehicleData.ST_VehicleData",
|
||||
"Key": "vehicle_wow",
|
||||
},
|
||||
"ItemQuality": "EItemQuality::ITEM_QUALITY_ORANGE",
|
||||
}
|
||||
tables["localization"]["ST_Item"].update(
|
||||
{"wow_ticket": "Wowzers Ticket", "wow_frame": "Wowzers Frame"}
|
||||
)
|
||||
tables["localization"]["ST_VehicleData"] = {"vehicle_wow": "Wowmobile"}
|
||||
|
||||
result = build_mapping_update(sample_current(), replace(assets, tables=tables))
|
||||
|
||||
self.assertEqual(
|
||||
result.mappings["items.json"]["WowTicket"],
|
||||
{"type": "item", "name": "Wowzers Ticket", "rank": "A"},
|
||||
)
|
||||
self.assertEqual(
|
||||
result.mappings["items.json"]["Frame_Wowzers"],
|
||||
{"type": "cosmetic", "name": "Wowzers Frame", "rank": "B"},
|
||||
)
|
||||
self.assertEqual(
|
||||
result.mappings["items.json"]["vehiclewow"],
|
||||
{"type": "item", "name": "Wowmobile", "rank": "S"},
|
||||
)
|
||||
|
||||
def test_embedded_localized_strings_are_ignored(self):
|
||||
_current, result = build_sample_update()
|
||||
|
||||
|
|
|
|||
116
tests/test_mystery_box_decoding.py
Normal file
116
tests/test_mystery_box_decoding.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from tests.support import * # noqa: F401,F403
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from nte_history_exporter.constants import MYSTERY_BOX_MARKER
|
||||
from nte_history_exporter.decoder.mystery_box import (
|
||||
build_mystery_box_rows_from_pairs,
|
||||
is_mystery_box_history_request,
|
||||
mystery_box_request_page,
|
||||
parse_mystery_box_response,
|
||||
)
|
||||
|
||||
|
||||
def mystery_box_request(page: int) -> bytes:
|
||||
request = bytearray(54)
|
||||
request[26:30] = (2060).to_bytes(4, "little")
|
||||
request[31:35] = (page * 2).to_bytes(4, "little")
|
||||
request[35:39] = (2110).to_bytes(4, "little")
|
||||
request[40:44] = (2).to_bytes(4, "little")
|
||||
request[44:48] = (2060).to_bytes(4, "little")
|
||||
request[49:53] = (10).to_bytes(4, "little")
|
||||
request[53] = 6
|
||||
return bytes(request)
|
||||
|
||||
|
||||
def mystery_box_response(records: list[tuple[str, int, datetime]]) -> bytes:
|
||||
body = bytearray()
|
||||
for reward_id, quantity, timestamp in records:
|
||||
encoded_id = reward_id.encode("utf-8") + b"\0"
|
||||
ticks = int(timestamp.timestamp() * 10_000_000) + 621_355_968_000_000_000
|
||||
body += len(encoded_id).to_bytes(4, "little")
|
||||
body += encoded_id
|
||||
body += quantity.to_bytes(4, "little")
|
||||
body += b"\x01"
|
||||
body += ticks.to_bytes(8, "little")
|
||||
return (
|
||||
bytes(50)
|
||||
+ MYSTERY_BOX_MARKER
|
||||
+ b"\0"
|
||||
+ bytes(4)
|
||||
+ len(body).to_bytes(4, "little")
|
||||
+ len(records).to_bytes(4, "little")
|
||||
+ body
|
||||
+ b"\x03"
|
||||
)
|
||||
|
||||
|
||||
class MysteryBoxDecodingTests(unittest.TestCase):
|
||||
def test_recognizes_request_and_page_cursor(self):
|
||||
request = mystery_box_request(6)
|
||||
self.assertTrue(is_mystery_box_history_request(request))
|
||||
self.assertEqual(mystery_box_request_page(request), 6)
|
||||
|
||||
def test_decodes_exact_quantity_and_timestamp(self):
|
||||
timestamp = datetime(2026, 7, 8, 19, 9, 33, tzinfo=timezone.utc)
|
||||
rows = parse_mystery_box_response(
|
||||
mystery_box_response(
|
||||
[
|
||||
("vehicle039", 1, timestamp),
|
||||
("SpecialGift_ticket", 3, timestamp),
|
||||
("gold", 100_000, timestamp),
|
||||
]
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0]["reward_id"], "vehicle039")
|
||||
self.assertEqual(rows[0]["reward_name"], "Draco")
|
||||
self.assertEqual(rows[0]["timestamp_decoded"], "2026-07-08 19:09:33")
|
||||
self.assertEqual(rows[1]["quantity"], 3)
|
||||
self.assertEqual(rows[1]["result_type"], "single_pull")
|
||||
self.assertEqual(rows[2]["reward_name"], "Beetle Coin")
|
||||
self.assertEqual(rows[2]["reward_rank"], "B")
|
||||
|
||||
def test_live_session_accepts_partial_final_page(self):
|
||||
session = LiveHistorySession("192.168.0.10")
|
||||
request = mystery_box_request(3)
|
||||
timestamp = datetime(2026, 7, 8, 19, 9, 33, tzinfo=timezone.utc)
|
||||
response = mystery_box_response(
|
||||
[("Fons", 100_000, timestamp), ("gold", 100_000, timestamp)]
|
||||
)
|
||||
self.assertFalse(
|
||||
session.process_packet(
|
||||
UdpPacket(1.0, "192.168.0.10", "203.0.113.5", 50000, 40000, request)
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
session.process_packet(
|
||||
UdpPacket(1.1, "203.0.113.5", "192.168.0.10", 40000, 50000, response)
|
||||
)
|
||||
)
|
||||
self.assertEqual(session.kinds_seen(), ["mystery_box"])
|
||||
self.assertEqual(session.pairs[0][0], 3)
|
||||
self.assertEqual(session.pairs[0][8:10], (0, 2))
|
||||
|
||||
def test_rows_and_export_use_non_shared_mystery_box_banner(self):
|
||||
timestamp = datetime(2026, 7, 8, 19, 9, 33, tzinfo=timezone.utc)
|
||||
response = mystery_box_response([("Fons", 100_000, timestamp)])
|
||||
rows = build_mystery_box_rows_from_pairs(
|
||||
[(1, 2, 1, 1.0, 2, 1.1, response, "mystery_box", 0, 1)]
|
||||
)
|
||||
export = build_export_json(rows, [])
|
||||
self.assertEqual(export["banner"]["id"], "Gashapon_MysteryBox")
|
||||
self.assertEqual(export["banner"]["name"], "Mystery Box")
|
||||
self.assertIs(export["banner"]["shared_pity"], False)
|
||||
self.assertEqual(export["records"][0]["result_type"], "single_pull")
|
||||
self.assertEqual(export["records"][0]["quantity"], 100_000)
|
||||
self.assertNotIn("roll_result", export["records"][0])
|
||||
self.assertEqual(rows[0]["timestamp_group_size_seen"], 1)
|
||||
self.assertEqual(rows[0]["uid_status"], "stable")
|
||||
|
||||
def test_export_path_uses_mystery_box_prefix(self):
|
||||
_csv_path, json_path = export_paths("mystery_box", "218216016349")
|
||||
self.assertRegex(
|
||||
json_path.name,
|
||||
r"^218216016349_MysteryBox_\d{8}_\d{6}(?:_\d+)?\.json$",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue