Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion loremaster-desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ function MainApp() {

{weekly?.pendingRaidTarget && <section className="raid-confirmation" role="alertdialog" aria-label="Confirm raid difficulty">
<span className="raid-confirmation-glyph">✓</span>
<div><small>RAID BOSS DEFEATED</small><strong>{weekly.pendingRaidTarget}</strong><p>Confirm the completed tier to mark this week’s lockout and preserve the clear time.</p></div>
<div><small>RAID BOSS DEFEATED</small><strong>{(weekly.pendingRaidTargets?.length ? weekly.pendingRaidTargets : [weekly.pendingRaidTarget]).join(" · ")}</strong><p>Confirm the completed tier to mark {(weekly.pendingRaidTargets?.length ?? 1) > 1 ? "these lockouts" : "this week’s lockout"} and preserve the clear time.</p></div>
<div className="raid-confirmation-tiers">{raidDifficulties.map((difficulty) => <button key={difficulty} type="button"
onClick={() => changeRaidDifficulty(difficulty)}>D{difficulty}</button>)}</div>
</section>}
Expand Down
2 changes: 2 additions & 0 deletions loremaster-desktop/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
71 changes: 42 additions & 29 deletions loremaster/desktop_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 "")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]))
Expand Down
44 changes: 43 additions & 1 deletion loremaster/tests/test_desktop_worker.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
Expand Down