From 76b225bea2c82512d72626bb772c2ec8dc32b558 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:01:27 -0400 Subject: [PATCH 1/2] Keep every raid kill awaiting a difficulty A single pending slot silently discarded the first kill when two raid targets died before a difficulty was confirmed, which is exactly when a raid confirms several at once: the second kill overwrote the first, and only the second was ever credited. This fix existed on this fork before the upstream sync and was parked during the PR #72 merge, because upstream had meanwhile extended the same slot with the kill's occurred_at, zone, character and evidence. Reverting to the old target -> seconds map would have dropped that context, so the kills are now held as an ordered target -> PendingRaidKill map that carries all of it. Confirming a difficulty credits every waiting kill with its own context, not whichever fight happened to be current when the confirmation arrived. The ordering keeps protocol v1 honest: pendingRaidTarget still names the first kill still awaiting confirmation, exactly as it always did, and pendingRaidTargets is added beside it for renderers that want the full list. The test kills two raid targets ten minutes apart and asserts each is credited with its own kill time and evidence. Verified to fail when the pending kills share one context, which is the bug being fixed. Co-Authored-By: Claude Opus 5 (1M context) --- loremaster-desktop/src/App.tsx | 2 +- loremaster-desktop/src/protocol.ts | 2 + loremaster/desktop_worker.py | 71 +++++++++++++++---------- loremaster/tests/test_desktop_worker.py | 39 ++++++++++++++ 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/loremaster-desktop/src/App.tsx b/loremaster-desktop/src/App.tsx index 8b091bf..4d6b6c8 100644 --- a/loremaster-desktop/src/App.tsx +++ b/loremaster-desktop/src/App.tsx @@ -1148,7 +1148,7 @@ function MainApp() { {weekly?.pendingRaidTarget &&
-
RAID BOSS DEFEATED{weekly.pendingRaidTarget}

Confirm the completed tier to mark this week’s lockout and preserve the clear time.

+
RAID BOSS DEFEATED{(weekly.pendingRaidTargets?.length ? weekly.pendingRaidTargets : [weekly.pendingRaidTarget]).join(" · ")}

Confirm the completed tier to mark {(weekly.pendingRaidTargets?.length ?? 1) > 1 ? "these lockouts" : "this week’s lockout"} and preserve the clear time.

{raidDifficulties.map((difficulty) => )}
} diff --git a/loremaster-desktop/src/protocol.ts b/loremaster-desktop/src/protocol.ts index 1720fc6..f5adcd1 100644 --- a/loremaster-desktop/src/protocol.ts +++ b/loremaster-desktop/src/protocol.ts @@ -266,6 +266,8 @@ export interface WeeklyProgressView { difficultySource?: "log-zone" | "manual" | "unknown" | string; raidContext?: RaidContextView | null; pendingRaidTarget?: string; + /** Every kill awaiting a difficulty. pendingRaidTarget names only the first. */ + pendingRaidTargets?: string[]; } export interface RaidContextView { diff --git a/loremaster/desktop_worker.py b/loremaster/desktop_worker.py index 855f934..81d9193 100644 --- a/loremaster/desktop_worker.py +++ b/loremaster/desktop_worker.py @@ -17,6 +17,7 @@ import threading import time from datetime import datetime, timedelta +from typing import NamedTuple from adventure_journal import (AdventureJournal, encounter_identity, evidence_hash) @@ -62,6 +63,21 @@ sys.stdout.reconfigure(encoding="utf-8", errors="strict") +class PendingRaidKill(NamedTuple): + """One raid kill waiting for its difficulty to be confirmed. + + Carries the whole context of the kill rather than the target alone, + because the confirmation can arrive minutes later, by which time the + session's zone, character and fight have all moved on. + """ + + seconds: float + occurred_at: datetime | None + zone: str + character: str + evidence: str + + class HeadlessEngine: """One deterministic parser session with replaceable log input.""" @@ -94,12 +110,10 @@ def __init__(self, *, log_path: str = "", data_dir: str | Path | None = None, self.raid_context = RaidContextTracker() self.raid_difficulty: int | None = None self.configured_composition = "" - self.pending_raid_target = "" - self.pending_raid_seconds = 0.0 - self.pending_raid_occurred_at: datetime | None = None - self.pending_raid_zone = "" - self.pending_raid_character = "" - self.pending_raid_evidence = "" + # A single slot silently discarded the first kill when two raid + # targets died before a difficulty was confirmed, which is exactly + # when a raid confirms several at once. + self.pending_raid_kills: dict[str, PendingRaidKill] = {} self.sequence = 0 self.stats = SessionStats() self.mez = MezTracker() @@ -150,12 +164,7 @@ def _disable_journal(self, error: BaseException | str) -> None: self._journal_cache_dirty = False def _clear_pending_raid_kill(self) -> None: - self.pending_raid_target = "" - self.pending_raid_seconds = 0.0 - self.pending_raid_occurred_at = None - self.pending_raid_zone = "" - self.pending_raid_character = "" - self.pending_raid_evidence = "" + self.pending_raid_kills.clear() def _ensure_fight_journal_identity(self, fight) -> str: encounter_id = str(getattr(fight, "journal_id", "") or "") @@ -236,15 +245,15 @@ def set_raid_difficulty(self, value: int | None) -> bool: if value is not None and value not in DIFFICULTIES: return False self.raid_difficulty = value - if value is not None and self.pending_raid_target: - self.weekly.observe_kill( - self.pending_raid_occurred_at or self.last_observed_at, - self.pending_raid_target, - zone=self.pending_raid_zone, - character=self.pending_raid_character, - difficulty=value, duration_seconds=self.pending_raid_seconds, - difficulty_source="manual-confirmation", - evidence=self.pending_raid_evidence) + if value is not None and self.pending_raid_kills: + for target, kill in list(self.pending_raid_kills.items()): + self.weekly.observe_kill( + kill.occurred_at or self.last_observed_at, target, + zone=kill.zone, + character=kill.character, + difficulty=value, duration_seconds=kill.seconds, + difficulty_source="manual-confirmation", + evidence=kill.evidence) self._clear_pending_raid_kill() return True @@ -622,13 +631,15 @@ def process_line(self, line: str) -> bool: if raid_target: difficulty = self._effective_raid_difficulty() if difficulty is None: - self.pending_raid_target = raid_target.name - self.pending_raid_seconds = ( - self.stats.fight.seconds if self.stats.fight else 0.0) - self.pending_raid_occurred_at = occurred_at - self.pending_raid_zone = self.stats.zone - self.pending_raid_character = self.stats.character - self.pending_raid_evidence = raw_message + self.pending_raid_kills.setdefault( + raid_target.name, + PendingRaidKill( + seconds=(self.stats.fight.seconds + if self.stats.fight else 0.0), + occurred_at=occurred_at, + zone=self.stats.zone, + character=self.stats.character, + evidence=raw_message)) else: self.weekly.observe_kill( occurred_at, raid_target.name, @@ -706,7 +717,9 @@ def snapshot_event(self, now: datetime | None = None) -> dict: "log-zone" if context is not None else "manual" if self.raid_difficulty is not None else "unknown") weekly["raidContext"] = self.raid_context.snapshot() - weekly["pendingRaidTarget"] = self.pending_raid_target + pending = list(self.pending_raid_kills) + weekly["pendingRaidTarget"] = pending[0] if pending else "" + weekly["pendingRaidTargets"] = pending event["snapshot"]["weekly"] = weekly self.alerts = [alert for alert in self.alerts if self._aware(datetime.fromisoformat(alert["expiresAt"])) diff --git a/loremaster/tests/test_desktop_worker.py b/loremaster/tests/test_desktop_worker.py index 4ec6364..91a271a 100644 --- a/loremaster/tests/test_desktop_worker.py +++ b/loremaster/tests/test_desktop_worker.py @@ -181,6 +181,45 @@ def test_auto_attack_state_crosses_desktop_boundary_exactly(self): self.assertTrue(enabled["snapshot"]["combat"]["autoAttack"]) self.assertFalse(disabled["snapshot"]["combat"]["autoAttack"]) + def test_every_raid_kill_awaiting_a_difficulty_is_kept(self): + """A second kill before confirmation must not displace the first.""" + with tempfile.TemporaryDirectory() as root: + engine = HeadlessEngine(data_dir=root) + try: + for line in ( + "[Fri Aug 07 20:00:00 2026] You slash Lord Nagafen for 10 points of damage.", + "[Fri Aug 07 20:00:01 2026] You have slain Lord Nagafen!", + "[Fri Aug 07 20:10:00 2026] You slash Lady Vox for 10 points of damage.", + "[Fri Aug 07 20:10:01 2026] You have slain Lady Vox!"): + engine.process_line(line) + pending = engine.snapshot_event( + datetime(2026, 8, 7, 20, 10, 2))["snapshot"]["weekly"] + self.assertEqual(pending["pendingRaidTargets"], + ["Lord Nagafen", "Lady Vox"]) + # The v1 field still names one target, so older renderers see + # what they always saw. + self.assertEqual(pending["pendingRaidTarget"], "Lord Nagafen") + self.assertTrue(engine.set_raid_difficulty(3)) + weekly = engine.snapshot_event( + datetime(2026, 8, 7, 20, 10, 3))["snapshot"]["weekly"] + finally: + engine.close() + self.assertEqual(weekly["pendingRaidTargets"], []) + recorded = {row["target"]: row["difficulties"] + for row in weekly["raids"]} + self.assertTrue(recorded["Lord Nagafen"][3]) + # Each kill keeps its own context. The confirmation arrives once, + # after both are dead, so a shared slot would credit both with + # whichever fight and zone happened to be current at the end. + kills = {kill.target: kill for kill in engine.weekly._kills} + self.assertEqual(kills["Lord Nagafen"].killed_at, + "2026-08-08T00:00:01Z") + self.assertEqual(kills["Lady Vox"].killed_at, + "2026-08-08T00:10:01Z") + self.assertIn("Lord Nagafen", kills["Lord Nagafen"].evidence) + self.assertIn("Lady Vox", kills["Lady Vox"].evidence) + self.assertTrue(recorded["Lady Vox"][3]) + def test_live_snapshot_preserves_damage_and_control_parity(self): with tempfile.TemporaryDirectory() as root: engine = HeadlessEngine(data_dir=root) From b737223b7f6daa13053237d264ea115f6db148e8 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:05:12 -0400 Subject: [PATCH 2/2] Assert the interval between the kills, not two absolute stamps The new test pinned both kill times as UTC strings, which only held in the timezone it was written in: the log lines carry no zone, so the stored instant follows whatever machine runs the suite. CI is UTC and this desktop is UTC-4, so the runner read 20:00:01Z where the test expected 00:00:01Z. It asserts the ten minutes between the two kills instead, which is the property that actually distinguishes per-kill context from a shared slot and holds in any zone. Verified passing under EDT, UTC and Asia/Tokyo, and still failing when the pending kills share one context. Co-Authored-By: Claude Opus 5 (1M context) --- loremaster/tests/test_desktop_worker.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/loremaster/tests/test_desktop_worker.py b/loremaster/tests/test_desktop_worker.py index 91a271a..2a7ba3a 100644 --- a/loremaster/tests/test_desktop_worker.py +++ b/loremaster/tests/test_desktop_worker.py @@ -1,7 +1,7 @@ import sys import tempfile import unittest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path @@ -212,10 +212,13 @@ def test_every_raid_kill_awaiting_a_difficulty_is_kept(self): # after both are dead, so a shared slot would credit both with # whichever fight and zone happened to be current at the end. kills = {kill.target: kill for kill in engine.weekly._kills} - self.assertEqual(kills["Lord Nagafen"].killed_at, - "2026-08-08T00:00:01Z") - self.assertEqual(kills["Lady Vox"].killed_at, - "2026-08-08T00:10:01Z") + # Asserted as the interval between the two kills rather than two + # absolute stamps: the log lines carry no zone, so the stored UTC + # instant depends on the timezone of whatever machine runs this. + stamps = {target: datetime.fromisoformat(kill.killed_at) + for target, kill in kills.items()} + self.assertEqual(stamps["Lady Vox"] - stamps["Lord Nagafen"], + timedelta(minutes=10)) self.assertIn("Lord Nagafen", kills["Lord Nagafen"].evidence) self.assertIn("Lady Vox", kills["Lady Vox"].evidence) self.assertTrue(recorded["Lady Vox"][3])