From a48da863ea190bc73c94751ba186103e1f293761 Mon Sep 17 00:00:00 2001 From: Golumpa Date: Thu, 30 Jul 2026 17:10:33 +0100 Subject: [PATCH 1/2] Reconcile page-split timestamps for ten-pull transactions --- src/nte_history_exporter/decoder/arc.py | 20 +++++++ src/nte_history_exporter/decoder/boundary.py | 55 ++++++++++++++++++++ tests/test_arc_decoding.py | 17 ++++++ tests/test_uid_compatibility.py | 21 ++++++++ 4 files changed, 113 insertions(+) diff --git a/src/nte_history_exporter/decoder/arc.py b/src/nte_history_exporter/decoder/arc.py index b42b1e8..bc5ed10 100644 --- a/src/nte_history_exporter/decoder/arc.py +++ b/src/nte_history_exporter/decoder/arc.py @@ -266,6 +266,26 @@ def annotate_arc_groups(rows: list[dict[str, Any]]) -> None: # Pages are anchored at page 1, so ordinal 0 of every group is captured and # all UIDs are stable -- even a partially captured oldest 10-pull, whose # unseen rows can only append after the captured ones. All rows are exported. + # Arc transactions are always ten records. A transaction can cross response + # pages whose raw timestamps occasionally differ while displaying the same + # second. Re-key each consecutive batch to its first timestamp before UIDs + # are assigned, preserving prefix UIDs and preventing an ordinal restart. + run_start = 0 + while run_start < len(rows): + displayed = rows[run_start].get("timestamp_decoded") + run_end = run_start + 1 + while run_end < len(rows) and rows[run_end].get("timestamp_decoded") == displayed: + run_end += 1 + for batch_start in range(run_start, run_end, 10): + batch = rows[batch_start : min(batch_start + 10, run_end)] + canonical = batch[0].get("timestamp_raw_hex", "") + for row in batch: + original = row.get("timestamp_raw_hex", "") + if original != canonical: + row["timestamp_reconciled_from_raw_hex"] = original + row["timestamp_raw_hex"] = canonical + run_start = run_end + groups: dict[str, list[int]] = defaultdict(list) for index, row in enumerate(rows): groups[row["timestamp_raw_hex"]].append(index) diff --git a/src/nte_history_exporter/decoder/boundary.py b/src/nte_history_exporter/decoder/boundary.py index 4e893b7..6c05556 100644 --- a/src/nte_history_exporter/decoder/boundary.py +++ b/src/nte_history_exporter/decoder/boundary.py @@ -97,6 +97,59 @@ def is_dice_record(row: dict[str, Any]) -> bool: return False +def reconcile_monopoly_page_split_timestamps(rows: list[dict[str, Any]]) -> None: + """Join timestamp fragments only when they form a page-split 10-pull. + + Monopoly also supports singles, so equal display timestamps alone are not a + sufficient reason to join groups. A reconciliation is allowed only when + adjacent raw-timestamp groups cross a history-page boundary and contain ten + dice results in total. Ancillary rewards travel with their timestamp group + but do not count toward the ten pulls. + """ + groups: list[list[dict[str, Any]]] = [] + for row in rows: + raw = row.get("timestamp_raw_hex", "") + if groups and groups[-1][0].get("timestamp_raw_hex", "") == raw: + groups[-1].append(row) + else: + groups.append([row]) + + index = 0 + while index < len(groups): + first = groups[index] + first_dice = sum(is_dice_record(row) for row in first) + if not 0 < first_dice < 10: + index += 1 + continue + + displayed = first[0].get("timestamp_decoded") + pages = {row.get("page") for row in first} + dice_count = first_dice + end = index + 1 + while end < len(groups) and groups[end][0].get("timestamp_decoded") == displayed: + candidate = groups[end] + candidate_dice = sum(is_dice_record(row) for row in candidate) + if dice_count + candidate_dice > 10: + break + dice_count += candidate_dice + pages.update(row.get("page") for row in candidate) + end += 1 + if dice_count == 10: + break + + if dice_count == 10 and len(pages) > 1 and end > index + 1: + canonical = first[0].get("timestamp_raw_hex", "") + for group in groups[index:end]: + for row in group: + original = row.get("timestamp_raw_hex", "") + if original != canonical: + row["timestamp_reconciled_from_raw_hex"] = original + row["timestamp_raw_hex"] = canonical + index = end + else: + index += 1 + + def annotate_groups(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: """Assign timestamp-group ordinals and stable UIDs to every decoded row. @@ -109,6 +162,8 @@ def annotate_groups(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: if not rows: return rows + reconcile_monopoly_page_split_timestamps(rows) + groups: list[list[dict[str, Any]]] = [] current: list[dict[str, Any]] = [] prev_ts = None diff --git a/tests/test_arc_decoding.py b/tests/test_arc_decoding.py index 4a422d9..2d05040 100644 --- a/tests/test_arc_decoding.py +++ b/tests/test_arc_decoding.py @@ -1,7 +1,24 @@ from tests.support import * # noqa: F401,F403 +from nte_history_exporter.decoder.arc import annotate_arc_groups class ArcDecodingTests(unittest.TestCase): + def test_page_split_timestamp_variants_form_one_ten_pull(self): + rows = [ + { + "page": 1 if index < 5 else 2, + "timestamp_raw_hex": "aa" if index < 5 else "bb", + "timestamp_decoded": "2026-07-11 07:21:22", + } + for index in range(10) + ] + + annotate_arc_groups(rows) + + self.assertEqual([row["timestamp_group_ordinal"] for row in rows], list(range(10))) + self.assertEqual({row["timestamp_raw_hex"] for row in rows}, {"aa"}) + self.assertEqual(rows[5]["timestamp_reconciled_from_raw_hex"], "bb") + def test_arc_key_timestamp_and_uid_match_fixture(self): fixture = load_network_fixture() row = fixture_session().build_rows("arc_miracle_box")[0] diff --git a/tests/test_uid_compatibility.py b/tests/test_uid_compatibility.py index ed7d3af..a82451e 100644 --- a/tests/test_uid_compatibility.py +++ b/tests/test_uid_compatibility.py @@ -2,6 +2,27 @@ from tests.support import * # noqa: F401,F403 class UidCompatibilityTests(unittest.TestCase): + def test_monopoly_page_split_timestamp_variants_complete_ten_pull(self): + rows = [self._synthetic_row(1, "aa", "dice") for _ in range(5)] + rows += [self._synthetic_row(2, "bb", "dice") for _ in range(5)] + for row in rows: + row["timestamp_decoded"] = "2026-07-11 07:21:22" + + annotated = annotate_groups(rows) + + self.assertEqual([row["timestamp_group_ordinal"] for row in annotated], list(range(10))) + self.assertEqual({row["timestamp_raw_hex"] for row in annotated}, {"aa"}) + + def test_monopoly_singles_with_same_second_remain_separate(self): + rows = [self._synthetic_row(1, "aa", "dice"), self._synthetic_row(2, "bb", "dice")] + for row in rows: + row["timestamp_decoded"] = "2026-07-11 07:21:22" + + annotated = annotate_groups(rows) + + self.assertEqual([row["timestamp_group_ordinal"] for row in annotated], [0, 0]) + self.assertEqual([row["timestamp_raw_hex"] for row in annotated], ["aa", "bb"]) + def test_uid_source_matches_committed_network_fixture(self): fixture = load_network_fixture() rows = annotate_groups(fixture_session().build_rows("permanent")) From e44c5eecbfbf9165285148cfd40d489aa65d0cb6 Mon Sep 17 00:00:00 2001 From: Golumpa Date: Thu, 30 Jul 2026 17:11:38 +0100 Subject: [PATCH 2/2] Bump version to 0.2.1 --- docs/export-format.md | 2 +- pyproject.toml | 2 +- src/nte_history_exporter/__init__.py | 2 +- src/nte_history_exporter/constants.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/export-format.md b/docs/export-format.md index 863442f..6004738 100644 --- a/docs/export-format.md +++ b/docs/export-format.md @@ -15,7 +15,7 @@ decoder-only offsets. "capture_source": "npcap", "exporter": { "name": "nte-history-exporter", - "version": "0.2.0" + "version": "0.2.1" }, "banner": { "id": "Lottery_Permanent", diff --git a/pyproject.toml b/pyproject.toml index 39e30a6..c5c4a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nte-history-exporter" -version = "0.2.0" +version = "0.2.1" description = "Cross-platform Neverness to Everness pull-history exporter." requires-python = ">=3.10" dependencies = [] diff --git a/src/nte_history_exporter/__init__.py b/src/nte_history_exporter/__init__.py index d3ec452..3ced358 100644 --- a/src/nte_history_exporter/__init__.py +++ b/src/nte_history_exporter/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/nte_history_exporter/constants.py b/src/nte_history_exporter/constants.py index f5afd5b..883f920 100644 --- a/src/nte_history_exporter/constants.py +++ b/src/nte_history_exporter/constants.py @@ -13,7 +13,7 @@ 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.2.0" +EXPORTER_VERSION = "0.2.1" HISTORY_REQUEST_BANNER = 4220 HISTORY_REQUEST_LENGTH = 45