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 +1 @@
|
|||
__version__ = "0.1.10"
|
||||
__version__ = "0.1.11"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ from typing import Any
|
|||
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1
|
||||
from nte_history_exporter.decoder.arc import build_arc_rows_from_pairs, select_continuous_arc_run
|
||||
from nte_history_exporter.decoder.run import build_rows_from_pairs
|
||||
from nte_history_exporter.decoder.mystery_box import (
|
||||
build_mystery_box_rows_from_pairs,
|
||||
select_continuous_mystery_box_run,
|
||||
)
|
||||
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
|
||||
from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket
|
||||
|
||||
|
|
@ -101,12 +105,21 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
|
|||
packet = UdpPacket(ts, remote_ip, local_ip, remote_port, local_port, content)
|
||||
session.process_packet(packet)
|
||||
|
||||
pairs = [pair for pair in session.pairs if pair[7] != "arc_miracle_box"]
|
||||
pairs = [
|
||||
pair
|
||||
for pair in session.pairs
|
||||
if pair[7] not in {"arc_miracle_box", "mystery_box"}
|
||||
]
|
||||
arc_pairs = [pair for pair in session.pairs if pair[7] == "arc_miracle_box"]
|
||||
mystery_box_pairs = [pair for pair in session.pairs if pair[7] == "mystery_box"]
|
||||
best_run, run_warnings = select_continuous_run_from_page_1(pairs)
|
||||
rows_out = build_rows_from_pairs(best_run)
|
||||
best_arc_run, arc_warnings = select_continuous_arc_run(arc_pairs)
|
||||
arc_rows = build_arc_rows_from_pairs(best_arc_run)
|
||||
best_mystery_box_run, mystery_box_warnings = select_continuous_mystery_box_run(
|
||||
mystery_box_pairs
|
||||
)
|
||||
mystery_box_rows = build_mystery_box_rows_from_pairs(best_mystery_box_run)
|
||||
|
||||
return {
|
||||
"flow_index": resolved_flow_index,
|
||||
|
|
@ -118,6 +131,10 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
|
|||
"best_arc_run": best_arc_run,
|
||||
"arc_rows": arc_rows,
|
||||
"arc_warnings": arc_warnings,
|
||||
"mystery_box_pairs": mystery_box_pairs,
|
||||
"best_mystery_box_run": best_mystery_box_run,
|
||||
"mystery_box_rows": mystery_box_rows,
|
||||
"mystery_box_warnings": mystery_box_warnings,
|
||||
"user_uid": session.user_uid or user_uid,
|
||||
"capture_diagnostics": session.diagnostic_report(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
return 1
|
||||
|
||||
decoded = decode_mitmproxy_flows(args.capture_source, args.flow_index)
|
||||
if decoded["arc_rows"] and not decoded["rows"]:
|
||||
if decoded["mystery_box_rows"] and not decoded["rows"] and not decoded["arc_rows"]:
|
||||
rows = decoded["mystery_box_rows"]
|
||||
warnings = decoded["mystery_box_warnings"]
|
||||
kind = "mystery_box"
|
||||
best_run = decoded["best_mystery_box_run"]
|
||||
pair_count = len(decoded["mystery_box_pairs"])
|
||||
elif decoded["arc_rows"] and not decoded["rows"]:
|
||||
rows = decoded["arc_rows"]
|
||||
warnings = decoded["arc_warnings"]
|
||||
kind = "arc_miracle_box"
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ def print_live_instructions(local_ip: str, backend: str = "windows_raw", detail:
|
|||
print(style(" Monopoly > Standard Board history", CYAN))
|
||||
print(style(" Monopoly > Limited Character Board history", CYAN))
|
||||
print(style(" Gashapon > Arc Miracle Box history", CYAN))
|
||||
print(style(" Gashapon > Mystery Box history", CYAN))
|
||||
print(" 3. Start at page 1 and scroll down through every page")
|
||||
print(" you want exported.")
|
||||
print(" 4. Scroll one page past where you plan to stop so the")
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ LIMITED_CHARACTER_BANNER_ID = "Lottery_LimitedCharacter"
|
|||
LIMITED_CHARACTER_BANNER_NAME = "Limited Character Board"
|
||||
ARC_BANNER_ID = "Arc_MiracleBox"
|
||||
ARC_BANNER_NAME = "Arc Miracle Box"
|
||||
MYSTERY_BOX_BANNER_ID = "Gashapon_MysteryBox"
|
||||
MYSTERY_BOX_BANNER_NAME = "Mystery Box"
|
||||
EXPORTER_NAME = "nte-history-exporter"
|
||||
EXPORTER_VERSION = "0.1.10"
|
||||
EXPORTER_VERSION = "0.1.11"
|
||||
|
||||
HISTORY_REQUEST_BANNER = 4220
|
||||
HISTORY_REQUEST_LENGTH = 45
|
||||
|
|
@ -38,6 +40,12 @@ POOL_META = {
|
|||
"system": ARC_SYSTEM_NAME,
|
||||
"shared_pity": True,
|
||||
},
|
||||
"mystery_box": {
|
||||
"id": MYSTERY_BOX_BANNER_ID,
|
||||
"name": MYSTERY_BOX_BANNER_NAME,
|
||||
"system": ARC_SYSTEM_NAME,
|
||||
"shared_pity": False,
|
||||
},
|
||||
}
|
||||
|
||||
ARC_HISTORY_REQUEST_BANNER = 2060
|
||||
|
|
@ -47,6 +55,15 @@ ARC_HISTORY_PAGE_CURSOR_MULTIPLIER = 2
|
|||
ARC_RESPONSE_FIRST_RECORD_OFFSET = 0x4C
|
||||
ARC_TIMESTAMP_TICKS_PER_SECOND = 20_000_000
|
||||
|
||||
MYSTERY_BOX_HISTORY_REQUEST_LENGTH = 54
|
||||
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT_OFFSET = 26
|
||||
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT = 2060
|
||||
MYSTERY_BOX_HISTORY_CURSOR_OFFSET = 31
|
||||
MYSTERY_BOX_HISTORY_PAGE_CURSOR_MULTIPLIER = 2
|
||||
MYSTERY_BOX_HISTORY_REQUEST_KIND_OFFSET = 35
|
||||
MYSTERY_BOX_HISTORY_REQUEST_KIND = 2110
|
||||
MYSTERY_BOX_MARKER = b"FGashaponLotteryRecordData"
|
||||
|
||||
TIMESTAMP_TICKS_PER_SECOND = 40_000_000
|
||||
DOTNET_UNIX_EPOCH_SECONDS = 62_135_596_800
|
||||
|
||||
|
|
|
|||
208
src/nte_history_exporter/decoder/mystery_box.py
Normal file
208
src/nte_history_exporter/decoder/mystery_box.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
from nte_history_exporter.constants import (
|
||||
ARC_SYSTEM,
|
||||
DOTNET_UNIX_EPOCH_SECONDS,
|
||||
GAME_UID_PART,
|
||||
MYSTERY_BOX_BANNER_ID,
|
||||
MYSTERY_BOX_HISTORY_CURSOR_OFFSET,
|
||||
MYSTERY_BOX_HISTORY_PAGE_CURSOR_MULTIPLIER,
|
||||
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT,
|
||||
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT_OFFSET,
|
||||
MYSTERY_BOX_HISTORY_REQUEST_KIND,
|
||||
MYSTERY_BOX_HISTORY_REQUEST_KIND_OFFSET,
|
||||
MYSTERY_BOX_HISTORY_REQUEST_LENGTH,
|
||||
MYSTERY_BOX_MARKER,
|
||||
POOL_META,
|
||||
)
|
||||
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1
|
||||
from nte_history_exporter.decoder.protocol import infer_reward_type
|
||||
from nte_history_exporter.decoder.run import fmt_packet_time
|
||||
from nte_history_exporter.mappings import REWARDS_BY_ID
|
||||
|
||||
DOTNET_TICKS_PER_SECOND = 10_000_000
|
||||
MAX_RECORDS_PER_BLOCK = 100
|
||||
MAX_REWARD_ID_LENGTH = 256
|
||||
REWARDS_BY_CASEFOLDED_ID = {
|
||||
reward_id.casefold(): reward for reward_id, reward in REWARDS_BY_ID.items()
|
||||
}
|
||||
|
||||
|
||||
def is_mystery_box_history_request(content: bytes) -> bool:
|
||||
if len(content) < MYSTERY_BOX_HISTORY_REQUEST_LENGTH:
|
||||
return False
|
||||
return (
|
||||
struct.unpack_from("<I", content, MYSTERY_BOX_HISTORY_REQUEST_CONSTANT_OFFSET)[0]
|
||||
== MYSTERY_BOX_HISTORY_REQUEST_CONSTANT
|
||||
and struct.unpack_from("<I", content, MYSTERY_BOX_HISTORY_REQUEST_KIND_OFFSET)[0]
|
||||
== MYSTERY_BOX_HISTORY_REQUEST_KIND
|
||||
)
|
||||
|
||||
|
||||
def mystery_box_request_page(content: bytes) -> int:
|
||||
cursor = struct.unpack_from("<I", content, MYSTERY_BOX_HISTORY_CURSOR_OFFSET)[0]
|
||||
return cursor // MYSTERY_BOX_HISTORY_PAGE_CURSOR_MULTIPLIER
|
||||
|
||||
|
||||
def _protocol_views(payload: bytes) -> Iterator[bytes]:
|
||||
yield payload
|
||||
# History payloads can begin at a non-byte-aligned transport position.
|
||||
for bit_shift in range(1, 8):
|
||||
shifted = bytearray()
|
||||
for index in range(max(0, len(payload) - 8)):
|
||||
bit_pos = (8 + index) * 8 + bit_shift
|
||||
byte_pos, shift = divmod(bit_pos, 8)
|
||||
if byte_pos >= len(payload):
|
||||
break
|
||||
value = payload[byte_pos] >> shift
|
||||
if shift and byte_pos + 1 < len(payload):
|
||||
value |= payload[byte_pos + 1] << (8 - shift)
|
||||
shifted.append(value & 0xFF)
|
||||
yield bytes(shifted)
|
||||
|
||||
|
||||
def _decode_timestamp(raw: bytes) -> tuple[int, float, str]:
|
||||
ticks = struct.unpack("<Q", raw)[0]
|
||||
unix_seconds = ticks / DOTNET_TICKS_PER_SECOND - DOTNET_UNIX_EPOCH_SECONDS
|
||||
decoded = datetime.fromtimestamp(unix_seconds, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return ticks, unix_seconds, decoded
|
||||
|
||||
|
||||
def _parse_view(data: bytes) -> list[dict[str, Any]]:
|
||||
marker_pos = data.find(MYSTERY_BOX_MARKER)
|
||||
if marker_pos < 0:
|
||||
return []
|
||||
pos = marker_pos + len(MYSTERY_BOX_MARKER)
|
||||
if pos < len(data) and data[pos] == 0:
|
||||
pos += 1
|
||||
if pos + 12 > len(data):
|
||||
return []
|
||||
_reserved, _declared_size, row_count = struct.unpack_from("<III", data, pos)
|
||||
pos += 12
|
||||
if row_count > MAX_RECORDS_PER_BLOCK:
|
||||
return []
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for _ in range(row_count):
|
||||
record_start = pos
|
||||
reward_length = struct.unpack_from("<I", data, pos)[0]
|
||||
pos += 4
|
||||
if not 1 <= reward_length <= MAX_REWARD_ID_LENGTH or pos + reward_length + 13 > len(data):
|
||||
return []
|
||||
reward_raw = data[pos : pos + reward_length]
|
||||
pos += reward_length
|
||||
if not reward_raw.endswith(b"\0"):
|
||||
return []
|
||||
reward_id = reward_raw[:-1].decode("utf-8")
|
||||
quantity = struct.unpack_from("<I", data, pos)[0]
|
||||
pos += 4
|
||||
record_flag = data[pos]
|
||||
pos += 1
|
||||
timestamp_raw = data[pos : pos + 8]
|
||||
pos += 8
|
||||
ticks, unix_seconds, timestamp_decoded = _decode_timestamp(timestamp_raw)
|
||||
reward = REWARDS_BY_ID.get(reward_id) or REWARDS_BY_CASEFOLDED_ID.get(
|
||||
reward_id.casefold(), {}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"record_start": record_start,
|
||||
"record_end": pos,
|
||||
"record_len": pos - record_start,
|
||||
"reward_type": reward.get("type") or infer_reward_type(reward_id),
|
||||
"reward_id": reward_id,
|
||||
"reward_name": reward.get("name", ""),
|
||||
"reward_rank": reward.get("rank"),
|
||||
"quantity": quantity,
|
||||
"source_type": "mystery_box",
|
||||
"result_type": "single_pull",
|
||||
"timestamp_raw_hex": timestamp_raw.hex(),
|
||||
"timestamp_ticks": ticks,
|
||||
"timestamp_unix": unix_seconds,
|
||||
"timestamp_decoded": timestamp_decoded,
|
||||
"record_flag": record_flag,
|
||||
"record_hex": data[record_start:pos].hex(),
|
||||
"decoder_mode": "structured",
|
||||
}
|
||||
)
|
||||
except (OSError, OverflowError, UnicodeDecodeError, ValueError, struct.error):
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
def parse_mystery_box_response(payload: bytes) -> list[dict[str, Any]]:
|
||||
for view in _protocol_views(payload):
|
||||
rows = _parse_view(view)
|
||||
if rows:
|
||||
return rows
|
||||
return []
|
||||
|
||||
|
||||
def make_mystery_box_uid(timestamp_raw: str, ordinal: int) -> str:
|
||||
source = "|".join(
|
||||
[GAME_UID_PART, ARC_SYSTEM, MYSTERY_BOX_BANNER_ID, timestamp_raw, str(ordinal)]
|
||||
)
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def annotate_mystery_box_rows(rows: list[dict[str, Any]]) -> None:
|
||||
groups: dict[str, list[int]] = defaultdict(list)
|
||||
for index, row in enumerate(rows):
|
||||
groups[row["timestamp_raw_hex"]].append(index)
|
||||
for group_index, (timestamp_raw, indexes) in enumerate(groups.items()):
|
||||
for ordinal, index in enumerate(indexes):
|
||||
row = rows[index]
|
||||
row["timestamp_group_index"] = group_index
|
||||
row["timestamp_group_ordinal"] = ordinal
|
||||
row["timestamp_group_size_seen"] = len(indexes)
|
||||
row["timestamp_group_record_size_seen"] = len(indexes)
|
||||
row["timestamp_group_boundary"] = (
|
||||
"oldest" if group_index == len(groups) - 1 else ("newest" if group_index == 0 else "")
|
||||
)
|
||||
row["uid"] = make_mystery_box_uid(timestamp_raw, ordinal)
|
||||
row["uid_status"] = "stable"
|
||||
row["export_record"] = True
|
||||
row["skip_reason"] = ""
|
||||
|
||||
|
||||
def build_mystery_box_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
pool = POOL_META["mystery_box"]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for pair in pairs:
|
||||
page, offset, req_i, req_ts, resp_i, resp_ts, response = pair[:7]
|
||||
records = parse_mystery_box_response(response)
|
||||
if len(pair) > 9:
|
||||
slice_start, slice_count = pair[8:10]
|
||||
records = records[slice_start : slice_start + slice_count]
|
||||
for row_index, record in enumerate(records, start=1):
|
||||
rows.append(
|
||||
{
|
||||
**record,
|
||||
"page": page,
|
||||
"offset": offset,
|
||||
"row": row_index,
|
||||
"pool_group_id": pool["id"],
|
||||
"pool_group_name": pool["name"],
|
||||
"request_msg": req_i,
|
||||
"request_time_utc": fmt_packet_time(req_ts),
|
||||
"response_msg": resp_i,
|
||||
"response_time_utc": fmt_packet_time(resp_ts),
|
||||
"response_len": len(response),
|
||||
"record_count": len(records),
|
||||
}
|
||||
)
|
||||
annotate_mystery_box_rows(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def select_continuous_mystery_box_run(
|
||||
pairs: list[tuple],
|
||||
) -> tuple[list[tuple], list[dict[str, Any]]]:
|
||||
return select_continuous_run_from_page_1(pairs)
|
||||
|
|
@ -58,6 +58,7 @@ FIELDNAMES = [
|
|||
"record_len",
|
||||
"dice_offset_in_record",
|
||||
"record_hex",
|
||||
"record_flag",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from nte_history_exporter.constants import (
|
|||
EXPORTER_NAME,
|
||||
GAME_NAME,
|
||||
POOL_META,
|
||||
MYSTERY_BOX_BANNER_ID,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -83,6 +84,21 @@ def _record_for_export(row: dict[str, Any]) -> dict[str, Any]:
|
|||
"source_type": row.get("source_type"),
|
||||
}
|
||||
|
||||
if row.get("pool_group_id") == MYSTERY_BOX_BANNER_ID:
|
||||
return {
|
||||
"uid": row.get("uid"),
|
||||
"pool_group_id": row.get("pool_group_id"),
|
||||
"timestamp": row.get("timestamp_decoded"),
|
||||
"timestamp_group_ordinal": row.get("timestamp_group_ordinal"),
|
||||
"result_type": "single_pull",
|
||||
"reward_type": row.get("reward_type"),
|
||||
"reward_id": row.get("reward_id"),
|
||||
"reward_name": row.get("reward_name"),
|
||||
"reward_rank": row.get("reward_rank"),
|
||||
"quantity": row.get("quantity"),
|
||||
"source_type": row.get("source_type"),
|
||||
}
|
||||
|
||||
return {
|
||||
"uid": row.get("uid"),
|
||||
"pool_group_id": row.get("pool_group_id", BANNER_ID),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ EXPORT_PREFIXES = {
|
|||
"permanent": "Permanent",
|
||||
"limited_character": "Limited",
|
||||
"arc_miracle_box": "Arc",
|
||||
"mystery_box": "MysteryBox",
|
||||
}
|
||||
|
||||
CAPTURE_SOURCE_LABELS = {
|
||||
|
|
@ -148,7 +149,7 @@ def run_live_capture(
|
|||
pairs = session.pairs_for_kind(kind)
|
||||
best_run, run_warnings = select_continuous_run_from_page_1(pairs)
|
||||
rows = session.build_rows(kind)
|
||||
if kind != "arc_miracle_box":
|
||||
if kind not in {"arc_miracle_box", "mystery_box"}:
|
||||
rows = annotate_groups(rows)
|
||||
warnings = run_warnings
|
||||
pages_seen = [p[0] for p in best_run]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,15 @@ from nte_history_exporter.decoder.protocol import (
|
|||
response_contains_history_marker,
|
||||
)
|
||||
from nte_history_exporter.decoder.run import build_rows_from_pairs
|
||||
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,
|
||||
select_continuous_mystery_box_run,
|
||||
)
|
||||
from nte_history_exporter.decoder.structured_protocol import FORK_MARKER, MONOPOLY_MARKER
|
||||
from nte_history_exporter.constants import MYSTERY_BOX_MARKER
|
||||
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
|
||||
from nte_history_exporter.live_capture.diagnostics import CaptureDiagnostics
|
||||
|
||||
|
|
@ -165,6 +173,30 @@ class LiveHistorySession:
|
|||
self.last_page_seen = req.page
|
||||
return False
|
||||
|
||||
if packet.src_ip == self.local_ip and is_mystery_box_history_request(packet.payload):
|
||||
page = mystery_box_request_page(packet.payload)
|
||||
req = PendingRequest(
|
||||
page=page,
|
||||
offset=page * 2,
|
||||
kind="mystery_box",
|
||||
request_msg=self.packet_count,
|
||||
request_time=packet.timestamp,
|
||||
src_ip=packet.src_ip,
|
||||
dst_ip=packet.dst_ip,
|
||||
src_port=packet.src_port,
|
||||
dst_port=packet.dst_port,
|
||||
)
|
||||
self._queue_request(req)
|
||||
self.diagnostics.counters["history_requests_recognized"] += 1
|
||||
self.diagnostics.add_event(
|
||||
"HISTORY_REQUEST_RECOGNIZED",
|
||||
self.packet_count,
|
||||
kind=req.kind,
|
||||
page=req.page,
|
||||
)
|
||||
self.last_page_seen = req.page
|
||||
return False
|
||||
|
||||
if packet.dst_ip != self.local_ip:
|
||||
return False
|
||||
|
||||
|
|
@ -191,12 +223,24 @@ class LiveHistorySession:
|
|||
|
||||
monopoly_records = decode_response_records(packet.payload)
|
||||
arc_records = parse_arc_response(packet.payload) if not monopoly_records else []
|
||||
mystery_box_records = (
|
||||
parse_mystery_box_response(packet.payload)
|
||||
if not monopoly_records and not arc_records
|
||||
else []
|
||||
)
|
||||
if monopoly_records:
|
||||
candidates = [req for req in connection_candidates if req.kind != "arc_miracle_box"]
|
||||
candidates = [
|
||||
req
|
||||
for req in connection_candidates
|
||||
if req.kind not in {"arc_miracle_box", "mystery_box"}
|
||||
]
|
||||
records = monopoly_records
|
||||
elif arc_records:
|
||||
candidates = [req for req in connection_candidates if req.kind == "arc_miracle_box"]
|
||||
records = arc_records
|
||||
elif mystery_box_records:
|
||||
candidates = [req for req in connection_candidates if req.kind == "mystery_box"]
|
||||
records = mystery_box_records
|
||||
else:
|
||||
candidates = connection_candidates
|
||||
records = []
|
||||
|
|
@ -215,6 +259,7 @@ class LiveHistorySession:
|
|||
if response_contains_history_marker(packet.payload)
|
||||
or MONOPOLY_MARKER in packet.payload
|
||||
or FORK_MARKER in packet.payload
|
||||
or MYSTERY_BOX_MARKER in packet.payload
|
||||
else "NO_HISTORY_MARKER"
|
||||
)
|
||||
self._record_rejected_candidate(candidates, packet.payload, reason)
|
||||
|
|
@ -340,6 +385,9 @@ class LiveHistorySession:
|
|||
if kind == "arc_miracle_box":
|
||||
best_run, _warnings = select_continuous_arc_run(self.pairs_for_kind(kind))
|
||||
return build_arc_rows_from_pairs(best_run)
|
||||
if kind == "mystery_box":
|
||||
best_run, _warnings = select_continuous_mystery_box_run(self.pairs_for_kind(kind))
|
||||
return build_mystery_box_rows_from_pairs(best_run)
|
||||
return build_rows_from_pairs(self.best_run(kind))
|
||||
|
||||
def best_run(self, kind: str | None = None) -> list[tuple]:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ ASSET_TABLES = {
|
|||
"capital_inventory": "DataTable/Inventory/DT_CapitalItemConfig.json",
|
||||
"appearances": "DataTable/Character/Appearance/DT_AppearanceData.json",
|
||||
"illustrations": "DataTable/Gacha/GachaIllustrate.json",
|
||||
"mystery_box_pools": "DataTable/GashaponLottery/DT_GashaponLotteryGlobal.json",
|
||||
"vehicle_inventory": "DataTable/Vehicle/DT_VehicleItemData.json",
|
||||
"localization": "Localization/en/game.json",
|
||||
}
|
||||
REWARD_MAPPING_FILES = ("arcs.json", "characters.json", "items.json")
|
||||
|
|
@ -282,27 +284,60 @@ def _build_item_mapping(
|
|||
inventory = _casefold_index(tables["inventory"], "inventory")
|
||||
capital = _casefold_index(tables["capital_inventory"], "capital inventory")
|
||||
appearances = _casefold_index(tables["appearances"], "appearances")
|
||||
vehicle_inventory = _casefold_index(tables["vehicle_inventory"], "vehicle inventory")
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for illustrated_id in tables["illustrations"]:
|
||||
folded = illustrated_id.casefold()
|
||||
if illustrated_id.isdigit() or folded.startswith("fork_") or folded.startswith("characterawaken_"):
|
||||
candidate_ids = _reward_candidate_ids(tables)
|
||||
for candidate_id in candidate_ids:
|
||||
folded = candidate_id.casefold()
|
||||
if candidate_id.isdigit() or folded.startswith("fork_") or folded.startswith("characterawaken_"):
|
||||
continue
|
||||
sources = (inventory, capital, appearances)
|
||||
sources = (inventory, capital, appearances, vehicle_inventory)
|
||||
match = next((source.get(folded) for source in sources if folded in source), None)
|
||||
if match is None:
|
||||
raise MappingUpdateError(f"illustrated reward {illustrated_id} is missing from item tables")
|
||||
raise MappingUpdateError(f"reward {candidate_id} is missing from item tables")
|
||||
canonical_id, row = match
|
||||
if folded.startswith("fashion_glide_") and folded in appearances:
|
||||
canonical_id = appearances[folded][0]
|
||||
meta = _normalise_row(canonical_id, row, translations)
|
||||
result[canonical_id] = {
|
||||
"type": "cosmetic" if canonical_id.casefold().startswith("fashion_") else "item",
|
||||
"type": _item_mapping_type(canonical_id),
|
||||
"name": meta["name"],
|
||||
"rank": meta["rank"],
|
||||
}
|
||||
return dict(sorted(result.items(), key=lambda pair: (pair[0].casefold(), pair[0])))
|
||||
|
||||
|
||||
def _reward_candidate_ids(tables: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""Return rewards used by ordinary Gacha and every Mystery Box rotation."""
|
||||
candidates: dict[str, str] = {
|
||||
item_id.casefold(): item_id for item_id in tables["illustrations"]
|
||||
}
|
||||
for pool_id, pool in tables["mystery_box_pools"].items():
|
||||
if not isinstance(pool, dict):
|
||||
raise MappingUpdateError(f"Mystery Box pool {pool_id} must be an object")
|
||||
gifts = pool.get("GiftList")
|
||||
if not isinstance(gifts, list):
|
||||
raise MappingUpdateError(f"Mystery Box pool {pool_id} has no GiftList")
|
||||
for index, gift in enumerate(gifts):
|
||||
if not isinstance(gift, dict):
|
||||
raise MappingUpdateError(
|
||||
f"Mystery Box pool {pool_id} gift {index} must be an object"
|
||||
)
|
||||
item_id = gift.get("ItemID")
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
raise MappingUpdateError(
|
||||
f"Mystery Box pool {pool_id} gift {index} has no ItemID"
|
||||
)
|
||||
candidates.setdefault(item_id.casefold(), item_id)
|
||||
return sorted(candidates.values(), key=lambda value: (value.casefold(), value))
|
||||
|
||||
|
||||
def _item_mapping_type(item_id: str) -> str:
|
||||
folded = item_id.casefold()
|
||||
cosmetic_prefixes = ("fashion_", "frame_", "bussinesscard_")
|
||||
return "cosmetic" if folded.startswith(cosmetic_prefixes) else "item"
|
||||
|
||||
|
||||
def _casefold_index(rows: dict[str, Any], label: str) -> dict[str, tuple[str, Any]]:
|
||||
result: dict[str, tuple[str, Any]] = {}
|
||||
for item_id, row in rows.items():
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ POOL_MAPPING_FILES = {
|
|||
"permanent": "permanent_board.json",
|
||||
"limited_character": "limited_character_board.json",
|
||||
"arc_miracle_box": "arc_miracle_box.json",
|
||||
"mystery_box": "mystery_box.json",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue