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..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 @@ -181,6 +181,48 @@ 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} + # 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]) + def test_live_snapshot_preserves_damage_and_control_parity(self): with tempfile.TemporaryDirectory() as root: engine = HeadlessEngine(data_dir=root)