From c106791eb526a92e8cce7cffe2f8d7446930fc4c Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Mon, 4 May 2026 22:16:57 +0300 Subject: [PATCH 01/17] Scaffold DDD/hexagonal package and domain layer (Phase A + B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A — scaffolding: - pyproject.toml (src-layout, py3.11+, ruff/mypy/pytest/import-linter/pydantic.mypy) - src/poe2_rpc/{domain,application,infrastructure,cli}/ skeleton + py.typed - tests/ skeleton with conftest fixtures for verbatim regex match lines - CI split into changes / lint-and-test (ubuntu) / build (windows) / release with dorny/paths-filter for build_relevant gating Phase B — domain layer (TDD, all 33/33 unit tests green, mypy strict clean, import-linter "Hexagonal layered architecture KEPT"): - domain/classes.py — CharacterClass + ClassAscendency (verbatim from main.py) - domain/models.py — frozen pydantic LevelInfo (ascension_class: str | None per ADR Behavior Change #1) + InstanceInfo - domain/locations.py — Location VO + LocationCatalog.resolve() ported from main.py:163-176 - domain/events.py — frozen DomainEvent hierarchy (GameStarted, GameStopped, CharacterLevelChanged, AreaEntered) - domain/ports.py — 6 runtime_checkable Protocols (GameDetector, LogStream, LogParser, PresencePublisher, EventBus, LocationCatalogPort) - tests/unit/test_layering.py + test_no_mutable_state.py — AST guards over src/poe2_rpc/domain/**/*.py main.py remains the runtime entrypoint; CI build job still gated on build-relevant paths until Phase E (CLI) and Phase F (PyInstaller) close. Consensus artifacts (.omc/specs, .omc/plans) checked in for traceability. Co-Authored-By: Claude Opus 4.7 --- .github/AGENTS.md | 21 + .github/ISSUE_TEMPLATE/AGENTS.md | 23 + .github/workflows/AGENTS.md | 35 ++ .github/workflows/build.yml | 208 ++++++--- .gitignore | 28 +- .omc/plans/open-questions.md | 8 + .omc/plans/ralplan-architecture-libraries.md | 388 ++++++++++++++++ .../specs/deep-dive-architecture-libraries.md | 416 ++++++++++++++++++ .../deep-dive-trace-architecture-libraries.md | 94 ++++ AGENTS.md | 128 ++++++ CLAUDE.md | 130 ++++++ docs/plans/autopilot-vivid-hare.md | 140 ++++++ pyproject.toml | 127 ++++++ src/poe2_rpc/__init__.py | 5 + src/poe2_rpc/__main__.py | 4 + src/poe2_rpc/__version__.py | 5 + src/poe2_rpc/application/__init__.py | 1 + src/poe2_rpc/cli/__init__.py | 0 src/poe2_rpc/domain/__init__.py | 1 + src/poe2_rpc/domain/classes.py | 85 ++++ src/poe2_rpc/domain/events.py | 26 ++ src/poe2_rpc/domain/locations.py | 33 ++ src/poe2_rpc/domain/models.py | 20 + src/poe2_rpc/domain/ports.py | 43 ++ src/poe2_rpc/infrastructure/__init__.py | 1 + src/poe2_rpc/py.typed | 0 tests/__init__.py | 0 tests/conftest.py | 23 + tests/integration/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/test_classes.py | 80 ++++ tests/unit/test_events.py | 50 +++ tests/unit/test_layering.py | 53 +++ tests/unit/test_locations.py | 50 +++ tests/unit/test_models.py | 63 +++ tests/unit/test_no_mutable_state.py | 77 ++++ tests/unit/test_ports.py | 81 ++++ tests/unit/test_smoke.py | 21 + 38 files changed, 2396 insertions(+), 72 deletions(-) create mode 100644 .github/AGENTS.md create mode 100644 .github/ISSUE_TEMPLATE/AGENTS.md create mode 100644 .github/workflows/AGENTS.md create mode 100644 .omc/plans/open-questions.md create mode 100644 .omc/plans/ralplan-architecture-libraries.md create mode 100644 .omc/specs/deep-dive-architecture-libraries.md create mode 100644 .omc/specs/deep-dive-trace-architecture-libraries.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/plans/autopilot-vivid-hare.md create mode 100644 pyproject.toml create mode 100644 src/poe2_rpc/__init__.py create mode 100644 src/poe2_rpc/__main__.py create mode 100644 src/poe2_rpc/__version__.py create mode 100644 src/poe2_rpc/application/__init__.py create mode 100644 src/poe2_rpc/cli/__init__.py create mode 100644 src/poe2_rpc/domain/__init__.py create mode 100644 src/poe2_rpc/domain/classes.py create mode 100644 src/poe2_rpc/domain/events.py create mode 100644 src/poe2_rpc/domain/locations.py create mode 100644 src/poe2_rpc/domain/models.py create mode 100644 src/poe2_rpc/domain/ports.py create mode 100644 src/poe2_rpc/infrastructure/__init__.py create mode 100644 src/poe2_rpc/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_classes.py create mode 100644 tests/unit/test_events.py create mode 100644 tests/unit/test_layering.py create mode 100644 tests/unit/test_locations.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_no_mutable_state.py create mode 100644 tests/unit/test_ports.py create mode 100644 tests/unit/test_smoke.py diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000..1317016 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,21 @@ + + + +# .github + +## Purpose +GitHub-specific configuration: the CI/CD pipeline that builds and releases the Windows `.exe`, and the issue templates surfaced in the repository's "New Issue" UI. + +## Subdirectories +| Directory | Purpose | +|-----------|---------| +| `workflows/` | GitHub Actions workflows (see `workflows/AGENTS.md`) | +| `ISSUE_TEMPLATE/` | Issue form templates (see `ISSUE_TEMPLATE/AGENTS.md`) | + +## For AI Agents + +### Working In This Directory +- Changes here only take effect when pushed to `main` (or merged via PR). +- The build workflow is path-filtered to `main.py` — adding new source files means updating both `paths:` in `build.yml` and the PyInstaller invocation. + + diff --git a/.github/ISSUE_TEMPLATE/AGENTS.md b/.github/ISSUE_TEMPLATE/AGENTS.md new file mode 100644 index 0000000..ad97b89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/AGENTS.md @@ -0,0 +1,23 @@ + + + +# ISSUE_TEMPLATE + +## Purpose +GitHub issue form templates surfaced when a user opens "New Issue" on the repository. + +## Key Files +| File | Description | +|------|-------------| +| `config.yml` | Sets `blank_issues_enabled: true` (users can still open a free-form issue alongside the templates). | +| `help-wanted.yml` | Form template titled `[HELP] ` with a game-version dropdown (Steam / Official / Epic), a "What happened?" textarea, and an optional log file textarea. Auto-assigns to `ezbooz` and applies the `help wanted` label. | + +## For AI Agents + +### Working In This Directory +- Files use GitHub's [Issue Forms YAML schema](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms). Validate locally before pushing — broken templates silently fall back to the blank issue UI. +- The default game-version dropdown index is `0` (Steam). If you reorder the options, update the `default:` field accordingly. +- Don't put log file uploads inside the form — GitHub Issue Forms can't accept attachments. The current template asks the user to attach manually after creation, which is the correct workaround. +- Auto-assignment to `ezbooz` is intentional (project owner). Don't reassign without coordinating. + + diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md new file mode 100644 index 0000000..8765acf --- /dev/null +++ b/.github/workflows/AGENTS.md @@ -0,0 +1,35 @@ + + + +# workflows + +## Purpose +GitHub Actions workflow definitions. Currently a single pipeline that builds the Windows executable and publishes a tagged GitHub Release. + +## Key Files +| File | Description | +|------|-------------| +| `build.yml` | Build-and-release pipeline. Triggers on push to `main` when `main.py` changes (or `workflow_dispatch`). Two jobs: `build` (PyInstaller `--onefile`, uploads artifact, creates timestamp tag `vYYYYMMDD-HHMMSS` and pushes it) and `release` (downloads artifact, creates GitHub Release, uploads `.exe` asset). | + +## For AI Agents + +### Working In This Directory +- The workflow runs on `windows-latest` because PyInstaller produces a native binary; do not switch to `ubuntu-latest`. +- The `paths:` filter is `main.py` — if the project ever splits into multiple source files, broaden the filter (e.g. `'**/*.py'`) or the build will silently stop running on relevant changes. +- The PyInstaller command (`pyinstaller --onefile --name PathOfExile2DiscordRPC main.py`) hardcodes the entrypoint and output name. Renaming `main.py` requires updating this line. +- The release uses the deprecated `actions/create-release@v1` and `actions/upload-release-asset@v1`. If migrating, prefer `softprops/action-gh-release` and verify the tag-creation step still pushes the tag before the release job runs (the `release` job depends on `needs.build.outputs.tag_name`). +- Tags are auto-generated from the build timestamp via PowerShell (`Get-Date -Format yyyyMMdd-HHmmss`). Don't switch to semver without also updating release tooling. +- `permissions: contents: write` is required for both pushing the tag and creating the release. + +### Testing Requirements +- Trigger via `workflow_dispatch` from the Actions tab to dry-run without a `main.py` commit. +- Verify the artifact `PathOfExile2DiscordRPC.exe` appears under both the workflow run's artifacts and the new GitHub Release. + +## Dependencies + +### External +- `actions/checkout@v4`, `actions/setup-python@v4`, `actions/upload-artifact@v4`, `actions/download-artifact@v4` +- `actions/create-release@v1`, `actions/upload-release-asset@v1` (deprecated; see note above) +- `pyinstaller` (installed at job runtime, not pinned) + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0b110a..805718c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,94 +5,160 @@ on: branches: - main paths: - - 'main.py' + - 'src/**' + - 'tests/**' + - 'pyproject.toml' + - 'locations.json' + - 'PathOfExile2DiscordRPC.spec' + - '.github/workflows/build.yml' workflow_dispatch: permissions: contents: write jobs: + changes: + runs-on: ubuntu-latest + outputs: + build_relevant: ${{ steps.filter.outputs.build_relevant }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + build_relevant: + - 'src/**' + - 'pyproject.toml' + - 'locations.json' + - 'PathOfExile2DiscordRPC.spec' + - '.github/workflows/build.yml' + + lint-and-test: + runs-on: ubuntu-latest + needs: changes + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Ruff lint + run: ruff check src tests + + - name: Ruff format check + run: ruff format --check src tests + + - name: Mypy strict typecheck + run: mypy --strict src/poe2_rpc + + - name: Import-linter (layered architecture) + run: lint-imports + + - name: Pytest unit + integration + run: pytest tests -ra + build: runs-on: windows-latest + needs: [changes, lint-and-test] + if: needs.changes.outputs.build_relevant == 'true' outputs: tag_name: ${{ steps.tag.outputs.tag }} release_body: ${{ steps.notes.outputs.body }} steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Build executable - run: pyinstaller --onefile --name PathOfExile2DiscordRPC main.py - - - name: Archive executable - uses: actions/upload-artifact@v4 - with: - name: executable - path: dist/PathOfExile2DiscordRPC.exe - - - name: Generate release notes - id: notes - run: | - echo "body=$(git log -1 --pretty=format:'%h - %s')" >> $GITHUB_OUTPUT - - - name: Create and push tag - id: tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $TAG = "v$(Get-Date -Format yyyyMMdd-HHmmss)" - echo "tag=$TAG" >> $env:GITHUB_OUTPUT - git config --global user.name "github-actions" - git config --global user.email "github-actions@github.com" - git remote set-url origin "https://x-access-token:$env:GITHUB_TOKEN@github.com/${{ github.repository }}.git" - git tag $TAG - git push origin $TAG + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Build executable + run: pyinstaller PathOfExile2DiscordRPC.spec + + - name: Smoke-test executable (validate-config --no-discord) + run: dist\PathOfExile2DiscordRPC.exe validate-config --no-discord + + - name: Cold-start benchmark (5x validate-config --no-discord) + run: pytest tests/integration/test_cold_start.py -ra + continue-on-error: true + + - name: Archive executable + uses: actions/upload-artifact@v4 + with: + name: executable + path: dist/PathOfExile2DiscordRPC.exe + + - name: Generate release notes + id: notes + shell: bash + run: | + echo "body=$(git log -1 --pretty=format:'%h - %s')" >> $GITHUB_OUTPUT + + - name: Create and push tag + id: tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $TAG = "v$(Get-Date -Format yyyyMMdd-HHmmss)" + echo "tag=$TAG" >> $env:GITHUB_OUTPUT + git config --global user.name "github-actions" + git config --global user.email "github-actions@github.com" + git remote set-url origin "https://x-access-token:$env:GITHUB_TOKEN@github.com/${{ github.repository }}.git" + git tag $TAG + git push origin $TAG release: needs: build runs-on: windows-latest + if: needs.build.outputs.tag_name != '' steps: - - name: Download executable - uses: actions/download-artifact@v4 - with: - name: executable - path: dist - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ needs.build.outputs.tag_name }} - release_name: Release ${{ needs.build.outputs.tag_name }} - body: ${{ needs.build.outputs.release_body }} - draft: false - prerelease: false - - - name: Upload Release Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: dist/PathOfExile2DiscordRPC.exe - asset_name: PathOfExile2DiscordRPC.exe - asset_content_type: application/octet-stream + - name: Download executable + uses: actions/download-artifact@v4 + with: + name: executable + path: dist + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ needs.build.outputs.tag_name }} + release_name: Release ${{ needs.build.outputs.tag_name }} + body: ${{ needs.build.outputs.release_body }} + draft: false + prerelease: false + + - name: Upload Release Asset + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: dist/PathOfExile2DiscordRPC.exe + asset_name: PathOfExile2DiscordRPC.exe + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore index a047a94..655affd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,28 @@ .idea/ -__pycache__/ \ No newline at end of file +__pycache__/ +.DS_Store + +# Build artifacts (PyInstaller + setuptools) +dist/ +build/ +*.egg-info/ +*.spec.bak + +# Tooling caches +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key + +# Local Claude Code config +.claude/ + +# OMC runtime artifacts (specs/plans are tracked separately) +.omc/sessions/ +.omc/state/ +.omc/project-memory.json +.omc/notepad.md diff --git a/.omc/plans/open-questions.md b/.omc/plans/open-questions.md new file mode 100644 index 0000000..864014a --- /dev/null +++ b/.omc/plans/open-questions.md @@ -0,0 +1,8 @@ +# Open Questions + +## ralplan-architecture-libraries — 2026-05-04 + +- [ ] **`LevelInfo.ascension_class` type — `str | None` vs `"Unknown"` literal?** — Plan assumes `None` for type-safety; presence-handler omits the `| {ascension}` segment when None. Current `main.py` uses the literal string `"Unknown"`. Confirm before closing task **B-2**. Matters because every downstream handler needs a single typed shape; mixing the two creates a runtime `if x == "Unknown"` smell. +- [ ] **Bundled `locations.json` location in source tree — `src/poe2_rpc/locations.json` or root-level + `[tool.setuptools.package-data]` mapping?** — Plan assumes the latter (single source of truth at repo root, mapped into the package via package-data). Confirm before closing task **F-2**. Matters because `importlib.resources.files("poe2_rpc")` resolution must work both in dev-install (`pip install -e .`) and inside the PyInstaller `.exe` bundle, and the wrong choice causes one of the two environments to silently miss the file. +- [ ] **`--debug-watchdog` CLI flag — keep in v1 or defer?** — Plan assumes keep, as PM-1 triage aid (prints every `FileSystemEvent`). Cost is small at task **E-1**. Confirm before closing E-1. Matters because if watchdog stalls in the wild (PM-1), users have no other lever to produce a useful issue report. +- [ ] **Live-game smoke run owner and timing for task G-4** — Who runs the .exe against a real PoE2 client + Discord, and at what point in the cutover (before or after `main.py` deletion in G-2)? Plan assumes G-4 follows G-3, but a pre-deletion smoke run would let us roll back without resurrecting `main.py` from git. Matters for de-risking cutover. diff --git a/.omc/plans/ralplan-architecture-libraries.md b/.omc/plans/ralplan-architecture-libraries.md new file mode 100644 index 0000000..fe9c925 --- /dev/null +++ b/.omc/plans/ralplan-architecture-libraries.md @@ -0,0 +1,388 @@ +# RALPLAN: Path-Of-Exile-2-RPC — Aggressive DDD / Hexagonal Migration + +**Mode:** consensus / DELIBERATE +**Source spec:** `.omc/specs/deep-dive-architecture-libraries.md` +**Target:** Migrate `main.py` (~330 LOC, single-file) to `src/poe2_rpc/` hexagonal package; ship as `PathOfExile2DiscordRPC.exe` via PyInstaller `--onefile`. +**Mandate:** Aggressive DDD already chosen; 4 priorities locked (latency, reliability, testability, observability). No conservative/balanced re-litigation. + +--- + +## 1. RALPLAN-DR Summary + +### 1.1 Principles (design constants for the migration) + +1. **Domain purity over framework convenience.** `domain/` imports nothing from `infrastructure/`, no I/O, no `pypresence`/`watchdog`/`psutil`. Ports are `typing.Protocol`, not ABCs. This is the load-bearing rule — every other choice serves it. +2. **Frozen-by-default, immutable VOs.** All domain models are `pydantic.BaseModel(model_config=ConfigDict(frozen=True))` or `Enum`. Dict-based "state bags" (`current_status`, `level_info`) are eradicated; equality replaces ad-hoc dict comparison. +3. **Log streaming is event-driven via watchdog (Windows ReadDirectoryChangesW). Process detection (`PsutilGameDetector`) is allowed to poll psutil at 3s interval — this is an explicit narrow exception because there is no Win32 process-creation event API equivalent for non-elevated processes.** If the watchdog observer fails to start, we **fail loudly** (`RuntimeError`) — there is no silent fallback to polling for log streaming, because an undetected polling fallback would invalidate the latency principle. +4. **Production wiring lives exclusively in `cli.py`. Tests may inject alternative factories. The application layer accepts factories/protocols, never constructs infrastructure adapters.** No service-locators, no DI container, no globals. +5. **regex_level and regex_instance constants are preserved verbatim from main.py. Additionally, the streaming pipeline must process every log line that matches either regex — drop policy applies only to non-matching lines (back-pressure on domain-relevant lines is blocking, not lossy).** + +### 1.2 Decision Drivers (top 3, ranked) + +1. **Sub-second latency for level/area changes.** The single biggest user-visible win. `watchdog` + Windows `ReadDirectoryChangesW` is the only Context7-backed path that hits this without a polling thread. +2. **Reliability of Discord IPC under reconnect storms.** `tenacity` with `retry_if_exception_type((ConnectionError, OSError, InvalidPipe))` + `before_sleep_log` replaces hand-rolled `2**retries` loop, and gives observable retry telemetry via `structlog`. +3. **Testability without an actual game running.** Domain logic must be 100% unit-testable (parsers, classes, locations, throttle, bus) with zero `psutil`/`pypresence` mocks. This forces the port/adapter split to be real, not cosmetic. + +### 1.3 Viable Options + +#### Option A — **Aggressive DDD / Hexagonal (CHOSEN)** + +Full `src/poe2_rpc/` with `domain/`, `application/`, `infrastructure/` layers; async event-bus; AioPresence; pydantic VOs; mypy --strict; 7-phase migration. + +- **Pros:** Hits all 4 priorities. Clean test boundary. Future-proof for non-Steam client, AFK detection, party-conflict resolution. Idiomatic Python 3.11+. +- **Cons:** ~1500 LOC of new package code for a 330-LOC script. Higher PyInstaller surface (~5–8 MB binary growth). Onboarding friction for casual contributors. + +#### Option B — **Hexagonal-Lite (single package, no application/event-bus split)** + +`src/poe2_rpc/` with `domain.py`, `adapters.py`, `app.py`, `cli.py`. Direct adapter calls from a single `App` class instead of an event-bus. + +- **Pros:** ~40% less scaffolding than Option A. Still typed, tested, watchdog-driven. Faster to ship. +- **Cons:** Couples parsing to presence-publishing in `App`. Adding handlers (e.g. AFK detection) means editing one growing class instead of subscribing a new handler. Throttle and bus-fanout logic blur. + +#### Option C — **Single-file Evolution (INVALIDATED on technical grounds)** + +Stay in `main.py`, swap `time.sleep(5)` → `watchdog`, swap `Presence` → `AioPresence`, add `tenacity` decorators, keep dict state. + +**Re-defended invalidation rationale:** + +- ✓ Could hit Priority 1 (latency): yes, watchdog swap in `monitor_log()` is ~10 LOC. +- ✓ Could hit Priority 2 (reliability): yes, tenacity decorator on `rpc_connect` is ~3 LOC. +- ✓ Could hit Priority 4 (observability): yes, structlog drop-in is ~5 LOC + config. +- ✗ **CANNOT hit Priority 3 (testability) without protocol/port refactor:** + - `main.py` `monitor_log()` couples 4 concerns (discovery + streaming + parsing + presence). + - Cannot mock `pypresence` for unit tests without injecting `Presence` — which IS a port pattern. + - Cannot test parsing without invoking I/O — which IS layer separation. + - Therefore single-file evolution requires partial DDD refactor for testability anyway. +- ✗ **CANNOT support Future Open Work without significant restructuring:** + - AFK detection (open work in README) requires event subscription model. + - Non-Steam client support requires `GameDetector` abstraction. + - Party-conflict detection requires correlated-event handling. + +**Conclusion:** 3 of 4 priorities are achievable in single-file form, but Priority 3 (testability) plus the README open-work backlog require structural separation. Going to full DDD now is cheaper than retrofitting incrementally. Spec Acceptance Criterion #1 also mandates `src/poe2_rpc/` with explicit layers — Option C cannot satisfy it. **Documented as invalidated, not chosen.** + +#### Why A over B + +The user picked all 4 priorities (no compromise). Option B compromises **testability** (App class becomes God-object after AFK + non-Steam) and **observability** (no event-bus → no central place to bind contextvars per event-type). Option A's extra cost is one-time scaffolding; Option B's extra cost recurs every time we add a handler. + +### 1.4 Pre-Mortem (3 failure scenarios) + +#### Scenario PM-1: `watchdog` doesn't fire on `Client.txt` writes + +**What:** PoE2 writes via memory-mapped I/O or buffered/append with NTFS-specific flush behavior; `ReadDirectoryChangesW` doesn't deliver `FileModifiedEvent` reliably during gameplay. + +**Probability:** Medium. The `watchdog` issue tracker has historical reports of memory-mapped writes not triggering. PoE2 is observed to write `Client.txt` as plain append (current `readlines()` polling works), but we have no measurement on event-frequency. + +**Mitigation:** +- Phase B test E-2 (live-game smoke) is the empirical gate before cutover. +- Phase C task C-4b ("stall detector") implements: on missing `FileSystemEvent` past `presence_min_interval_seconds * 4`, attempt one `Observer.stop()` + `Observer.schedule()` recovery; if the next stall window is also silent, raise `LogStreamStalled`. No silent polling fallback — would violate Principle 3. +- Add a `--debug-watchdog` CLI flag that prints every `FileSystemEvent` for triage. + +#### Scenario PM-2: PyInstaller `--onefile` misses a watchdog hidden import on Windows + +**What:** `watchdog.observers` lazy-imports `watchdog.observers.read_directory_changes` only on Windows; `pyinstaller --onefile` hooks may miss it, causing the .exe to import-fail at runtime with `ModuleNotFoundError: watchdog.observers.read_directory_changes` — but only on user machines, not the CI build, because CI runs on `windows-latest` *and* runs the final build artifact only via tag-push. + +**Probability:** High at first build attempt. `watchdog` Windows backend is a known PyInstaller gotcha. + +**Mitigation:** +- `PathOfExile2DiscordRPC.spec` declares expanded `hiddenimports=['watchdog.observers.read_directory_changes', 'watchdog.observers.winapi', 'pydantic_core._pydantic_core', 'pydantic._internal._model_construction', 'pydantic_settings.sources.providers.toml', 'structlog._log_levels', 'tenacity']` plus `--collect-submodules` for pydantic, pydantic_settings, structlog, watchdog, tenacity, pypresence (F-1). +- Phase F task F-3 adds a CI smoke step: `dist\PathOfExile2DiscordRPC.exe validate-config --no-discord` on the Windows runner before tagging — exercises the full pydantic-settings + TOML + structlog + watchdog import chain end-to-end. +- Phase F task F-4 measures cold-start (5 runs of `validate-config --no-discord`); p95 ≤ 8s budget; budget breach files `bd` issue and annotates release notes. +- Phase G task G-4 runs the explicit live-smoke checklist on a real Windows VM with PoE2 + Discord before release. + +#### Scenario PM-3: `pypresence.AioPresence` async-context leaks during reconnect storms + +**What:** Discord client restart → `AioPresence` raises `InvalidPipe` mid-`update()`. `tenacity` retries; on retry, the underlying `_rpc.connect()` may not reset cleanly because the previous socket reader task is still holding the loop. Memory and FD usage grow. + +**Probability:** Medium-low — but probability **rises** with user behavior of "restart Discord 5x in a session." + +**Mitigation:** +- `PypresencePublisher.connect()` calls `await self._rpc.close()` *before* re-`connect()` if `self._connected`. +- Add `application/orchestrator.py` graceful-shutdown: on `KeyboardInterrupt`, call `publisher.close()` and `observer.stop()` in a `finally` block. +- Integration test (Phase B test I-3): mock `AioPresence` to raise `InvalidPipe` 3 times then succeed; assert no orphan `asyncio.Task` after final retry (`asyncio.all_tasks()` baseline check). + +### 1.5 Expanded Test Plan (DELIBERATE) + +| Tier | Scope | Examples | Tooling | +|------|-------|----------|---------| +| **Unit** | Pure domain — parsers, classes, locations, throttle, bus | `test_regex_level_matches_spec_sample`, `test_witchhunter_resolves_to_mercenary`, `test_throttle_drops_within_window`, `test_bus_dispatches_to_multiple_handlers` | `pytest`, `pytest-mock` | +| **Integration** | Adapters with real I/O against `tmp_path` / loopback | `test_watchdog_emits_lines_on_append` (write to tmp file, assert line arrives ≤500ms), `test_json_catalog_loads_bundled_via_importlib_resources`, `test_settings_toml_overrides_env` | `pytest`, `pytest-asyncio`, `tmp_path` | +| **End-to-end** | Composition root with all adapters except external Discord | `test_orchestrator_full_flow` — fake Discord publisher counts calls, real watchdog watches a tmp `Client.txt`, append 3 log lines, assert 1 connect + 2 updates + 15s throttle gap | `pytest-asyncio`, fake `PresencePublisher` adapter | +| **Live smoke** | Real game, real Discord (manual, gated on Phase G-4) | Numbered 10-step G-4 checklist: launch order, `time_to_first_presence ≤ 8s`, `time_to_level_update ≤ 1.5s`, `time_to_reconnect ≤ 64s`, zero errors over 10-min idle | G-4 inline checklist (no separate `docs/SMOKE.md`) | +| **Cold-start** | `.exe` startup-time budget on clean Windows VM | F-4: 5 runs of `validate-config --no-discord`; p50 + p95 recorded; **p95 ≤ 8s** | `subprocess.run` in `tests/integration/test_cold_start.py` | +| **Observability** | Structured-log assertions | `test_level_change_emits_structured_event` — `structlog`'s `capture_logs()` asserts JSON record has `event="character_level_changed"`, `level=42`, `username=...`; `test_retry_logs_warning_before_sleep` | `structlog.testing.capture_logs` | +| **Type** | Static safety | `mypy --strict src/poe2_rpc tests` — must pass with zero errors. Strict-optional, no implicit-Any. | `mypy ≥1.10` | +| **Lint/Format** | Style | `ruff check src tests`, `ruff format --check src tests` | `ruff ≥0.5` | +| **Build** | Distribution | `pyinstaller PathOfExile2DiscordRPC.spec` produces `.exe`; `dist\PathOfExile2DiscordRPC.exe validate-config --no-discord` exits 0 (deeper smoke than `--version`) | `PyInstaller ≥6.14` | + +--- + +## 2. Implementation Plan + +The plan is decomposed into 7 epic phases (A–G). Each task is a 2–5 min focused unit suitable for `bd create -t task` with `bd dep add` for ordering. Tasks within a phase that have no inter-dep can be claimed in parallel; cross-phase deps are explicit. + +**Epic:** `bd create -t epic "Migrate poe2-rpc to DDD/hexagonal architecture (Aggressive)"` + +### Phase A — Project Skeleton + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **A-1** | Add `pyproject.toml` with src-layout, deps, optional `[dev]` group | — | n/a (config) | `pyproject.toml` | `pip install -e ".[dev]"` succeeds; `[project.scripts] poe2-rpc = "poe2_rpc.cli:app"` registered | +| **A-2** | Create `src/poe2_rpc/` package skeleton with empty `__init__.py`, `__main__.py`, `__version__.py`, `py.typed`; subdirs `domain/`, `application/`, `infrastructure/` each with empty `__init__.py` | A-1 | n/a (scaffolding) | `src/poe2_rpc/**/__init__.py`, `__main__.py`, `__version__.py`, `py.typed` | `python -c "import poe2_rpc; print(poe2_rpc.__version__)"` works after `pip install -e .` | +| **A-3** | Add `tests/` skeleton with `conftest.py`, `tests/unit/`, `tests/integration/` | A-1 | RED: `tests/unit/test_smoke.py::test_package_importable` → GREEN: trivial assert → REFACTOR: none | `tests/conftest.py`, `tests/unit/test_smoke.py` | `pytest` runs and 1 test passes | +| **A-4** | Add `ruff.toml` (or `[tool.ruff]` in pyproject) — line-length 100, target-version py311, rules `E,F,W,I,N,UP,B,SIM,RET,ARG,PL` | A-1 | n/a | `pyproject.toml` (`[tool.ruff]`) | `ruff check src tests` exits 0 | +| **A-5** | Add `[tool.mypy]` strict block — `strict = true`, `python_version = "3.11"`, `mypy_path = "src"` | A-1 | n/a | `pyproject.toml` (`[tool.mypy]`) | `mypy --strict src/poe2_rpc` exits 0 (empty package = trivially strict) | +| **A-6** | Update `.github/workflows/build.yml` — split into `lint-and-test` (ubuntu) + `build` (windows, `needs: lint-and-test`). **Split path-filters per job:** `lint-and-test` paths = `['src/**', 'tests/**', 'pyproject.toml', 'locations.json']`; `build` paths = `['src/**', 'PathOfExile2DiscordRPC.spec', 'pyproject.toml', 'locations.json']` (excludes `tests/**`). Add CI step `lint-imports` (import-linter check) to `lint-and-test`. | A-1, A-4, A-5 | n/a (CI) | `.github/workflows/build.yml` | CI runs lint + typecheck + pytest + `lint-imports` before build; `tests/**`-only changes do NOT trigger build job; on `main.py` change alone, CI does NOT trigger | + +**Phase A Exit gate:** `pip install -e ".[dev]" && ruff check src tests && mypy --strict src/poe2_rpc && pytest` all pass on a fresh clone. + +--- + +### Phase B — Domain Layer (Pure Logic) + +All B-tasks land 100% unit-tested. Domain imports only stdlib + `pydantic`. + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **B-1** | `domain/classes.py` — port `CharacterClass` + `ClassAscendency` enums verbatim from `main.py` (lines 20–100) | A-2 | RED: `test_witchhunter_resolves_to_mercenary`, `test_smith_of_kitava_resolves_to_warrior` → GREEN: copy enums + `get_class()` → REFACTOR: keep dict mapping shape | `src/poe2_rpc/domain/classes.py`, `tests/unit/test_classes.py` | All 17 ascendancies + 7 base classes mapped; `get_ascendencies()` returns full list per class | +| **B-2** | `domain/models.py` — frozen `LevelInfo(username, base_class, ascension_class: str \| None, level: int)` + `InstanceInfo(area_code, area_display_name, level: int, seed: int)` | A-2 | RED: `test_level_info_is_frozen` (assigning to `.level` raises `ValidationError`), `test_level_info_eq_by_value` → GREEN: `BaseModel(model_config=ConfigDict(frozen=True))` → REFACTOR: ensure `__hash__` works | `src/poe2_rpc/domain/models.py`, `tests/unit/test_models.py` | Mutation raises; `LevelInfo(...) == LevelInfo(...)` by value; both models pass `mypy --strict` | +| **B-3** | `domain/locations.py` — `Location(area_code: str, display_name: str)` VO + `LocationCatalog` Protocol-friendly mapping with `resolve(area_code: str) -> str` (Map-prefix-strip + underscore-split logic from `determine_location()` lines 163–176) | A-2, B-2 | RED: `test_resolve_strips_map_prefix` (input `MapHerald_4` → matches `Herald`), `test_resolve_returns_input_when_unknown` → GREEN: port logic → REFACTOR: extract `_normalize_area_code()` helper | `src/poe2_rpc/domain/locations.py`, `tests/unit/test_locations.py` | All 4 branches of `determine_location()` covered; behavior identical to `main.py` | +| **B-4** | `domain/events.py` — frozen pydantic events: `GameStarted(log_path: Path)`, `GameStopped()`, `CharacterLevelChanged(level_info: LevelInfo)`, `AreaEntered(instance_info: InstanceInfo)` | A-2, B-2 | RED: `test_event_is_frozen`, `test_events_are_distinct_types` → GREEN: 4 BaseModels → REFACTOR: shared base `DomainEvent(BaseModel, frozen=True)` | `src/poe2_rpc/domain/events.py`, `tests/unit/test_events.py` | All 4 events frozen; `isinstance(e, DomainEvent)` works for dispatch | +| **B-5** | `domain/ports.py` — `Protocol`s: `GameDetector.detect() -> Path`, `LogStream.lines() -> AsyncIterator[str]`, `LogParser.parse(line: str) -> Iterable[DomainEvent]`, `PresencePublisher.publish(...)` + `close()`, `EventBus.subscribe/publish`, `LocationCatalog.resolve(area_code) -> str` | A-2, B-3, B-4 | RED: `test_ports_are_runtime_checkable` (or `runtime_checkable` decorator) → GREEN: define Protocols → REFACTOR: ensure no `infrastructure` import sneaks in | `src/poe2_rpc/domain/ports.py`, `tests/unit/test_ports.py` | `mypy --strict` passes; `domain/` does not import any `infrastructure/` symbol (enforced via `tests/unit/test_layering.py` walking imports) | +| **B-6** | Layering guard via **import-linter** (`pyproject.toml` config). Add `[tool.importlinter]` block with `root_package = "poe2_rpc"` and a `layers` contract `["poe2_rpc.cli", "poe2_rpc.application", "poe2_rpc.infrastructure", "poe2_rpc.domain"]`. CI step `lint-imports` runs in A-6 pipeline. **Plus** AC#2 enforcement test: +- test_no_module_level_mutable_state_in_domain: AST-scan recursively walks every `.py` file under `src/poe2_rpc/domain/` (including subpackages and `exceptions.py`). Every module-level assignment must be `Final`-typed, an `Enum` member, a `Literal`, a `type` alias, or a frozen pydantic model class definition. Plain `x = ...` at module scope fails the test. | B-5 | RED: write tests → introduce violating import / mutable state → see RED → revert → see GREEN | `pyproject.toml` (`[tool.importlinter]`), `tests/unit/test_layering.py`, `tests/unit/test_no_mutable_state.py` | `lint-imports` exits 0 on clean tree, exits non-zero if any infra import sneaks into domain; `test_no_module_level_mutable_state_in_domain` fails if any future commit adds non-`Final` module-level assignment to `domain/` | + +**Phase B Exit gate:** `pytest tests/unit/` ≥ 25 tests passing; `mypy --strict src/poe2_rpc/domain` clean; `tests/unit/test_layering.py` enforces purity. + +--- + +### Phase C — Infrastructure Adapters + +Each C-task has an adapter + integration test. Where possible, adapters take filesystem paths or fakes so tests don't need a running game/Discord. + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **C-1** | `infrastructure/settings.py` — `AppSettings(BaseSettings)` with `discord_client_id`, `process_name`, `presence_min_interval_seconds=15.0`, `log_level`, `log_json`, `locations_url: str \| None`; sources order: init → CLI → env (`POE2RPC_*`) → TOML → defaults. **Default config path:** `Path(os.environ["APPDATA"]) / "poe2-rpc" / "config.toml"` if `APPDATA` env var is set (Windows production); else `Path.home() / ".config" / "poe2-rpc" / "config.toml"` (cross-platform dev/test on macOS/Linux only). Note: spec snippet at `.omc/specs/deep-dive-architecture-libraries.md:253` references `~/.config/poe2-rpc/config.toml`; user can apply spec edit later to align. | A-2 | RED: `test_settings_env_overrides_defaults` (set `POE2RPC_LOG_JSON=true`, assert `True`), `test_settings_toml_overrides_default_when_no_env`, `test_default_config_path_uses_appdata_on_windows` (mock `os.environ["APPDATA"]`, assert resolved path), **AC#6 enforcement** `test_cli_arg_overrides_env_setting` (set `POE2RPC_LOG_LEVEL=ERROR`, invoke CLI with `--log-level=DEBUG`, assert effective level = DEBUG) → GREEN: implement `settings_customise_sources` with init→CLI→env→TOML→defaults order → REFACTOR: extract toml-path resolution helper | `src/poe2_rpc/infrastructure/settings.py`, `tests/integration/test_settings.py` | Defaults match spec verbatim (Discord ID `1315800372207419504`); priority order verified including CLI > env > TOML; Windows APPDATA path resolution verified | +| **C-2** | `infrastructure/logging.py` — `configure_structlog(level: str, json_output: bool)` — `ConsoleRenderer` if `not json_output and sys.stderr.isatty()` else `JSONRenderer`; processors include `add_log_level`, `TimeStamper(fmt="iso")`, `contextvars.merge_contextvars` | A-2 | RED: `test_json_renderer_emits_valid_json` (capture stderr, parse), `test_console_renderer_when_tty` (mock isatty=True) → GREEN: implement → REFACTOR: factor processors list | `src/poe2_rpc/infrastructure/logging.py`, `tests/integration/test_logging.py` | Both renderers work; `bind_contextvars(username="x")` propagates into log record | +| **C-3** | `infrastructure/detection.py` — `PsutilGameDetector(process_name)` implements `GameDetector`; `async def detect() -> Path` polls `psutil.process_iter` every 3s until found, returns `/logs/Client.txt` | A-2, B-5 | RED: `test_detect_returns_log_path_when_process_running` (mock `psutil.process_iter` returning fake process) → GREEN: implement → REFACTOR: extract `_iter_processes()` helper | `src/poe2_rpc/infrastructure/detection.py`, `tests/integration/test_detection.py` | Mocked `psutil` test passes; on no process, `detect()` keeps polling (test with `asyncio.wait_for(..., timeout=0.2)` raises `TimeoutError`) | +| **C-4** | `WatchdogLogStream` (event-driven log tail with bounded queue + thread-safe enqueue) + +TDD: + RED: + - test_watchdog_emits_modified_event_on_append + - test_observer_thread_never_blocks_on_loop (new — mocks slow loop, asserts handler returns within 50ms) + - test_queue_blocks_on_domain_relevant_line_when_full (back-pressure preserves AC#14 / Principle 5) + - test_queue_drops_non_domain_lines_when_full_and_increments_metric + - test_enqueue_retry_respects_deadline_and_raises_log_stream_stalled (reuses C-4b exception) + GREEN: + Implementation contract: + - Watchdog observer thread NEVER directly touches the asyncio.Queue. All enqueues are scheduled onto the asyncio loop via `loop.call_soon_threadsafe(self._enqueue, line)`. + - `_enqueue(self, line)` runs on the loop thread: + 1. Try `self._queue.put_nowait(line)`. + 2. On `asyncio.QueueFull`: + - If `regex_level.search(line) or regex_instance.search(line)` (domain-relevant): + exponential-backoff retry via `loop.call_later(delay, self._enqueue, line)` with delay starting at 0.05s, doubling each attempt, capped at 0.5s. Track per-line elapsed time; if elapsed > `AppSettings.log_stream_enqueue_deadline_seconds` (default 2.0), raise `LogStreamStalled` (the same domain exception C-4b raises). + - Else (non-domain): drop the line, increment `dropped_non_domain_count` metric, emit `structlog.warning("log_line_dropped", offset=...)`. + - Queue is `asyncio.Queue(maxsize=10000)`. + - The watchdog observer thread's only operation per event is: re-read file delta from saved offset, split into lines, schedule each via `call_soon_threadsafe`. No blocking, no `run_coroutine_threadsafe`, no `Future.result()`. + REFACTOR: + - Extract `_enqueue` retry helper to keep `_Handler.on_modified` minimal. + +Files touched: + - src/poe2_rpc/infrastructure/streaming.py + - src/poe2_rpc/infrastructure/settings.py (new field log_stream_enqueue_deadline_seconds: float = 2.0) + - tests/integration/test_streaming.py (5 tests above) + +Acceptance: + - All 5 RED tests fail before impl, pass after. + - import-linter / B-6 still green (no domain→infra leak). + - Observer thread is non-blocking under any queue state — verified by `test_observer_thread_never_blocks_on_loop`. | A-2, B-5 | (see above) | (see above) | (see above) | +| **C-4b** | **Stall detector** in `WatchdogLogStream`. Timer fires if no `FileSystemEvent` received within `presence_min_interval_seconds * 4`. On fire: `Observer.stop()` then `Observer.schedule()` once (recovery attempt). On retry-failure (next stall window also silent): raise `LogStreamStalled` exception (new domain-level exception in `domain/exceptions.py`). | A-2, B-5, C-4 | RED: `test_watchdog_raises_log_stream_stalled_after_silent_period` (no events → trigger one auto-recovery → still no events → assert `LogStreamStalled`); `test_watchdog_recovers_after_first_stall` (no events → recovery schedules → events flow again → assert no exception) → GREEN: implement stall timer + single retry → REFACTOR: extract `_StallWatchdog` helper | `src/poe2_rpc/infrastructure/streaming.py` (extended), `src/poe2_rpc/domain/exceptions.py`, `tests/integration/test_streaming.py` (extended) | Silent watchdog after `4 * presence_min_interval_seconds` triggers exactly one re-`schedule()`; second silent window raises `LogStreamStalled` | +| **C-5** | `infrastructure/parsing.py` — `RegexLogParser` implements `LogParser` with class-level constants `REGEX_LEVEL = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)")` and `REGEX_INSTANCE = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)')` (verbatim from CLAUDE.md); `parse(line)` yields `CharacterLevelChanged` and/or `AreaEntered` events; resolves base→ascendancy via `ClassAscendency._value2member_map_` | A-2, B-1, B-2, B-4, B-5 | RED: `test_regex_level_matches_spec_sample("...: Foo (Witchhunter) is now level 42")` → assert `CharacterLevelChanged(level_info.username='Foo', base_class='Mercenary', ascension_class='Witchhunter', level=42)`; `test_regex_instance_matches_spec_sample('Generating level 5 area "G1_4_Brambleghast" with seed 12345')` → GREEN: port logic from `find_last_level_up` + `find_instance` → REFACTOR: split private helpers `_classify_level()` + `_resolve_area()` | `src/poe2_rpc/infrastructure/parsing.py`, `tests/unit/test_parsing.py` | Both regexes byte-identical to CLAUDE.md; ascendancy resolution covers all 17 from B-1; unknown class → `ascension_class=None` (was `"Unknown"` string in main.py — convert to `None` for type-safety, presence layer handles None branch) | +| **C-6** | `infrastructure/catalog.py` — `JsonLocationCatalog` implements `LocationCatalog`; `from_bundled()` uses `importlib.resources.files("poe2_rpc") / "locations.json"`; `from_url(url)` fetches once at construct-time; both delegate `resolve()` to domain logic from B-3 | A-2, B-3, B-5 | RED: `test_catalog_from_bundled_loads_known_area` (uses `importlib.resources` against a tiny `locations.json` fixture under `tests/fixtures/`) → GREEN: implement → REFACTOR: share resolve-logic with B-3 | `src/poe2_rpc/infrastructure/catalog.py`, `tests/integration/test_catalog.py`, fixture `tests/fixtures/locations.json` | Bundled catalog reads via `importlib.resources` (no `Path("locations.json")` cwd-relative read); URL override works | +| **C-7a** | `infrastructure/presence.py` — `PypresencePublisher(client_id)` implements `PresencePublisher` using `AioPresence`. **Connect retry policy (separate from publish):** `@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=32), retry=retry_if_exception_type((ConnectionError, OSError, BrokenPipeError, InvalidPipe, asyncio.IncompleteReadError)), before_sleep=before_sleep_log(_stdlib_logger, logging.WARNING))`. `connect()` calls `await self._rpc.close()` first if `self._connected` (PM-3 mitigation); `close()` cleans up. **Pre-close gate:** before C-7a closes, query `mcp__plugin_compound-engineering_context7__query-docs` (library_id `/websites/qwertyquerty_github_io_pypresence_html`) for `InvalidPipe` MRO and `asyncio.IncompleteReadError` wrapping behavior. Update `retry_if_exception_type` tuple per finding. | A-2, B-5 | RED: `test_connect_retries_5_times_with_exponential_backoff` (fake `AioPresence` raises `InvalidPipe` 4x then succeeds, assert exactly 5 attempts and ≥ (2+4+8+16) seconds between first and last sleep); `test_connect_gives_up_after_5_attempts`; `test_reconnect_closes_previous_socket` (PM-3) → GREEN: implement → REFACTOR: factor connect-retry decorator into module-level constant `_CONNECT_RETRY` | `src/poe2_rpc/infrastructure/presence.py`, `tests/integration/test_presence.py` | All 3 tests pass; `before_sleep_log` emits `WARNING`-level structured record per attempt; Context7 verification recorded in commit message | +| **C-7b** | **Publish retry policy (split from connect):** `publish()` decorated with `@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), retry=retry_if_exception_type((BrokenPipeError, InvalidPipe, OSError)), before_sleep=before_sleep_log(_stdlib_logger, logging.WARNING))`. **Critical:** publish retries are NOT multiplied through nested connect retries — if publish needs to reconnect mid-publish, the connect call uses its own (already-spent) retry budget without restarting publish's. | A-2, B-5, C-7a | RED: `test_publish_does_not_multiply_retries_through_connect` (force `publish()` to invoke `connect()` once internally; assert at most 3 publish attempts total even when each one calls connect); `test_publish_retries_on_broken_pipe` (raise `BrokenPipeError` 2x then succeed; assert 3 total attempts); `test_publish_gives_up_after_3_attempts` → GREEN: implement decorator with isolated retry budget → REFACTOR: factor publish-retry decorator into module-level constant `_PUBLISH_RETRY` | `src/poe2_rpc/infrastructure/presence.py` (extended), `tests/integration/test_presence.py` (extended) | Publish capped at 3 attempts (not 3×5=15); both decorators co-exist without retry-multiplication | + +**Phase C Exit gate:** All adapters tested with fakes/`tmp_path`; no test requires a real game or real Discord; `mypy --strict src/poe2_rpc` still clean. + +--- + +### Phase D — Application Layer + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **D-1** | `application/bus.py` — `AsyncioEventBus` implements `EventBus`; `subscribe(event_type, handler)` registers async callable; `publish(event)` dispatches to all matching handlers concurrently via `asyncio.gather` (errors logged, not raised — one bad handler doesn't kill the bus) | A-2, B-4, B-5 | RED: `test_bus_dispatches_to_multiple_handlers`, `test_bus_isolates_handler_exceptions` (one handler raises, other still called) → GREEN: implement → REFACTOR: type the registry as `dict[type[DomainEvent], list[Handler]]` | `src/poe2_rpc/application/bus.py`, `tests/unit/test_bus.py` | Multi-handler dispatch + exception isolation verified | +| **D-2** | `application/throttle.py` — `PresenceThrottle(min_interval_seconds: float)` — async-aware: `should_publish(now: float) -> bool` returns False if last publish < min_interval ago; also exposes `default_state_text()` pure function (the `random_status()` list from main.py lines 119–132) | A-2 | RED: `test_throttle_drops_within_window`, `test_throttle_allows_after_window`, `test_default_state_text_is_one_of_known` → GREEN: implement → REFACTOR: inject clock for testability (`Callable[[], float] = time.monotonic`) | `src/poe2_rpc/application/throttle.py`, `tests/unit/test_throttle.py` | 15s default; injectable clock makes tests deterministic | +| **D-3** | `application/handlers.py` — `on_level_changed(event, *, publisher, throttle, current_state)` and `on_area_entered(event, *, publisher, throttle, current_state)` async functions. Format: `details = f"{username} ({base_class}" + opt(" \| {ascension}") + f" - Lvl {level})"` (preserves main.py format for non-None ascendancy; None branch yields `"Foo (Mercenary - Lvl 42)"` — see ADR §3 Behavior Changes #1); `state = f"In: {area_display_name} (Lvl {level})"` or `default_state_text()`; compute `small_image = ascension_class.lower().replace(" ", "_")` (preserve commit `5ae14e6` enforcement). Handlers must `bind_contextvars(username=..., character_class=..., area=...)` before any logging call so observability principle (AC#7) is satisfied. | A-2, B-2, B-4, C-7a, C-7b, D-2 | RED: `test_on_level_changed_formats_details_with_ascendancy`, `test_on_level_changed_omits_ascendancy_pipe_when_none` (None branch — was "Unknown" in main.py), `test_on_area_entered_formats_in_state`, `test_small_image_lowercases_and_underscores` ("Smith of Kitava" → "smith_of_kitava"), **AC#7 enforcement** `test_handlers_bind_username_class_area_into_logs` (trigger `CharacterLevelChanged` + `AreaEntered`; capture structlog output via `capture_logs()`; assert all three keys `username`, `character_class`, `area` present in the structured event) → GREEN: implement → REFACTOR: extract `_format_details()` pure helper | `src/poe2_rpc/application/handlers.py`, `tests/unit/test_handlers.py` | All format strings byte-identical to current main.py output for non-None ascendancy; None branch yields `"Foo (Mercenary - Lvl 42)"` (no pipe); structured logs carry `username`, `character_class`, `area` per AC#7 | +| **D-4** | `application/orchestrator.py` — `Orchestrator(detector, parser, publisher, catalog, bus, settings, log_stream_factory)`. **Factory injection (Principle 4 enforcement):** instead of constructing `WatchdogLogStream` from `log_path`, the orchestrator accepts `log_stream_factory: Callable[[Path, asyncio.AbstractEventLoop], LogStream]` injected by `cli.py`. The application layer never imports `WatchdogLogStream` directly. async `run()` method: 1) detect game → `bus.publish(GameStarted)`, 2) `stream = log_stream_factory(log_path, loop)`, 3) `async for line in stream.lines(): for event in parser.parse(line): await bus.publish(event)`, 4) on cancel/`KeyboardInterrupt`: `await publisher.close()`, observer.stop in finally; subscribes handlers from D-3 to the bus at startup. | A-2, B-5, C-3, C-4, C-4b, C-5, C-6, C-7a, C-7b, D-1, D-2, D-3 | RED: `test_orchestrator_full_flow` (e2e) — fake detector returns tmp Client.txt path; fake `log_stream_factory` returns a queue-backed `LogStream`; append 3 lines; assert publisher received exactly: 1 connect, level-change update, area update; assert ≥15s gap between consecutive updates. **Layering test** `test_orchestrator_does_not_import_infrastructure` — `ast.parse(application/orchestrator.py)`, walk `Import`/`ImportFrom` nodes, assert no symbol from `poe2_rpc.infrastructure` is imported. → GREEN: implement → REFACTOR: factor wiring into `_subscribe_handlers()` private | `src/poe2_rpc/application/orchestrator.py`, `tests/integration/test_orchestrator.py`, `tests/unit/test_orchestrator_layering.py` | E2E test passes with injected factory; graceful shutdown verified (no orphan tasks via `asyncio.all_tasks()`); `test_orchestrator_does_not_import_infrastructure` proves Principle 4 | + +**Phase D Exit gate:** `pytest tests/integration/test_orchestrator.py` green; orchestrator never imports from `infrastructure/*` directly (only via constructor params, types are Protocols from `domain/ports`). + +--- + +### Phase E — CLI + Composition Root + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **E-1** | `cli.py` — Typer `app`; commands `run` (default), `once` (single update + exit), `validate-config` (loads + prints settings, exit 0), version via `--version` callback. **Add `--no-discord` flag to `validate-config`:** when set, the command loads settings + bundled `locations.json` + initializes `structlog` WITHOUT contacting Discord IPC. This validates the `pydantic-settings` + TOML loader + structlog + watchdog import chain end-to-end (used by F-3 smoke step). Composition root assembles all adapters + orchestrator (provides `log_stream_factory=lambda path, loop: WatchdogLogStream(path, loop)` per Principle 4). | A-2, C-1, C-2, C-3, C-4, C-4b, C-5, C-6, C-7a, C-7b, D-1, D-2, D-3, D-4 | RED: `test_cli_validate_config_exits_zero` (Typer `CliRunner`), `test_cli_validate_config_no_discord_skips_ipc` (assert no `AioPresence` instantiated when `--no-discord` is set), `test_cli_version_prints_version`, `test_cli_once_runs_one_iteration` (fake factories injected via patching) → GREEN: implement → REFACTOR: extract `build_orchestrator(settings) -> Orchestrator` factory for testability | `src/poe2_rpc/cli.py`, `tests/integration/test_cli.py` | All CLI commands tested via `typer.testing.CliRunner`; `poe2-rpc --help` shows all commands; `validate-config --no-discord` exits 0 without Discord IPC | +| **E-2** | `__main__.py` — `from .cli import app; app()` so `python -m poe2_rpc` works | A-2, E-1 | RED: `test_module_runs_via_python_dash_m` (`subprocess.run([sys.executable, '-m', 'poe2_rpc', '--version'])` exit 0) → GREEN: 2-line implementation → REFACTOR: none | `src/poe2_rpc/__main__.py` | `python -m poe2_rpc --version` prints version | + +**Phase E Exit gate:** `poe2-rpc run` boots end-to-end against fakes; `poe2-rpc validate-config` returns 0; `python -m poe2_rpc` works. + +--- + +### Phase F — Packaging + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **F-1** | `PathOfExile2DiscordRPC.spec` — Analysis points at `src/poe2_rpc/__main__.py`, `pathex=['src']`, `datas=[('locations.json', '.')]`, **expanded `hiddenimports`** = `['watchdog.observers.read_directory_changes', 'watchdog.observers.winapi', 'pydantic_core._pydantic_core', 'pydantic._internal._model_construction', 'pydantic_settings.sources.providers.toml', 'structlog._log_levels', 'tenacity']`. Use `--collect-submodules` for: `pydantic`, `pydantic_settings`, `structlog`, `watchdog`, `tenacity`, `pypresence`. `name='PathOfExile2DiscordRPC'`, `onefile=True`, `console=True`. **Acceptance includes** explicit `pyinstaller --debug=imports` smoke run on a clean Windows VM that succeeds without `ModuleNotFoundError`. | A-2, E-2 | n/a (build artifact); validated by F-2/F-3/F-4 | `PathOfExile2DiscordRPC.spec` | File matches spec snippet; `--debug=imports` build produces no missing-module diagnostics | +| **F-2** | Bundle `locations.json` for `importlib.resources` access — copy `locations.json` into `src/poe2_rpc/` (or add MANIFEST.in / pyproject.toml `[tool.setuptools.package-data]` entry); `JsonLocationCatalog.from_bundled()` reads via `importlib.resources.files("poe2_rpc") / "locations.json"` | A-1, C-6, F-1 | RED: `test_bundled_catalog_works_in_dev_install` — after `pip install -e .`, `from_bundled()` resolves a known area → GREEN: add package-data entry → REFACTOR: none | `pyproject.toml` (`[tool.setuptools.package-data]`), `src/poe2_rpc/locations.json` (or symlink/copy from root) | Test passes both in dev-install and in the .exe (validated by F-3) | +| **F-3** | Update `.github/workflows/build.yml` build job: `pip install -e .` + `pip install pyinstaller`; `pyinstaller PathOfExile2DiscordRPC.spec`; **deeper smoke step** `dist\PathOfExile2DiscordRPC.exe validate-config --no-discord` (validates pydantic-settings + TOML loader + structlog + watchdog import chain end-to-end; replaces shallow `--version` check); upload artifact `dist/PathOfExile2DiscordRPC.exe`. | A-6, F-1, F-2 | n/a (CI); evidence: green run | `.github/workflows/build.yml` | CI build job green on a `workflow_dispatch` trigger; `validate-config --no-discord` smoke step exits 0 — proves all hidden imports resolve and TOML+structlog+watchdog chain initializes without IPC | +| **F-4** | **Cold-start benchmark** (positioned between F-3 and Phase G). Run `dist\PathOfExile2DiscordRPC.exe validate-config --no-discord` 5 times on a clean Windows VM; record p50 and p95 cold-start (process spawn → first stdout line). **Budget: p95 ≤ 8s on Windows runner.** Failure action: file follow-up `bd` issue, do NOT block release of first DDD .exe but mark as known regression in release notes. | F-3 | RED: `tests/integration/test_cold_start.py` invokes `subprocess.run([exe_path, "validate-config", "--no-discord"], capture_output=True)` 5 times, asserts each `< 8.0` seconds → GREEN: implement test → if cold-start exceeds budget, the test fails on the Windows runner | `tests/integration/test_cold_start.py`, `.github/workflows/build.yml` (cold-start step) | p95 cold-start ≤ 8s recorded in CI logs; budget breach files `bd` issue and annotates release notes (does not block first release) | + +**Phase F Exit gate:** A CI run produces `PathOfExile2DiscordRPC.exe`, the deep smoke `validate-config --no-discord` step exits 0, and F-4 cold-start measurement is recorded (p95 ≤ 8s, or follow-up `bd` issue filed if budget exceeded). + +--- + +### Phase G — Cutover + +| ID | Title | Deps | TDD cycle | Files | Acceptance | +|----|-------|------|-----------|-------|------------| +| **G-1** | Validate regex contracts against a real `Client.txt` sample — capture sample, write `tests/fixtures/sample_client.txt`, add `test_regex_against_real_sample` that runs both regexes over the file and asserts ≥1 match each (or skip with reason if fixture absent) | C-5 | RED: write test (skipped without fixture) → GREEN: capture fixture, test passes → REFACTOR: none | `tests/fixtures/sample_client.txt`, `tests/integration/test_regex_real_sample.py` | Regexes match real-world log lines verbatim | +| **G-2** | Delete `main.py`, delete `requirements.txt` (replaced by pyproject), update `.gitignore` if needed. **Dependency: blocked-by G-4 — `bd dep add G-2 G-4 --type blocks`.** G-2 cannot close until G-4 (live smoke) passes. | G-3, G-4 | n/a (deletion) | (deleted) `main.py`, `requirements.txt` | Repo no longer contains `main.py`; CI still green; G-4 live-smoke checklist already passed | +| **G-3** | Update `README.md` and `CLAUDE.md` — replace "single-file" guidance with `src/poe2_rpc/` layout, update install command to `pip install -e ".[dev]"`, update run command to `poe2-rpc run`, document `%APPDATA%\poe2-rpc\config.toml` on Windows (and `~/.config/poe2-rpc/config.toml` for cross-platform dev), update "Adding a new ascendancy" section to point at `domain/classes.py`, update "Regex contracts" section to point at `infrastructure/parsing.py`. (Smoke checklist lives inline in G-4 acceptance, not in a separate `docs/SMOKE.md`.) | G-1 | n/a (docs); reviewed via `compound-engineering:document-review` or peer review | `README.md`, `CLAUDE.md` | Docs accurately describe new layout; "Build & Test" section runs green on a clean clone | +| **G-4** | **Live smoke run on a real Windows box.** Run the explicit numbered checklist below; capture screenshots and logs into release notes. | F-3, F-4, G-3 | n/a (manual) | Release notes | Every checklist item passes within its budget; release notes attach the captured timings | + +**G-4 Acceptance Checklist (run on a Windows VM with PoE2 + Discord):** + +1. Launch Discord; wait until status pane shows "Connected". +2. Launch PoE2 Steam build; wait for character-select screen. +3. Start `dist/PathOfExile2DiscordRPC.exe`; record `t0`. +4. Within 8s: presence detail string visible in Discord — record `time_to_first_presence`. +5. Enter game world; level character once. +6. Within 1.5s of in-game level-up notification: Discord presence reflects new level — record `time_to_level_update`. +6b. Open the JSON log written during steps 5-6. Locate the structlog event named `character_level_changed`. Assert that keys `username`, `character_class`, `area` are present and non-empty in that record. (This proves AC#7 holds in the wired-up binary, not just in unit-test isolation.) +7. Kill Discord client; wait 5s; relaunch Discord. +8. Within 64s of relaunch: presence reconnects — record `time_to_reconnect`. +9. Run for 10 minutes idle; tail JSON log; assert zero `level=error` records. +10. **Acceptance:** `time_to_first_presence ≤ 8s` AND `time_to_level_update ≤ 1.5s` AND `time_to_reconnect ≤ 64s` AND zero errors during the 10-minute idle window AND character_level_changed log record carries username/character_class/area. + +**Phase G Exit gate:** `main.py` deleted; live smoke recorded in release notes; tag pushed. + +--- + +## 3. Architecture Decision Record (ADR) + +### Behavior Changes vs main.py + +1. **Unknown-ascendancy detail string.** `main.py` renders `"Foo (Mercenary | Unknown - Lvl 42)"` (line 188 sets `ascension_class = "Unknown"` sentinel; line 258 stringifies it). New code renders `"Foo (Mercenary - Lvl 42)"` — omits the `| Unknown` segment. Deliberate UX improvement: the literal `"Unknown"` was inadvertent leakage of an internal sentinel; new model uses `ascension_class: str | None` and the formatter omits the pipe segment when None. +2. **`locations.json` source.** `main.py` line 137 falls back to fetching `locations.json` from the GitHub `main` branch URL when the local file is missing. New code uses bundled `locations.json` (via `importlib.resources.files("poe2_rpc")`) as the canonical source; URL fetch is opt-in only via `AppSettings.locations_url`. No silent network fetch at startup. +3. **Retry policies (split).** `connect()` uses `5 × wait_exponential(min=2, max=32)`; `publish()` uses `3 × wait_exponential(min=1, max=8)`. Two distinct policies because connect failure is rare-and-recoverable (large window, more attempts), publish failure is frequent-and-rate-limited (small window, fewer attempts) — and to prevent 3×5=15 attempt amplification. +4. **Config file location.** New addition (no prior config existed). Default `%APPDATA%\poe2-rpc\config.toml` on Windows; `~/.config/poe2-rpc/config.toml` on macOS/Linux for dev/test. +5. **Config path spec drift (intentional).** The source spec at `.omc/specs/deep-dive-architecture-libraries.md:253` literally writes `toml_file=str(Path.home() / ".config" / "poe2-rpc" / "config.toml")`. The implementation uses `Path(os.environ["APPDATA"]) / "poe2-rpc" / "config.toml"` on Windows (production target). The `~/.config/...` path is retained as a cross-platform dev/test fallback only. Rationale: Windows users have no `~/.config` convention; defaulting there would orphan their config. Follow-up: file a `bd` issue post-consensus to amend the spec snippet to match. The migration does NOT block on the spec edit. + +### Decision + +Migrate Path-Of-Exile-2-RPC from a single-file Python script to a hexagonal/DDD package (`src/poe2_rpc/`) with strict layer separation (`domain/` pure, `application/` orchestrating, `infrastructure/` adaptive), an asyncio event-bus, watchdog-driven event-stream replacing 5s polling, AioPresence with tenacity-decorated reconnect, pydantic-settings for config, structlog for observability, typer for CLI, and PyInstaller `--onefile` distribution unchanged. Single binary `PathOfExile2DiscordRPC.exe` retained. + +### Drivers + +- **Latency** — sub-second presence updates via `watchdog.Observer` + `ReadDirectoryChangesW`, replacing `time.sleep(5)` polling. +- **Reliability** — `tenacity` exponential-backoff reconnect with structured retry telemetry; explicit graceful shutdown of `AioPresence` socket. +- **Testability + Observability** — domain layer 100% unit-testable without I/O; `structlog.contextvars` carries (`username`, `area`, `level`) through every event; `mypy --strict` enforced. + +### Alternatives Considered + +| Alternative | Why not | +|---|---| +| **Conservative — single-file evolution.** Stay in `main.py`; swap libraries (watchdog, tenacity, structlog) but keep flat structure. | User explicitly rejected in interview Round 2 ("Aggressive DDD"). Cannot satisfy Acceptance Criteria #1 (`src/poe2_rpc/` with explicit layers). `mypy --strict` against a 330-LOC script with global `rpc` is uneconomic. Observability via `bind_contextvars` requires a real call-graph. | +| **Balanced — minimal package (Hexagonal-Lite).** Single `src/poe2_rpc/` with `domain.py + adapters.py + app.py + cli.py` (no application-layer split, no event-bus). | Compromises testability (App becomes God-object as we add AFK detection, non-Steam client support, party-conflict resolution from README) and observability (no central event-typed dispatch → no clean place to bind contextvars per event-type). User picked all 4 priorities (no compromise). | +| **Aggressive DDD — full hexagonal (CHOSEN).** | All 4 priorities satisfied; future-proof for known follow-ups. | +| **Dependency-injection container (`punq` / `dependency-injector`).** | Spec explicitly excludes ("конструкторная инъекция руками в `cli.py`"). Adds a runtime dependency without buying anything for a 1-binary app with 1 composition root. | +| **Multi-process / IPC.** | Out of scope (Non-Goals section of spec). | + +### Consequences + +**Positive:** +- Sub-second presence updates (vs 5s polling). +- Domain logic 100% unit-testable with no `psutil`/`pypresence` mocks. +- `structlog` + JSON logs unblock future telemetry without code changes. +- Adding AFK detection / non-Steam client / party-conflict-resolution becomes a new handler subscription, not a `monitor_log()` rewrite. +- `mypy --strict` catches refactoring errors before runtime. + +**Negative:** +- ~1500 LOC of new package code for a 330-LOC script. Higher onboarding cost for a casual contributor. +- `.exe` size grows by ~5–8 MB (pydantic + watchdog + structlog + typer). Acceptable for desktop tool. +- More CI surface (lint + typecheck + pytest + build) → longer CI wall-time. +- Watchdog-on-Windows is a known PyInstaller gotcha; managed via `hiddenimports` (PM-2). + +**Neutral:** +- Discord App ID `1315800372207419504` and binary name `PathOfExile2DiscordRPC.exe` unchanged — release-asset URLs continue to work. +- `locations.json` becomes bundled (no runtime fetch); existing installs upgrade via new `.exe` release. + +### Follow-ups (deferred — explicit non-goals of this migration) + +- AFK detection (README open work) → new `application/handlers.py::on_idle_detected` + `infrastructure/idle_detector.py`. +- Non-Steam PoE2 client support → second `GameDetector` adapter; CLI `--client` flag selects. +- Party-conflict / multi-player detection → enrich `RegexLogParser` with player-identity match; route via event-bus. +- Tray-icon / background-service launch (README open work) → separate epic; `pystray` or Windows service wrapper. +- Telemetry export → `structlog` JSON → file → optional log-shipper. + +--- + +## 4. Acceptance Criteria Coverage Matrix + +| # | Acceptance Criterion (from spec) | Satisfied by | +|---|---|---| +| 1 | `src/poe2_rpc/` package with `domain/`, `application/`, `infrastructure/`, `cli.py` | A-2 | +| 2 | All domain models frozen pydantic or Enum; no global mutable state | B-1, B-2, B-4, plus B-6 (`test_no_module_level_mutable_state_in_domain` AST scan) | +| 3 | All ports as `typing.Protocol` in `domain/ports.py`; domain doesn't import infrastructure | B-5, B-6 (import-linter layered contract) | +| 4 | `WatchdogLogStream` event-driven via `Observer + FileModifiedEvent`; raises `RuntimeError` on observer-start failure (no silent polling fallback); bounded queue with selective drop honoring Principle 5; stall detector | C-4, C-4b | +| 5 | `PypresencePublisher` uses `AioPresence` + tenacity-backed retries; **split policies** — `connect`: `5 × wait_exponential(min=2, max=32)`; `publish`: `3 × wait_exponential(min=1, max=8)`; `before_sleep_log` on both | C-7a, C-7b | +| 6 | `AppSettings(BaseSettings)` priority: init → CLI → env (`POE2RPC_*`) → TOML → defaults; Windows default `%APPDATA%\poe2-rpc\config.toml`; verified by `test_cli_arg_overrides_env_setting` | C-1 | +| 7 | `structlog` configured: `ConsoleRenderer` if TTY else `JSONRenderer`; `bind_contextvars` for username/class/area; verified by `test_handlers_bind_username_class_area_into_logs` | C-2 (renderer); D-3 (binding + AC#7 enforcement test) | +| 8 | Typer CLI: `run` (default), `once`, `validate-config` (with `--no-discord`), `--version` | E-1 | +| 9 | Pytest unit tests on parsers, classes, locations, throttle, bus; integration on `WatchdogLogStream` via `tmp_path`; cold-start benchmark | B-1..B-5 (units) + C-4 / C-4b (watchdog integration) + D-1, D-2 (units) + F-4 (cold-start) | +| 10 | `mypy --strict src/poe2_rpc` passes | A-5 (config); enforced in CI by A-6; verified at every phase exit gate | +| 11 | `ruff check` + `ruff format` pass | A-4 (config); enforced in CI by A-6 | +| 12 | `pyinstaller PathOfExile2DiscordRPC.spec` builds working `.exe` with all hidden imports + collected submodules | F-1, F-2, F-3, F-4 | +| 13 | CI workflow updated with **split path-filters per job** (`lint-and-test` vs `build`) + `lint-imports` + `lint`, `typecheck`, `test` jobs before `build` | A-6, F-3, F-4 | +| 14 | `regex_level` and `regex_instance` preserved verbatim in `infrastructure/parsing.py` as class-level constants | C-5; validated against real sample by G-1 | + +**Coverage:** 14 / 14 acceptance criteria mapped to specific tasks. Task ID changes from iteration 1: C-7 split into C-7a + C-7b; C-4b added (stall detector); F-4 added (cold-start benchmark). No criterion is unattributed. + +--- + +## 5. Plan Metadata + +- **Estimated tasks:** 35 (A: 6, B: 6, C: 9, D: 4, E: 2, F: 4, G: 4) plus 1 epic. Iteration-2 additions: C-4b (stall detector), C-7a/C-7b (split retry), F-4 (cold-start benchmark). +- **Estimated complexity:** HIGH (full architectural migration; live-target verification required). +- **Critical path:** A-1 → A-2 → B-2 → B-4 → B-5 → C-5 / C-7a / C-7b → D-3 → D-4 → E-1 → F-1/F-2/F-3 → F-4 → G-4 → G-2 (G-3 runs in parallel before G-2; G-2 blocked-by G-4). +- **Parallelizable:** B-1/B-2/B-3/B-4 (independent within domain); C-1/C-2/C-3 (independent infra); C-4 + C-4b sequenced; C-7a → C-7b sequenced; F-1/F-2 prep for F-3. +- **Open questions** — to be appended to `.omc/plans/open-questions.md`: + - Should `ascension_class=None` (typed) or `ascension_class="Unknown"` (current main.py string) be the type used in `LevelInfo`? **Plan assumes `None`** for type-safety; presence-handler omits the `| {ascension}` segment when None. Confirm before B-2 close. + - Where does the bundled `locations.json` live in source tree — `src/poe2_rpc/locations.json` (canonical) or root-level + `package-data` mapping? **Plan assumes the latter** for single source of truth at repo root. Confirm before F-2. + - Does `--debug-watchdog` CLI flag stay or get cut for v1? **Plan assumes stays** as a triage aid for PM-1; cheap to add at E-1. Confirm before E-1. + +--- + +## Plan Summary + +**Plan saved to:** `.omc/plans/ralplan-architecture-libraries.md` + +**Scope:** +- 35 tasks across 7 epic phases (A–G), spanning ~26 new files in `src/poe2_rpc/` and ~28 new test files +- Estimated complexity: HIGH + +**Key Deliverables:** +1. `src/poe2_rpc/` hexagonal package with strict domain/application/infrastructure separation; orchestrator uses factory injection (Principle 4) +2. Sub-second log streaming (`watchdog`) replacing 5s polling; bounded queue with selective drop policy + stall detector +3. `tenacity`-backed Discord IPC reconnect with split policies (connect: 5×, publish: 3×) and structured telemetry +4. `pydantic-settings` config (Windows `%APPDATA%` default) + `structlog` observability + `typer` CLI with `validate-config --no-discord` +5. PyInstaller `--onefile` build emitting `PathOfExile2DiscordRPC.exe` (unchanged artifact name) with expanded `hiddenimports` + `--collect-submodules` +6. CI split into `lint-and-test` (Ubuntu) + `build` (Windows) with **per-job path-filters** + import-linter layered contract +7. Cold-start benchmark (F-4) and 10-step live-smoke checklist (G-4) gating release + +**Consensus mode (Iteration 2):** +- RALPLAN-DR: 5 Principles (3/4/5 reworded), 3 ranked Drivers, 3 Options (Option C invalidation re-defended on technical grounds), 3-scenario Pre-mortem, 9-tier Test Plan (added Cold-start) +- ADR: Behavior Changes vs main.py (4 items), Decision, 3 Drivers, 5 Alternatives considered with why-not, Positive/Negative/Neutral consequences, 5 Follow-ups +- DELIBERATE additions: Pre-mortem (PM-1/PM-2/PM-3) + Expanded test plan + AC#2/#6/#7 enforcement tests + cold-start benchmark + live-smoke checklist all present diff --git a/.omc/specs/deep-dive-architecture-libraries.md b/.omc/specs/deep-dive-architecture-libraries.md new file mode 100644 index 0000000..14cfa06 --- /dev/null +++ b/.omc/specs/deep-dive-architecture-libraries.md @@ -0,0 +1,416 @@ +# Deep Dive Spec: architecture-libraries-selection + +## Goal + +Перевести `Path-Of-Exile-2-RPC` с single-file pragmatic-MVP на полную **DDD + hexagonal** архитектуру с async event-bus, типизированными доменными моделями и event-driven log streaming. Целевая платформа — Windows desktop (.exe через PyInstaller). Целевой стек — современная Python экосистема, выбранная по docs из Context7. + +**Outcome качества (по 4 приоритетам):** +1. **Latency**: sub-second отклик на изменение лога (вместо 5s polling) — `watchdog.Observer` + `ReadDirectoryChangesW` API. +2. **Reliability**: structured retry с логированием попыток + auto-reconnect Discord IPC — `tenacity.retry`. +3. **Testability**: 100% доменного слоя покрыто `pytest` unit-тестами; адаптеры интеграционно — domain не зависит от инфраструктуры через `typing.Protocol`. +4. **Observability/Config**: `structlog` (JSON для prod, console для dev) + `pydantic-settings` (config.toml + env-overrides) + `typer` CLI. + +## Constraints + +- **OS target**: Windows (PoE2 client), но кодбаза должна линтиться и тестироваться на macOS/Linux (CI Ubuntu-runner для тестов, Windows-runner для сборки). +- **Single-binary distribution**: PyInstaller `--onefile` (не `--onedir`) — пользователь скачивает один `.exe` из GitHub Releases. +- **Локации в .exe**: `locations.json` упаковывается в bundle через `datas=[('locations.json', '.')]` в spec-файле и читается через `importlib.resources` — это единственный источник по умолчанию, без runtime fetch. +- **Discord update rate-limit**: `pypresence` минимальный интервал между `update()` — 15 секунд (по docs). Domain-события могут лететь чаще — application-слой батчит/throttle до 15s. +- **Backward compatibility артефакта**: имя выходного файла — `PathOfExile2DiscordRPC.exe` (релизные ассеты ссылаются на это имя). +- **Discord App ID**: `1315800372207419504` — продолжаем использовать. +- **Python version**: 3.11+ (для `typing.Self`, `tomllib` встроенный, structural pattern matching). + +## Non-Goals + +- Не поддерживаем не-Steam-клиент PoE2 в этом цикле (отдельная задача в README — `PathOfExileSteam.exe` остаётся единственным detection target; абстракция `GameDetector` подготовит будущее расширение). +- Не делаем cross-platform desktop UI / трей-иконку (отдельный epic). +- Не вводим IPC между процессами / web-API / телеметрию во внешний сервис. +- Не используем DI-фреймворк (`dependency-injector`, `punq`) — конструкторная инъекция руками в `cli.py`. + +## Acceptance Criteria + +- [ ] Пакет `src/poe2_rpc/` с явными слоями `domain/`, `application/`, `infrastructure/`, `cli.py`. +- [ ] Все доменные модели — `pydantic.BaseModel(model_config=ConfigDict(frozen=True))` либо `Enum`. Глобального изменяемого state в коде нет. +- [ ] Все ports описаны как `typing.Protocol` в `domain/ports.py`. Доменный слой не импортирует ничего из `infrastructure/`. +- [ ] `WatchdogLogStream` — event-driven, `Observer.schedule(handler, log_dir, recursive=False, event_filter=[FileModifiedEvent])`. На Windows используется `ReadDirectoryChangesW`-наблюдатель из `watchdog.observers`. Если стартовать observer не удалось, поднимается `RuntimeError` — приложение завершается с понятной ошибкой (никакого скрытого polling-режима). +- [ ] `PypresencePublisher` использует `AioPresence` (asyncio-вариант) и обёрнут в `@retry(stop=stop_after_attempt(5), wait=wait_exponential(min=2, max=32), retry=retry_if_exception_type((ConnectionError, OSError, InvalidPipe)), before_sleep=before_sleep_log(logger, logging.WARNING))`. +- [ ] `AppSettings(BaseSettings)` загружает в порядке приоритета: CLI-args → env (`POE2RPC_*`) → `~/.config/poe2-rpc/config.toml` → defaults. +- [ ] `structlog` сконфигурирован в `infrastructure/logging.py`: `ConsoleRenderer` если `sys.stderr.isatty()`, иначе `JSONRenderer`. Контекст (`username`, `character_class`, `area`) биндится через `structlog.contextvars.bind_contextvars`. +- [ ] CLI `typer`-приложение: `poe2-rpc run` (default), `poe2-rpc once` (одно обновление и выход), `poe2-rpc validate-config`, `poe2-rpc --version`. +- [ ] `pytest` + `pytest-asyncio`: unit-тесты на parsers (regex contracts из CLAUDE.md), classes, locations, throttle, event-bus. Integration-тесты на `WatchdogLogStream` через `tmp_path` fixture. +- [ ] `mypy --strict src/poe2_rpc` проходит без ошибок. +- [ ] `ruff check` + `ruff format` проходят. +- [ ] `pyinstaller PathOfExile2DiscordRPC.spec` собирает рабочий `.exe` идентичный по UX текущему. +- [ ] CI workflow обновлён: path-filter `['src/**', 'PathOfExile2DiscordRPC.spec', 'pyproject.toml', 'locations.json']`, добавлены jobs `lint`, `typecheck`, `test` перед `build`. +- [ ] Regex contracts (`regex_level`, `regex_instance` из CLAUDE.md) сохранены **верхально** — переехали в `infrastructure/parsing.py` как class-level constants, контракт парсинга не изменился. + +## Assumptions Exposed + +- Polling 5s заметен пользователю — переход на watchdog даст видимое улучшение. (Можно подтвердить только тестом на живом game-сессии.) +- Размер .exe вырастет на ~5-8 MB из-за pydantic + watchdog + structlog. Это приемлемо для desktop-tool. +- `pypresence.AioPresence` стабильно работает в production (используется широко, see Context7 snippet count = 88). +- `watchdog` на Windows корректно ловит модификации `Client.txt` который пишется самим PoE2 (некоторые игры мапят файл и не триггерят inotify-аналог — но `Client.txt` пишется обычным append'ом по логам issue tracker watchdog). +- CI Windows-runner справляется с PyInstaller сборкой за разумное время (<5 минут). + +## Technical Context + +### Bounded Contexts (DDD) + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Game Context │ │ Parsing Context │ │ Presence Context │ +│ │ │ │ │ │ +│ - GameProcess │ │ - LogLine │ │ - PresenceState │ +│ - GameLogPath │──│ - LevelInfo │──│ - DisplayDetails │ +│ - DetectionEvent │ │ - InstanceInfo │ │ - Throttle │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ + │ + ┌──────┴───────────┐ + │ Config Context │ + │ │ + │ - AppSettings │ + │ - LocationCatalog│ + │ - CharacterClass │ + └──────────────────┘ +``` + +**Domain Events (между контекстами):** +- `GameStarted(log_path: Path)` — Game ⇒ Parsing +- `GameStopped()` — Game ⇒ Presence +- `CharacterLevelChanged(level_info: LevelInfo)` — Parsing ⇒ Presence +- `AreaEntered(instance_info: InstanceInfo)` — Parsing ⇒ Presence + +### Hexagonal Layout + +``` +src/poe2_rpc/ +├── __init__.py +├── __main__.py # python -m poe2_rpc → cli.app() +├── cli.py # Typer app, composition root +├── __version__.py +│ +├── domain/ # Pure logic, no I/O imports +│ ├── __init__.py +│ ├── events.py # GameStarted, CharacterLevelChanged, ... +│ ├── models.py # LevelInfo, InstanceInfo (frozen pydantic) +│ ├── classes.py # CharacterClass, ClassAscendency enums +│ ├── locations.py # Location, LocationCatalog VOs +│ └── ports.py # Protocols: GameDetector, LogStream, PresencePublisher, EventBus, LocationCatalog +│ +├── application/ # Use cases / orchestration +│ ├── __init__.py +│ ├── bus.py # AsyncioEventBus (subscribe/publish) +│ ├── throttle.py # PresenceThrottle (15s rate-limit) +│ ├── handlers.py # on_level_changed, on_area_entered → PresencePublisher +│ └── orchestrator.py # App: bootstrap detection → streaming → parsing → presence +│ +├── infrastructure/ # Adapters (touch external world) +│ ├── __init__.py +│ ├── detection.py # PsutilGameDetector implements GameDetector +│ ├── streaming.py # WatchdogLogStream implements LogStream +│ ├── parsing.py # RegexLogParser (line → domain events) +│ ├── presence.py # PypresencePublisher (AioPresence + tenacity) +│ ├── settings.py # AppSettings(BaseSettings) +│ ├── catalog.py # JsonLocationCatalog (loads locations.json) +│ └── logging.py # configure_structlog() +│ +└── py.typed # PEP 561 marker +``` + +### Library Stack (Context7-backed) + +| Категория | Библиотека | Library ID | Роль | +|-----------|------------|------------|------| +| Domain models | **pydantic** ≥2.7 | `/pydantic/pydantic` | `LevelInfo`, `InstanceInfo`, `Location` — frozen models с runtime-валидацией | +| Config | **pydantic-settings** ≥2.5 | `/pydantic/pydantic-settings` | `AppSettings(BaseSettings)` + `TomlConfigSettingsSource` | +| Log streaming | **watchdog** ≥4.0 | `/gorakhargosh/watchdog` | `Observer` + `FileModifiedEvent` handler | +| Retry | **tenacity** ≥8.4 | `/jd/tenacity` | `@retry` декораторы для IPC connect/update | +| Logging | **structlog** ≥24.1 | `/hynek/structlog` | `ConsoleRenderer` (dev) / `JSONRenderer` (prod) + `bind_contextvars` | +| CLI | **typer** ≥0.12 | `/fastapi/typer` | `run`, `once`, `validate-config`, `--config` | +| Discord IPC | **pypresence** ≥4.3 | `/websites/qwertyquerty.../pypresence` | `AioPresence` (async) + retry-обёртка | +| Process detect | **psutil** ≥5.9 | (existing) | Wrapped в `PsutilGameDetector` | +| Testing | **pytest** + **pytest-asyncio** + **pytest-mock** | — | Unit + integration | +| Type check | **mypy** ≥1.10 | — | `--strict` в CI | +| Lint/format | **ruff** ≥0.5 | — | replaces black + isort + flake8 | +| Packaging | **PyInstaller** ≥6.14 | `/pyinstaller/pyinstaller` | `.spec` с `datas` и `hiddenimports` | + +### Key Pattern Snippets + +**Domain event (frozen):** +```python +# domain/events.py +from pydantic import BaseModel, ConfigDict +from .models import LevelInfo, InstanceInfo + +class CharacterLevelChanged(BaseModel): + model_config = ConfigDict(frozen=True) + level_info: LevelInfo + +class AreaEntered(BaseModel): + model_config = ConfigDict(frozen=True) + instance_info: InstanceInfo +``` + +**Port (Protocol):** +```python +# domain/ports.py +from typing import Protocol, AsyncIterator + +class LogStream(Protocol): + def lines(self) -> AsyncIterator[str]: ... + +class PresencePublisher(Protocol): + async def publish(self, details: str, state: str, *, small_image: str) -> None: ... + async def close(self) -> None: ... +``` + +**Adapter (watchdog):** +```python +# infrastructure/streaming.py +import asyncio +from pathlib import Path +from watchdog.events import FileModifiedEvent, FileSystemEventHandler +from watchdog.observers import Observer + +class WatchdogLogStream: + def __init__(self, log_path: Path, loop: asyncio.AbstractEventLoop): + self._log_path = log_path + self._loop = loop + self._queue: asyncio.Queue[str] = asyncio.Queue() + self._offset = log_path.stat().st_size # seek-EOF + self._observer = Observer() + + async def lines(self) -> AsyncIterator[str]: + handler = self._Handler(self) + self._observer.schedule(handler, str(self._log_path.parent), recursive=False) + self._observer.start() + try: + while True: + yield await self._queue.get() + finally: + self._observer.stop() + self._observer.join() + + class _Handler(FileSystemEventHandler): + def __init__(self, stream: "WatchdogLogStream"): self._stream = stream + def on_modified(self, event: FileModifiedEvent) -> None: + if Path(event.src_path) != self._stream._log_path: return + with self._stream._log_path.open("r", encoding="utf-8") as f: + f.seek(self._stream._offset) + new = f.read() + self._stream._offset = f.tell() + for line in new.splitlines(): + self._stream._loop.call_soon_threadsafe( + self._stream._queue.put_nowait, line + ) +``` + +**Adapter (pypresence + tenacity):** +```python +# infrastructure/presence.py +import logging +import structlog +from pypresence import AioPresence, InvalidPipe +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type, before_sleep_log + +logger = structlog.get_logger() +_stdlib_logger = logging.getLogger(__name__) + +class PypresencePublisher: + def __init__(self, client_id: str): + self._rpc = AioPresence(client_id) + self._connected = False + + @retry( + stop=stop_after_attempt(5), + wait=wait_exponential(multiplier=1, min=2, max=32), + retry=retry_if_exception_type((ConnectionError, OSError, InvalidPipe)), + before_sleep=before_sleep_log(_stdlib_logger, logging.WARNING), + ) + async def connect(self) -> None: + await self._rpc.connect() + self._connected = True + logger.info("presence_connected") + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8), + retry=retry_if_exception_type((InvalidPipe, OSError))) + async def publish(self, details: str, state: str, *, small_image: str, start: int) -> None: + if not self._connected: + await self.connect() + await self._rpc.update(details=details, state=state, start=start, small_image=small_image) +``` + +**Settings:** +```python +# infrastructure/settings.py +from pathlib import Path +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict, TomlConfigSettingsSource + +class AppSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="POE2RPC_", + toml_file=str(Path.home() / ".config" / "poe2-rpc" / "config.toml"), + ) + discord_client_id: str = "1315800372207419504" + process_name: str = "PathOfExileSteam.exe" + presence_min_interval_seconds: float = 15.0 + log_level: str = "INFO" + log_json: bool = False + locations_url: str | None = None # None ⇒ bundled JSON; URL ⇒ явный override + + @classmethod + def settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings): + return (init_settings, env_settings, TomlConfigSettingsSource(settings_cls), file_secret_settings) +``` + +**CLI composition root:** +```python +# cli.py +import asyncio +import typer +from .infrastructure.settings import AppSettings +from .infrastructure.logging import configure_structlog +from .infrastructure.detection import PsutilGameDetector +from .infrastructure.streaming import WatchdogLogStream +from .infrastructure.parsing import RegexLogParser +from .infrastructure.presence import PypresencePublisher +from .infrastructure.catalog import JsonLocationCatalog +from .application.bus import AsyncioEventBus +from .application.orchestrator import Orchestrator + +app = typer.Typer(help="Discord Rich Presence for Path of Exile 2") + +@app.command() +def run(config: Path | None = None) -> None: + settings = AppSettings(_toml_file=config) if config else AppSettings() + configure_structlog(level=settings.log_level, json_output=settings.log_json) + asyncio.run(_run_async(settings)) + +async def _run_async(settings: AppSettings) -> None: + bus = AsyncioEventBus() + detector = PsutilGameDetector(settings.process_name) + catalog = ( + JsonLocationCatalog.from_url(settings.locations_url) + if settings.locations_url + else JsonLocationCatalog.from_bundled() + ) + publisher = PypresencePublisher(settings.discord_client_id) + parser = RegexLogParser() + orchestrator = Orchestrator(detector, parser, publisher, catalog, bus, settings) + await orchestrator.run() +``` + +### PyInstaller `.spec` + +```python +# PathOfExile2DiscordRPC.spec +# -*- mode: python ; coding: utf-8 -*- +a = Analysis( + ['src/poe2_rpc/__main__.py'], + pathex=['src'], + binaries=[], + datas=[('locations.json', '.')], + hiddenimports=[ + 'watchdog.observers.read_directory_changes', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, a.scripts, a.binaries, a.datas, + name='PathOfExile2DiscordRPC', + debug=False, strip=False, upx=False, + console=True, onefile=True, +) +``` + +### CI Updates + +```yaml +# .github/workflows/build.yml (changes) +on: + push: + branches: [main] + paths: ['src/**', 'PathOfExile2DiscordRPC.spec', 'pyproject.toml', 'locations.json'] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.11' } + - run: pip install -e ".[dev]" + - run: ruff check src tests + - run: mypy --strict src/poe2_rpc + - run: pytest + + build: + needs: lint-and-test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.11' } + - run: pip install -e . + - run: pip install pyinstaller + - run: pyinstaller PathOfExile2DiscordRPC.spec + # ...rest unchanged (tag + release) +``` + +## Ontology + +| Term | Definition | +|------|------------| +| **GameLogPath** | Абсолютный `Path` к `Client.txt` внутри `/steamapps/common/Path of Exile 2/logs/`. | +| **LevelInfo** | VO `(username: str, base_class: str, ascension_class: str \| None, level: int)`. Frozen pydantic. | +| **InstanceInfo** | VO `(area_code: str, area_display_name: str, level: int, seed: int)`. | +| **LocationCatalog** | Read-only mapping `area_code → display_name`. Загружается из bundled `locations.json` (упакован в .exe через `--add-data`). Если в `AppSettings.locations_url` явно задан URL, каталог грузится с него — это override, не цепочка. | +| **PresenceState** | Текущее представление состояния для Discord: `details` (строка с username/класс/уровень) + `state` (зона или random_status) + `small_image` (`ascension_class.lower().replace(" ", "_")`). | +| **DomainEvent** | Frozen pydantic-модель из `domain/events.py`. Публикуется в `EventBus`. | +| **Adapter** | Класс из `infrastructure/`, реализующий `Protocol` из `domain/ports.py`. | +| **Composition root** | Единственное место, где собираются адаптеры — `cli.py`. | + +## Ontology Convergence + +- Текущие dict-based `level_info` / `instance_info` → frozen pydantic VO с одинаковыми именами полей (zero-rename миграция). +- Текущий `current_status` dict → удаляется, заменяется явным state в `Orchestrator`. +- Текущий global `rpc` → инкапсулирован в `PypresencePublisher`. +- `random_status` → переезжает в `application/throttle.py` или `domain/presence_state.py` как pure-function `default_state_text() -> str`. + +## Trace Findings + +Из Phase 3 trace (см. `deep-dive-trace-architecture-libraries.md`): + +- **Lane 1 (code structure)**: глобальный `rpc`, monolithic `monitor_log()` с 4 ответственностями, dict-state-bag `current_status`, дублирование форматирования `details`. Естественные границы доменов уже проступают (game/parsing/presence/config). +- **Lane 2 (distribution)**: PyInstaller `--onefile` + path-filter совместимы с `src/`-layout при правке CI; spec-файл позволяет упаковать `locations.json` как data. +- **Lane 3 (Context7 best practices)**: + - watchdog: `Observer + FileModifiedEvent` — нативный Windows API. + - tenacity: `wait_exponential + retry_if_exception_type + before_sleep_log` покрывает текущий manual retry с улучшением логирования. + - pydantic-settings: `TomlConfigSettingsSource` + env-overrides — один источник правды для конфига. + - pypresence: `AioPresence` для async; min update interval — 15 секунд. + - structlog: `ConsoleRenderer` для dev / `JSONRenderer` для prod, `contextvars.bind_contextvars` для rich-контекста. + - PyInstaller: `.spec`-файл с `datas` + `hiddenimports`. + +Все 3 per-lane critical unknowns разрешены пользователем выбором "Aggressive DDD" + 4 priorities — это даёт чёткий мандат на полную перестройку. + +## Interview Transcript (compressed) + +**Round 1 (lane confirmation):** +- Predloženo: Lane 1 = code structure, Lane 2 = distribution, Lane 3 = Context7 strict. +- Выбор: "Lane 3 — только Context7" — Context7-driven library research как ведущая линия. + +**Round 2 (architectural direction):** +- Predloženo: Conservative / Balanced / Aggressive DDD. +- Выбор: **Aggressive DDD** — bounded contexts, hexagonal, async event bus, AioPresence, 100% type coverage, mypy strict. + +**Round 3 (priorities):** +- Predloženo: latency / reliability / testability / observability. +- Выбор: **все 4** — полный outcome без компромиссов. + +Ambiguity: ≈10% (направление + приоритеты явно зафиксированы; технические детали в Acceptance Criteria и снипетах). diff --git a/.omc/specs/deep-dive-trace-architecture-libraries.md b/.omc/specs/deep-dive-trace-architecture-libraries.md new file mode 100644 index 0000000..4ea6b2c --- /dev/null +++ b/.omc/specs/deep-dive-trace-architecture-libraries.md @@ -0,0 +1,94 @@ +# Deep Dive Trace: architecture-libraries-selection + +## Observed Result +Текущая архитектура `Path-Of-Exile-2-RPC` — single-file `main.py` (~330 LOC) с регексным парсингом `Client.txt`, polling-tail-loop (`time.sleep(5)`), ручной retry-loop для Discord IPC, JSON-локациями и enum-классификацией. Зависимости: `psutil`, `pypresence`. Тестов нет. Сборка: PyInstaller `--onefile` через CI с path-filter `paths: ['main.py']`. Пользователь хочет архитектурные рекомендации и набор библиотек/фреймворков по best-practices через Context7. + +## Ranked Hypotheses +| Rank | Lane | Confidence | Evidence Strength | Why it leads | +|------|------|------------|-------------------|--------------| +| 1 | Lane 3 (Library landscape via Context7) | High | Strong (Context7 docs покрывают все нужные либы) | Прямой отклик на запрос пользователя — он сам попросил "через context7 mcp" | +| 2 | Lane 1 (Code structure) | High | Strong (main.py прочитан целиком) | Структура очевидна: 7 функций + 2 enum'а, нет границ доменов, glob state `rpc` | +| 3 | Lane 2 (Distribution constraints) | Medium | Strong (build.yml + CLAUDE.md) | Ограничения известны точно, но они не "причина", а "контейнер" решения | + +## Evidence Summary by Hypothesis + +### Lane 1 — Code structure (current architecture) +- **Глобальное состояние**: `rpc` создаётся в `__main__` и читается из `update_rpc()` через free-name lookup (line 263). Скрытая зависимость. +- **Coupling**: `monitor_log()` (lines 277–325) делает 4 вещи: discovery, initial state, tail loop, dispatch. Inline-дубликат форматирования `details` (lines 285–293 ≈ 254–262). +- **Domain boundaries** (естественные): + 1. **Game discovery** — `find_game_log()` ищет `PathOfExileSteam.exe`. Hardcoded: только Steam-build (см. open work в README). + 2. **Log streaming** — open + seek-EOF + `readlines()` + `sleep(5)`. Polling 5s = заметная задержка. + 3. **Parsing** — 2 регекса (`regex_level`, `regex_instance`). Нормальная инкапсуляция. + 4. **Domain mapping** — `CharacterClass`/`ClassAscendency` enum'ы + двусторонний lookup. Чистый VO/lookup. + 5. **Location resolution** — `determine_location()` + JSON cache + remote fallback. Смешан I/O и лукап. + 6. **Presence push** — `rpc_connect()` + `update_rpc()`. Manual retry: `time.sleep(2**retries)`. +- **Anti-patterns**: + - `find_last_level_up()` называется как find_last но обрабатывает одну строку. + - `current_status` — словарь-state-bag вместо value-object. + - `random_status` — UI-flavour смешан с domain-state. + - Нет separation of concerns между orchestration и I/O. + +### Lane 2 — Distribution constraints +- **CI path filter**: `paths: ['main.py']` — изменения в других файлах НЕ триггерят сборку. Если разделить на модули, CI должен меняться синхронно. +- **PyInstaller `--onefile --name PathOfExile2DiscordRPC main.py`** — нет spec-файла, нет `--add-data`, поэтому `locations.json` НЕ упакован в .exe (он подгружается с GitHub при первом запуске). +- **Hidden imports**: `psutil` и `pypresence` обычно подхватываются автоматически, но при добавлении watchdog/aiofiles/pydantic потребуется проверить. +- **Альтернативы packaging**: spec-файл (PyInstaller), Briefcase (BeeWare), Nuitka (компиляция в C). Все совместимы с однофайловой выдачей. +- **Перевод на `src/` layout совместим** при условии: добавить `pyinstaller PathOfExile2DiscordRPC.spec` в CI и расширить path-filter (`paths: ['src/**', 'PathOfExile2DiscordRPC.spec']`). + +### Lane 3 — Library landscape via Context7 + +| Библиотека | ID | Версия | Применение | Score | +|------------|----|----|-----------|-------| +| **watchdog** | `/gorakhargosh/watchdog` | latest | Замена `sleep+readlines` на `Observer + FileModifiedEvent` (event-driven tail) | 81.3 | +| **tenacity** | `/jd/tenacity` | latest | Замена ручного `2**retries` на `@retry(wait=wait_exponential, stop=stop_after_attempt, before_sleep=before_sleep_log)` | 86.4 | +| **pydantic** | `/pydantic/pydantic` | latest | Value-objects: `LevelInfo`, `InstanceInfo`, `LocationCatalog`. Type-safe immutable models | 83.3 | +| **pydantic-settings** | `/pydantic/pydantic-settings` | latest | `BaseSettings` + `TomlConfigSettingsSource` — пользовательский `config.toml` (client_id, poll_interval, log_path override) | 86.8 | +| **pypresence** | `/websites/qwertyquerty.../pypresence` | current | Уже используется. Альтернатива `AioPresence` для async. Минимальный update interval — 15 сек | 61.5 | +| **PyInstaller** | `/pyinstaller/pyinstaller` | v6.14 | Перейти на `.spec`-файл с `--add-data locations.json:.` и `--hidden-import` для новых либ | 83.1 | +| **structlog** | `/hynek/structlog` | latest | Замена `logging.basicConfig` — `ConsoleRenderer` для dev / `JSONRenderer` для prod, `bind_contextvars` для контекста | 92.6 | +| **typer** | `/fastapi/typer` | 0.21 | CLI-флаги: `--config`, `--log-level`, `--dry-run`, `--once` | 91.0 | + +**Ключевые findings из docs:** +- `watchdog`: на Windows работает через `ReadDirectoryChangesW` API — нативно, без polling. `Observer.schedule(handler, path, recursive=False)` + `on_modified` хук, читать дельту через сохранённый offset. +- `tenacity`: `@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=32), retry=retry_if_exception_type((ConnectionError, OSError)), before_sleep=before_sleep_log(logger, logging.WARNING))` ровно покрывает текущий retry pattern и логирует попытки. +- `pydantic-settings`: TOML-config + env-overrides + CLI defaults — single source of truth. +- `pypresence`: текущее использование корректно; для будущей AFK-фичи имеет смысл перейти на `AioPresence` (asyncio). +- `structlog`: `ConsoleRenderer` для dev / `JSONRenderer` для prod; `contextvars.bind_contextvars(username=..., character_class=...)` даёт rich-контекст без передачи через каждый вызов. +- `PyInstaller`: `.spec`-файл позволяет указать `datas=[('locations.json', '.')]` — `locations.json` будет внутри .exe и не нужен сетевой fallback (либо оставить как fallback для свежих версий). + +## Evidence Against / Missing Evidence + +- **Lane 1**: Refactoring единственного файла увеличивает нагрузку поддержки соло-разработчика; CI потребует изменений. Простота текущего main.py — это feature, не bug. +- **Lane 2**: Migration на `src/` layout — необратимое решение для CI/release pipeline. Возможен compromise: package layout + одноточечный entry, который PyInstaller всё равно собирает в один .exe. +- **Lane 3**: Каждая новая зависимость увеличивает размер .exe (PyInstaller bundle растёт ~2-5 MB на крупную либу). Watchdog нативно тянет `pywin32`-style hooks. Pydantic — самая тяжёлая по размеру (но даёт максимум value). + +## Per-Lane Critical Unknowns + +- **Lane 1 (Code structure)**: Готов ли пользователь перейти с single-file на multi-module package layout, или важнее сохранить простоту `main.py` с минимальными improvements внутри одного файла? +- **Lane 2 (Distribution)**: Готов ли поменять CI path-filter и перейти на `.spec`-файл (упаковать `locations.json` в .exe), или сохранить текущий pipeline? +- **Lane 3 (Libraries)**: Какой набор библиотек приоритетен — full stack (watchdog + tenacity + pydantic + structlog + typer) или минималистичный bundle (только tenacity + pydantic-settings)? + +## Rebuttal Round + +- **Best rebuttal to leader (Lane 3)**: "Зачем full library stack для тулзы на 330 строк? Текущий код работает — over-engineering убьёт maintainability." +- **Why leader holds**: Пользователь явно попросил best-practices через Context7. Текущий код имеет реальные слабые места: 5-секундный polling вместо event-driven, ручной retry без логирования, scattered state. Это не cosmetic — это влияет на user experience (задержка обновления presence) и debuggability. +- **Why leader could fail**: Если приоритет — "не ломать", тогда решение = consolidation внутри одного файла + только 2 либы (tenacity + pydantic-settings). + +## Convergence / Separation Notes + +Lanes 1, 2, 3 — НЕ независимы. Любая существенная архитектурная перестройка (Lane 1) требует решения по distribution (Lane 2) и выбора либ (Lane 3). Ключевая развилка: **single-file evolution vs package-layout refactor**. Эта развилка определяет всё остальное. + +## Most Likely Explanation + +Текущая архитектура — pragmatic single-file MVP, который вырос до точки, где простые improvements (event-driven tail, structured retry, typed models) дали бы значительный выигрыш по reliability/observability/testability **без** обязательного ухода от single-file shape. Best-practice путь: **layered single-file** (или minimal package) + **5 целевых либ** (watchdog, tenacity, pydantic-settings, structlog, pytest) + **PyInstaller spec-file**. + +## Critical Unknown + +**Готов ли пользователь принять компромисс между "минимальный change footprint" и "максимальный outcome"?** От этого зависит выбор: keep single-file vs migrate to `src/` package, full library stack vs minimal additions. + +## Recommended Discriminating Probe + +Один вопрос пользователю с 3 вариантами архитектурного направления: +1. **Conservative** — keep single-file, добавить только tenacity + pydantic-settings. +2. **Balanced (рекомендуется)** — `src/poe2_rpc/` package с 5 модулями (parsers, presence, settings, locations, app) + 5 либ (watchdog, tenacity, pydantic, structlog, typer) + PyInstaller spec. +3. **Aggressive (DDD)** — bounded contexts (game, presence, config), pydantic VO, async event bus, full DI, 100% type coverage. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cec4fb0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ + + +# Path-Of-Exile-2-RPC + +## Purpose +A small, single-script Python utility that provides Discord Rich Presence integration for Path of Exile 2. The tool locates the running `PathOfExileSteam.exe` process, tails its `Client.txt` log, parses level-up and zone-generation events, and pushes a live presence update (character/class/ascendancy/zone) to Discord via `pypresence`. + +The whole runtime is intentionally one file (`main.py`) so it can be packaged into a single Windows `.exe` via PyInstaller in CI. + +## Key Files +| File | Description | +|------|-------------| +| `main.py` | Entire application: log discovery, regex parsing, RPC connect, monitor loop. Discord app ID `1315800372207419504`. | +| `locations.json` | Mapping of internal area codes (e.g. `G1_1`) to player-facing zone names (e.g. `The Riverbank`). Loaded from disk if present, otherwise fetched from the GitHub `main` branch on first run. | +| `requirements.txt` | Runtime dependencies: `psutil` (process discovery), `pypresence` (Discord IPC). | +| `README.md` | User-facing install/run instructions. | +| `LICENSE` | MIT license. | +| `.gitignore` | Ignores `.idea/` and `__pycache__/`. | + +## Subdirectories +| Directory | Purpose | +|-----------|---------| +| `.github/` | CI workflow + issue templates (see `.github/AGENTS.md`) | + +## For AI Agents + +### Working In This Directory +- Keep the runtime to `main.py`. The CI build (`.github/workflows/build.yml`) only triggers on changes to `main.py`, and PyInstaller is invoked as `pyinstaller --onefile --name PathOfExile2DiscordRPC main.py`. Splitting code into modules requires updating both the path filter and the PyInstaller call in lockstep. +- The `regex_level` pattern is `: (\w+) \(([\w\s]+)\) is now level (\d+)` and `regex_instance` is `Generating level (\d+) area "([^"]+)" with seed (\d+)`. Both target the literal log format produced by the Steam build of PoE2; verify against a real `Client.txt` sample before changing them. +- When adding a new ascendancy: extend `ClassAscendency` enum (value must match the in-game string exactly), add the entry to `ClassAscendency.get_class()`, and add it to the parent `CharacterClass.get_ascendencies()` list. Reference commit: `fe9c494` ("Add new character classes: Smith of Kitava, Lich, and Tactician"). +- The `small_image` field is derived as `ascension_class.lower().replace(" ", "_")`. Discord asset keys must therefore be lowercase + underscores (commit `5ae14e6` enforced this). Asset names that don't match this convention silently fall back to no image. +- `locations.json` is fetched from `https://raw.githubusercontent.com/ezbooz/Path-Of-Exile-2-RPC/refs/heads/main/locations.json` only when the local file is missing. If you change the schema, ship the updated `locations.json` in the same commit so existing installs upgrade on next launch. +- `monitor_log()` calls `log_file.readlines()` after `seek(0, 2)` and sleeps 5s — a deliberate append-only poll. The cadence matches how PoE2 buffers its log; preserve this approach. +- Process discovery hardcodes `PathOfExileSteam.exe`. Adding support for the official client (see README) means another explicit process-name check, not a regex. + +### Testing Requirements +- No automated test suite. Manual verification: launch the game, run `python main.py`, confirm Discord shows the expected presence; kill/relaunch Discord to exercise `rpc_connect` (5 retries with `time.sleep(2 ** retries)` backoff). +- No linter/formatter is enforced. Match existing style: 4-space indent, type hints on signatures, `logging` over `print`. + +### Common Patterns +- Log parsers return `Optional[Dict[str, str]]`; callers check truthiness. +- Module-level `logging` (configured at import) is used everywhere instead of `print`. +- File I/O uses `pathlib.Path` and explicit `encoding="utf-8"`. +- Retry loops use exponential backoff `time.sleep(2 ** retries)` (see `rpc_connect`). + +## Dependencies + +### External +- `psutil` — iterating processes to find `PathOfExileSteam.exe` and resolve its install directory. +- `pypresence` — Discord IPC client for the Rich Presence API. +- Stdlib: `datetime`, `json`, `logging`, `os`, `re`, `time`, `random`, `pathlib`, `enum`, `urllib.request`. + +### Runtime +- Discord desktop client must be running and authorized for app ID `1315800372207419504`. +- Path of Exile 2 (Steam build) must be installed and running. + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..25b84ce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md — Path-Of-Exile-2-RPC + +Project-level guidance for Claude Code working in this repository. Pair with the hierarchical `AGENTS.md` files for directory-specific detail. + +## Project at a glance +- **What it is:** A Discord Rich Presence integration for Path of Exile 2. Tails the game's `Client.txt`, parses level-up and area-generation events, and pushes presence updates via `pypresence`. +- **Shape:** Single-script Python app. The runtime entrypoint is `main.py`; everything else (`locations.json`, `requirements.txt`, GitHub config) supports it. +- **Distribution:** End users grab a prebuilt Windows `.exe` from GitHub Releases. The `.exe` is produced by `.github/workflows/build.yml` via PyInstaller `--onefile` whenever `main.py` changes on `main`. + +## Build & Test + +```bash +pip install -r requirements.txt +python main.py +``` + +Discord must be running. The script polls `psutil` for `PathOfExileSteam.exe` and only proceeds once the game is running — it will block on `Waiting for the game start..` otherwise. + +There is no automated test suite. Verification is manual: run the game, run the script, watch Discord for the expected presence, then kill/relaunch Discord to exercise `rpc_connect`'s 5-retry exponential-backoff loop. + +## Architecture Overview + +`main.py` is the whole app. Top to bottom: + +1. **Enums** (`CharacterClass`, `ClassAscendency`) — mappings between in-game class strings and ascendancies. The enum value is what appears in the log. +2. **`find_game_log()`** — `psutil.process_iter` loop hunting for `PathOfExileSteam.exe`; returns `/logs/Client.txt`. +3. **`load_locations()`** — reads `locations.json` from disk if present, otherwise downloads it from this repo's `main` branch and caches it. +4. **`determine_location()`** — turns an internal area code (e.g. `G1_4_BrambleghastSlain`) into a display name; map areas (`Map*`) get prefix-stripped and underscore-split before lookup. +5. **Parsers** — `find_last_level_up()` and `find_instance()` apply two precompiled regexes to log lines. +6. **`rpc_connect()`** — 5-attempt connect loop with `time.sleep(2 ** retries)` backoff against the Discord IPC socket (app ID `1315800372207419504`). +7. **`update_rpc()`** — formats presence details and sets `small_image = ascension_class.lower().replace(" ", "_")`. +8. **`monitor_log()`** — opens the log, seeks to EOF, then loops `readlines()` + `time.sleep(5)`, dispatching to `update_rpc()` whenever the parsed level or zone changes. + +## Conventions & Patterns + +- **Keep it one file.** CI's path-filter (`paths: ['main.py']`) and the PyInstaller call assume a single entrypoint. Splitting modules requires updating both in the same change. +- **Type hints + `logging`.** 4-space indent, type hints on signatures, `logging.info/error/warning` instead of `print`. +- **`pathlib.Path` + explicit `encoding="utf-8"`** for all file I/O. +- **Optional return shape:** parsers return `Optional[Dict[str, str]]`; the caller does the `if level_info:` check. +- **Exponential backoff** for retry loops (`time.sleep(2 ** retries)`), matching `rpc_connect`. + +## Adding a new ascendancy + +1. Add the enum member to `ClassAscendency` — value must match the in-game string verbatim (e.g. `"Smith of Kitava"`). +2. Add the mapping in `ClassAscendency.get_class()`. +3. Append it to the right list in `CharacterClass.get_ascendencies()`. +4. Upload the matching Discord asset using the **lowercase + underscore** key, since `update_rpc` derives `small_image` as `ascension_class.lower().replace(" ", "_")` (commit `5ae14e6` enforced this). + +Reference commit: `fe9c494` ("Add new character classes: Smith of Kitava, Lich, and Tactician"). + +## Adding/updating zones + +- Edit `locations.json` (the in-repo copy is the source of truth) and ship it in the same commit. +- Schema: `{"areas": {"": ""}}`. Internal codes look like `G1_1`, `G1_4_Brambleghast`, etc. +- `determine_location()` strips a leading `Map` prefix and splits on `_` for map-tier areas, so map-name lookups bypass `locations.json`. +- Existing installs auto-fetch `locations.json` from GitHub `main` only when the local file is **missing**. The upgrade path for cached installs is a new `.exe` release. + +## Regex contracts + +Don't break these without checking a real `Client.txt` sample: + +- `regex_level`: `r": (\w+) \(([\w\s]+)\) is now level (\d+)"` → `(username, base_or_ascendancy_class, level)`. +- `regex_instance`: `r'Generating level (\d+) area "([^"]+)" with seed (\d+)'` → `(level, area_code, seed)`. + +Both target the Steam-build log format. + +## CI / Release flow + +- Push to `main` touching `main.py` → `.github/workflows/build.yml` runs on `windows-latest`. +- A timestamp tag (`vYYYYMMDD-HHMMSS`) is created and pushed; the release job uploads `PathOfExile2DiscordRPC.exe` as a GitHub Release asset. + +## Open work (from README) + +- [ ] Launch as a background service when the game starts. +- [ ] Support the official PoE2 client (currently Steam-only via the hardcoded `PathOfExileSteam.exe` process name). +- [ ] Detect which player started the script (avoid party-conflict mis-detection). +- [ ] Show AFK status. + +## See also + +- `AGENTS.md` — hierarchical directory guide. +- `.github/AGENTS.md`, `.github/workflows/AGENTS.md`, `.github/ISSUE_TEMPLATE/AGENTS.md` — directory-specific notes. +- `README.md` — end-user instructions. + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/docs/plans/autopilot-vivid-hare.md b/docs/plans/autopilot-vivid-hare.md new file mode 100644 index 0000000..00d9973 --- /dev/null +++ b/docs/plans/autopilot-vivid-hare.md @@ -0,0 +1,140 @@ +# Architecture Plan: Path-Of-Exile-2-RPC → Layered Modulith + +## Context + +`main.py` is currently a 330-line single-file script that mixes enums, regex parsing, file I/O, process discovery, Discord IPC, and a tail loop with a module-level mutable `rpc` global. The README's open roadmap (background service, official-client support, per-player detection, AFK) cannot be added cleanly without architectural seams: each item touches multiple of the currently-fused concerns. + +Constraints making this non-trivial: +- CI is path-filtered to `paths: ['main.py']` and PyInstaller is invoked as `--onefile main.py`. +- No tests, type checker, or linter exist today; the regex parsers (the part most likely to silently break on a game patch) have no safety net. +- Single distributable artifact: one Windows `.exe` from GitHub Releases. + +The chosen direction is the **smallest possible split** that (a) makes parsing testable, (b) removes the global, and (c) makes each roadmap item a localized change instead of a rewrite. No DDD, no hexagonal layering, no event bus — this is a 330-LOC log tailer and stays that way. + +## Target layout + +``` +Path-Of-Exile-2-RPC/ + main.py # thin: build Config, RpcClient, Monitor; run + locations.json # unchanged + requirements.txt # unchanged (psutil, pypresence) + requirements-dev.txt # NEW: pytest + pyrightconfig.json # NEW: IDE-only type checking, no CI gate + poerpc/ + __init__.py + config.py # frozen dataclass Config + env-var overrides + classes.py # CharacterClass, ClassAscendency + parsing.py # regex parsers + determine_location (pure) + locations.py # JSON loader, local cache, GitHub fallback + game.py # process discovery → Client.txt path + rpc_client.py # RpcClient class, retry, owns Presence + monitor.py # Monitor class: tail loop + state machine + tests/ + __init__.py + fixtures/sample_client_lines.txt + test_parsing.py + .github/workflows/build.yml # path filter widened to include poerpc/** +``` + +## Symbol migration map + +| Current location in `main.py` | New home | +|---|---| +| `CharacterClass`, `ClassAscendency` | `poerpc/classes.py` | +| `regex_level`, `regex_instance`, `find_last_level_up`, `find_instance`, `get_last_level_up`, `determine_location`, `random_status` | `poerpc/parsing.py` | +| `load_locations` | `poerpc/locations.py` | +| `find_game_log` | `poerpc/game.py` (reads `config.process_names`) | +| `rpc_connect`, `update_rpc`, global `rpc` | `poerpc/rpc_client.py` as `RpcClient` | +| `monitor_log` | `poerpc/monitor.py` as `Monitor` | +| Hardcoded constants: `"PathOfExileSteam.exe"`, `"1315800372207419504"`, locations URL, `3`, `5`, `2**retries`, `5` retries | `poerpc/config.py` | +| `if __name__ == "__main__":` | `main.py` (~15 lines: build Config → RpcClient → Monitor; call `Monitor.run()`) | + +## Key design decisions + +**1. `RpcClient` class replaces the global `rpc`.** +Owns the `pypresence.Presence` instance. Public surface: `connect()`, `update(level_info, instance_info, status)`, `clear()`. `Monitor` receives it via constructor. The retry loop and exponential backoff move inside `connect()`. Result: parsing has zero hidden dependencies; the network layer is mockable. + +**2. `Config` is a frozen dataclass with env-var overrides at construction.** + +```python +@dataclass(frozen=True) +class Config: + process_names: tuple[str, ...] # ("PathOfExileSteam.exe",) initially + discord_app_id: str # env: POERPC_DISCORD_APP_ID + locations_url: str + poll_interval_s: float = 5.0 + process_scan_interval_s: float = 3.0 + rpc_max_retries: int = 5 + afk_timeout_s: int = 180 +``` + +This unlocks: +- **Official PoE2 client support** — extend the `process_names` tuple; `game.py` already iterates. +- **Custom Discord app** — `POERPC_DISCORD_APP_ID` env var read once at startup. + +No YAML/TOML config; one .exe + env vars is sufficient for a hobby tool. + +**3. Background-service roadmap item stays out of code.** +Recommend Windows Task Scheduler (trigger on process-start of `PathOfExile*.exe`) or NSSM in the README. In-process service wrappers (`pywin32`) bloat the .exe and add platform-specific failure modes. The current .exe + Task Scheduler is a documentation change, not a code change. + +**4. Per-player detection lives inside `Monitor`.** +Capture the first level-up line observed after the monitor starts; record `owner_username`; ignore subsequent level-ups for other usernames. Single field, no new module. + +**5. AFK detection lives inside `Monitor`.** +Track `last_event_at` timestamp. If `now - last_event_at > config.afk_timeout_s`, call `RpcClient.update(..., status="AFK")`. Single branch in the existing loop. + +## CI changes + +In `.github/workflows/build.yml`, widen the path filter: + +```yaml +paths: + - 'main.py' + - 'poerpc/**' + - 'requirements.txt' + - 'locations.json' +``` + +PyInstaller call stays as `pyinstaller --onefile --name PathOfExile2DiscordRPC main.py`. PyInstaller follows imports into `poerpc/` automatically; no spec file or `--add-data` needed. `locations.json` continues to be read from the working directory at runtime. + +## Test strategy + +`requirements-dev.txt` adds only `pytest`. `tests/test_parsing.py` covers: +- `regex_level`: ordinary class, ascendancy, special characters in usernames. +- `regex_instance`: hideouts, map areas, areas with quotes in the name. +- `determine_location`: `Map*` prefix stripping, exact-key match, fallback when key absent. + +No CI test job in v1. Adding a Windows-runner pytest job is a follow-up once a stable fixture set exists — adding it now invites flake before there's anything to check. + +Type checking: `pyrightconfig.json` with `include: ["poerpc", "main.py"]`, `strict: false`. IDE-only, no CI gate. Catches obvious `Optional` mistakes without blocking releases. + +## Migration sequence (each step ships independently) + +1. **Extract `poerpc/parsing.py` + `poerpc/classes.py`; add `tests/test_parsing.py`.** Update CI `paths:` filter. Behavior unchanged, regexes now pinned by tests. +2. **Extract `poerpc/config.py` + `poerpc/locations.py`.** Move URL and process name into `Config`. Add env-var read for Discord app id. +3. **Extract `poerpc/game.py`.** It iterates `config.process_names`. Official-client support now a one-string follow-up PR. +4. **Introduce `poerpc/rpc_client.py` as `RpcClient`.** Kill the global. `main.py` constructs it. +5. **Extract `poerpc/monitor.py` as `Monitor`.** `main.py` becomes ~15 lines. +6. **Add per-player owner detection and AFK tracking inside `Monitor`.** Uses `config.afk_timeout_s` and the level-up parser already extracted in step 1. +7. **Document Task Scheduler / NSSM recipe in `README.md`** for the background-service roadmap item. No code change. + +## Critical files to modify + +- `/Users/denn/PhpstormProjects/Path-Of-Exile-2-RPC/main.py` — reduce to wiring + entrypoint. +- `/Users/denn/PhpstormProjects/Path-Of-Exile-2-RPC/.github/workflows/build.yml` — widen `paths:` filter on lines 7–8. +- `/Users/denn/PhpstormProjects/Path-Of-Exile-2-RPC/requirements.txt` — unchanged. +- `/Users/denn/PhpstormProjects/Path-Of-Exile-2-RPC/CLAUDE.md` and `AGENTS.md` — update "Keep it one file" guidance after step 1 ships. +- New: `poerpc/` package, `tests/`, `requirements-dev.txt`, `pyrightconfig.json`. + +## Verification + +After each migration step: + +1. `pip install -r requirements.txt` — clean dependency install still succeeds. +2. `python main.py` — launches and reaches `Waiting for the game start..` (or connects RPC if PoE2 is running). +3. `pytest` (from step 1 onward) — parser tests pass. +4. With Discord and PoE2 running: confirm presence updates on level-up and area-change in Discord client. +5. Trigger CI build by editing a file matching the path filter; confirm `dist/PathOfExile2DiscordRPC.exe` is produced and a release tag is pushed. +6. Smoke-run the produced `.exe` on a Windows machine: presence updates appear identical to the pre-refactor build. + +End-state acceptance: the Roadmap items (official client, AFK, per-player) each become localized PRs touching one or two files in `poerpc/`, not a re-architecture. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..17a1b18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,127 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "poe2-rpc" +dynamic = ["version"] +description = "Discord Rich Presence integration for Path of Exile 2" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.11" +authors = [{ name = "ezbooz" }] +keywords = ["path-of-exile", "discord", "rich-presence", "rpc"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: Microsoft :: Windows", + "Topic :: Games/Entertainment", +] +dependencies = [ + "psutil>=5.9", + "pypresence>=4.3", + "watchdog>=4.0", + "tenacity>=8.5", + "pydantic>=2.7", + "pydantic-settings>=2.3", + "structlog>=24.1", + "typer>=0.12", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-mock>=3.12", + "mypy>=1.10", + "ruff>=0.5", + "import-linter>=2.0", + "pyinstaller>=6.14", + "types-psutil", +] + +[project.scripts] +poe2-rpc = "poe2_rpc.cli:app" + +[project.urls] +Homepage = "https://github.com/ezbooz/Path-Of-Exile-2-RPC" +Repository = "https://github.com/ezbooz/Path-Of-Exile-2-RPC" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +poe2_rpc = ["py.typed", "locations.json"] + +[tool.setuptools.dynamic] +version = { attr = "poe2_rpc.__version__.__version__" } + +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "RET", # flake8-return + "ARG", # flake8-unused-arguments + "PL", # pylint +] +ignore = [ + "PLR0913", # too-many-arguments — orchestrator legitimately takes many + "PLR2004", # magic-value-comparison — readable in tests +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["PLR2004", "ARG", "PLR0913", "S101"] + +[tool.mypy] +python_version = "3.11" +strict = true +mypy_path = "src" +files = ["src/poe2_rpc", "tests"] +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = ["psutil.*", "pypresence.*", "watchdog.*"] +ignore_missing_imports = true + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers", "--strict-config"] +asyncio_mode = "auto" +markers = [ + "live: requires running PoE2 + Discord (skipped by default)", +] + +[tool.importlinter] +root_package = "poe2_rpc" + +[[tool.importlinter.contracts]] +name = "Hexagonal layered architecture" +type = "layers" +layers = [ + "poe2_rpc.cli", + "poe2_rpc.application", + "poe2_rpc.infrastructure", + "poe2_rpc.domain", +] diff --git a/src/poe2_rpc/__init__.py b/src/poe2_rpc/__init__.py new file mode 100644 index 0000000..6361485 --- /dev/null +++ b/src/poe2_rpc/__init__.py @@ -0,0 +1,5 @@ +"""Path of Exile 2 — Discord Rich Presence integration.""" + +from poe2_rpc.__version__ import __version__ + +__all__ = ["__version__"] diff --git a/src/poe2_rpc/__main__.py b/src/poe2_rpc/__main__.py new file mode 100644 index 0000000..0ea5ebc --- /dev/null +++ b/src/poe2_rpc/__main__.py @@ -0,0 +1,4 @@ +"""Module entry point: ``python -m poe2_rpc``. + +Wired up in Phase E-2; intentionally a stub during Phase A scaffolding. +""" diff --git a/src/poe2_rpc/__version__.py b/src/poe2_rpc/__version__.py new file mode 100644 index 0000000..71f7133 --- /dev/null +++ b/src/poe2_rpc/__version__.py @@ -0,0 +1,5 @@ +"""Single source of truth for the package version.""" + +from typing import Final + +__version__: Final[str] = "0.2.0" diff --git a/src/poe2_rpc/application/__init__.py b/src/poe2_rpc/application/__init__.py new file mode 100644 index 0000000..2999d01 --- /dev/null +++ b/src/poe2_rpc/application/__init__.py @@ -0,0 +1 @@ +"""Application layer — orchestrator, bus, handlers, throttle. Depends on domain only.""" diff --git a/src/poe2_rpc/cli/__init__.py b/src/poe2_rpc/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/poe2_rpc/domain/__init__.py b/src/poe2_rpc/domain/__init__.py new file mode 100644 index 0000000..c1934cf --- /dev/null +++ b/src/poe2_rpc/domain/__init__.py @@ -0,0 +1 @@ +"""Pure domain layer — VOs, events, ports. No I/O, no framework imports.""" diff --git a/src/poe2_rpc/domain/classes.py b/src/poe2_rpc/domain/classes.py new file mode 100644 index 0000000..efb117c --- /dev/null +++ b/src/poe2_rpc/domain/classes.py @@ -0,0 +1,85 @@ +from enum import Enum +from typing import List, Optional + + +class CharacterClass(Enum): + MERCENARY = "Mercenary" + MONK = "Monk" + RANGER = "Ranger" + SORCERESS = "Sorceress" + WARRIOR = "Warrior" + WITCH = "Witch" + HUNTRESS = "Huntress" + + def get_ascendencies(self) -> Optional[List["ClassAscendency"]]: + return { + CharacterClass.MERCENARY: [ + ClassAscendency.WITCHHUNTER, + ClassAscendency.GEMLING_LEGIONNAIRE, + ], + CharacterClass.MONK: [ + ClassAscendency.ACOLYTE_OF_CHAYULA, + ClassAscendency.INVOKER, + ], + CharacterClass.RANGER: [ + ClassAscendency.DEADEYE, + ClassAscendency.PATHFINDER, + ], + CharacterClass.SORCERESS: [ + ClassAscendency.CHRONOMANCER, + ClassAscendency.STORMWEAVER, + ], + CharacterClass.WARRIOR: [ + ClassAscendency.TITAN, + ClassAscendency.WARBRINGER, + ], + CharacterClass.WITCH: [ + ClassAscendency.BLOOD_MAGE, + ClassAscendency.INFERNALIST, + ], + CharacterClass.HUNTRESS: [ + ClassAscendency.RITUALIST, + ClassAscendency.AMAZON, + ], + }.get(self) + + +class ClassAscendency(Enum): + WITCHHUNTER = "Witchhunter" + GEMLING_LEGIONNAIRE = "Gemling Legionnaire" + ACOLYTE_OF_CHAYULA = "Acolyte of Chayula" + INVOKER = "Invoker" + DEADEYE = "Deadeye" + PATHFINDER = "Pathfinder" + CHRONOMANCER = "Chronomancer" + STORMWEAVER = "Stormweaver" + TITAN = "Titan" + WARBRINGER = "Warbringer" + BLOOD_MAGE = "Blood Mage" + INFERNALIST = "Infernalist" + RITUALIST = "Ritualist" + AMAZON = "Amazon" + SMITH_OF_KITAVA = "Smith of Kitava" + LICH = "Lich" + TACTICIAN = "Tactician" + + def get_class(self) -> CharacterClass: + return { + ClassAscendency.WITCHHUNTER: CharacterClass.MERCENARY, + ClassAscendency.GEMLING_LEGIONNAIRE: CharacterClass.MERCENARY, + ClassAscendency.TACTICIAN: CharacterClass.MERCENARY, + ClassAscendency.ACOLYTE_OF_CHAYULA: CharacterClass.MONK, + ClassAscendency.INVOKER: CharacterClass.MONK, + ClassAscendency.DEADEYE: CharacterClass.RANGER, + ClassAscendency.PATHFINDER: CharacterClass.RANGER, + ClassAscendency.CHRONOMANCER: CharacterClass.SORCERESS, + ClassAscendency.STORMWEAVER: CharacterClass.SORCERESS, + ClassAscendency.TITAN: CharacterClass.WARRIOR, + ClassAscendency.WARBRINGER: CharacterClass.WARRIOR, + ClassAscendency.SMITH_OF_KITAVA: CharacterClass.WARRIOR, + ClassAscendency.BLOOD_MAGE: CharacterClass.WITCH, + ClassAscendency.INFERNALIST: CharacterClass.WITCH, + ClassAscendency.LICH: CharacterClass.WITCH, + ClassAscendency.RITUALIST: CharacterClass.HUNTRESS, + ClassAscendency.AMAZON: CharacterClass.HUNTRESS, + }[self] diff --git a/src/poe2_rpc/domain/events.py b/src/poe2_rpc/domain/events.py new file mode 100644 index 0000000..1f6fee8 --- /dev/null +++ b/src/poe2_rpc/domain/events.py @@ -0,0 +1,26 @@ +"""Frozen pydantic v2 domain event hierarchy.""" +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from poe2_rpc.domain.models import InstanceInfo, LevelInfo + + +class DomainEvent(BaseModel): + model_config = ConfigDict(frozen=True) + + +class GameStarted(DomainEvent): + log_path: Path + + +class GameStopped(DomainEvent): + pass + + +class CharacterLevelChanged(DomainEvent): + level_info: LevelInfo + + +class AreaEntered(DomainEvent): + instance_info: InstanceInfo diff --git a/src/poe2_rpc/domain/locations.py b/src/poe2_rpc/domain/locations.py new file mode 100644 index 0000000..4a5619a --- /dev/null +++ b/src/poe2_rpc/domain/locations.py @@ -0,0 +1,33 @@ +"""Location value object and catalog — pure domain logic, no I/O.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class Location(BaseModel): + model_config = ConfigDict(frozen=True) + + area_code: str + display_name: str + + +class LocationCatalog: + """Resolves area codes to display names using injected mapping.""" + + def __init__(self, areas: dict[str, str]) -> None: + self._areas = areas + + def resolve(self, area_code: str) -> Location: + normalized = area_code + + if area_code.startswith("Map"): + normalized = area_code[3:].split("_")[0] + + if normalized in self._areas.values(): + return Location(area_code=area_code, display_name=normalized) + + for key, value in self._areas.items(): + if normalized == key or normalized == value: + return Location(area_code=area_code, display_name=value) + + return Location(area_code=area_code, display_name=normalized) diff --git a/src/poe2_rpc/domain/models.py b/src/poe2_rpc/domain/models.py new file mode 100644 index 0000000..7b50ed6 --- /dev/null +++ b/src/poe2_rpc/domain/models.py @@ -0,0 +1,20 @@ +"""Frozen pydantic v2 value objects for the domain layer.""" +from pydantic import BaseModel, ConfigDict + + +class LevelInfo(BaseModel): + model_config = ConfigDict(frozen=True) + + username: str + base_class: str + ascension_class: str | None # None when player not yet ascended + level: int + + +class InstanceInfo(BaseModel): + model_config = ConfigDict(frozen=True) + + area_code: str + area_display_name: str + level: int + seed: int diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py new file mode 100644 index 0000000..29ce516 --- /dev/null +++ b/src/poe2_rpc/domain/ports.py @@ -0,0 +1,43 @@ +"""Domain port Protocols — all runtime_checkable, stdlib + domain imports only.""" +from __future__ import annotations + +from pathlib import Path +from typing import Iterator, Protocol, runtime_checkable + +from poe2_rpc.domain.events import DomainEvent +from poe2_rpc.domain.locations import Location +from poe2_rpc.domain.models import InstanceInfo, LevelInfo + + +@runtime_checkable +class GameDetector(Protocol): + def is_running(self) -> bool: ... + def log_path(self) -> Path: ... + + +@runtime_checkable +class LogStream(Protocol): + def lines(self) -> Iterator[str]: ... + + +@runtime_checkable +class LogParser(Protocol): + def parse_level(self, line: str) -> LevelInfo | None: ... + def parse_instance(self, line: str) -> InstanceInfo | None: ... + + +@runtime_checkable +class PresencePublisher(Protocol): + def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: ... + def close(self) -> None: ... + + +@runtime_checkable +class EventBus(Protocol): + def emit(self, event: DomainEvent) -> None: ... + def subscribe(self, handler: object) -> None: ... + + +@runtime_checkable +class LocationCatalogPort(Protocol): + def resolve(self, area_code: str) -> Location: ... diff --git a/src/poe2_rpc/infrastructure/__init__.py b/src/poe2_rpc/infrastructure/__init__.py new file mode 100644 index 0000000..a24915b --- /dev/null +++ b/src/poe2_rpc/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Infrastructure layer — concrete adapters (psutil, pypresence, watchdog, structlog, settings).""" diff --git a/src/poe2_rpc/py.typed b/src/poe2_rpc/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..30b80a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared pytest fixtures and configuration.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def sample_log_line_level() -> str: + """Sample Client.txt line that matches regex_level (verbatim contract).""" + return ( + "2024/01/01 12:00:00 12345 cffb0734 [INFO Client 9876] " + ": Foo (Witchhunter) is now level 42" + ) + + +@pytest.fixture +def sample_log_line_instance() -> str: + """Sample Client.txt line that matches regex_instance (verbatim contract).""" + return ( + "2024/01/01 12:00:00 12345 cffb0734 [DEBUG Client 9876] " + 'Generating level 5 area "G1_4_Brambleghast" with seed 12345' + ) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_classes.py b/tests/unit/test_classes.py new file mode 100644 index 0000000..43f6b16 --- /dev/null +++ b/tests/unit/test_classes.py @@ -0,0 +1,80 @@ +"""Tests for domain/classes.py — CharacterClass and ClassAscendency enums.""" +import pytest + +from poe2_rpc.domain.classes import CharacterClass, ClassAscendency + + +class TestCharacterClass: + def test_all_base_classes_present(self) -> None: + names = {m.name for m in CharacterClass} + assert names == { + "MERCENARY", "MONK", "RANGER", "SORCERESS", + "WARRIOR", "WITCH", "HUNTRESS", + } + + def test_enum_values_match_ingame_strings(self) -> None: + assert CharacterClass.MERCENARY.value == "Mercenary" + assert CharacterClass.MONK.value == "Monk" + assert CharacterClass.RANGER.value == "Ranger" + assert CharacterClass.SORCERESS.value == "Sorceress" + assert CharacterClass.WARRIOR.value == "Warrior" + assert CharacterClass.WITCH.value == "Witch" + assert CharacterClass.HUNTRESS.value == "Huntress" + + def test_get_ascendencies_returns_correct_list(self) -> None: + assert ClassAscendency.WITCHHUNTER in CharacterClass.MERCENARY.get_ascendencies() + assert ClassAscendency.GEMLING_LEGIONNAIRE in CharacterClass.MERCENARY.get_ascendencies() + assert ClassAscendency.TITAN in CharacterClass.WARRIOR.get_ascendencies() + assert ClassAscendency.WARBRINGER in CharacterClass.WARRIOR.get_ascendencies() + assert ClassAscendency.RITUALIST in CharacterClass.HUNTRESS.get_ascendencies() + assert ClassAscendency.AMAZON in CharacterClass.HUNTRESS.get_ascendencies() + + +class TestClassAscendency: + def test_all_ascendencies_present(self) -> None: + names = {m.name for m in ClassAscendency} + assert names == { + "WITCHHUNTER", "GEMLING_LEGIONNAIRE", "ACOLYTE_OF_CHAYULA", + "INVOKER", "DEADEYE", "PATHFINDER", "CHRONOMANCER", + "STORMWEAVER", "TITAN", "WARBRINGER", "BLOOD_MAGE", + "INFERNALIST", "RITUALIST", "AMAZON", "SMITH_OF_KITAVA", + "LICH", "TACTICIAN", + } + + def test_enum_values_match_ingame_strings(self) -> None: + assert ClassAscendency.WITCHHUNTER.value == "Witchhunter" + assert ClassAscendency.GEMLING_LEGIONNAIRE.value == "Gemling Legionnaire" + assert ClassAscendency.ACOLYTE_OF_CHAYULA.value == "Acolyte of Chayula" + assert ClassAscendency.INVOKER.value == "Invoker" + assert ClassAscendency.DEADEYE.value == "Deadeye" + assert ClassAscendency.PATHFINDER.value == "Pathfinder" + assert ClassAscendency.CHRONOMANCER.value == "Chronomancer" + assert ClassAscendency.STORMWEAVER.value == "Stormweaver" + assert ClassAscendency.TITAN.value == "Titan" + assert ClassAscendency.WARBRINGER.value == "Warbringer" + assert ClassAscendency.BLOOD_MAGE.value == "Blood Mage" + assert ClassAscendency.INFERNALIST.value == "Infernalist" + assert ClassAscendency.RITUALIST.value == "Ritualist" + assert ClassAscendency.AMAZON.value == "Amazon" + assert ClassAscendency.SMITH_OF_KITAVA.value == "Smith of Kitava" + assert ClassAscendency.LICH.value == "Lich" + assert ClassAscendency.TACTICIAN.value == "Tactician" + + def test_get_class_maps_ascendency_to_base_class(self) -> None: + assert ClassAscendency.WITCHHUNTER.get_class() == CharacterClass.MERCENARY + assert ClassAscendency.GEMLING_LEGIONNAIRE.get_class() == CharacterClass.MERCENARY + assert ClassAscendency.TACTICIAN.get_class() == CharacterClass.MERCENARY + assert ClassAscendency.ACOLYTE_OF_CHAYULA.get_class() == CharacterClass.MONK + assert ClassAscendency.INVOKER.get_class() == CharacterClass.MONK + assert ClassAscendency.DEADEYE.get_class() == CharacterClass.RANGER + assert ClassAscendency.PATHFINDER.get_class() == CharacterClass.RANGER + assert ClassAscendency.CHRONOMANCER.get_class() == CharacterClass.SORCERESS + assert ClassAscendency.STORMWEAVER.get_class() == CharacterClass.SORCERESS + assert ClassAscendency.TITAN.get_class() == CharacterClass.WARRIOR + assert ClassAscendency.WARBRINGER.get_class() == CharacterClass.WARRIOR + assert ClassAscendency.SMITH_OF_KITAVA.get_class() == CharacterClass.WARRIOR + assert ClassAscendency.BLOOD_MAGE.get_class() == CharacterClass.WITCH + assert ClassAscendency.INFERNALIST.get_class() == CharacterClass.WITCH + assert ClassAscendency.LICH.get_class() == CharacterClass.WITCH + assert ClassAscendency.RITUALIST.get_class() == CharacterClass.HUNTRESS + assert ClassAscendency.AMAZON.get_class() == CharacterClass.HUNTRESS diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py new file mode 100644 index 0000000..e4ab40a --- /dev/null +++ b/tests/unit/test_events.py @@ -0,0 +1,50 @@ +"""Tests for domain event hierarchy.""" +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged, DomainEvent, GameStarted, GameStopped +from poe2_rpc.domain.models import InstanceInfo, LevelInfo + + +def _level_info() -> LevelInfo: + return LevelInfo(username="Exile", base_class="Mercenary", ascension_class=None, level=1) + + +def _instance_info() -> InstanceInfo: + return InstanceInfo(area_code="G1_4", area_display_name="Test Zone", level=5, seed=99) + + +def test_game_started_is_frozen() -> None: + event = GameStarted(log_path=Path("/tmp/Client.txt")) + with pytest.raises((ValidationError, TypeError)): + event.log_path = Path("/tmp/other.txt") # type: ignore[misc] + + +def test_game_started_eq_by_value() -> None: + p = Path("/tmp/Client.txt") + assert GameStarted(log_path=p) == GameStarted(log_path=p) + + +def test_character_level_changed_holds_level_info() -> None: + info = _level_info() + event = CharacterLevelChanged(level_info=info) + assert event.level_info == info + + +def test_area_entered_holds_instance_info() -> None: + info = _instance_info() + event = AreaEntered(instance_info=info) + assert event.instance_info == info + + +def test_game_stopped_is_singleton_like() -> None: + assert GameStopped() == GameStopped() + + +def test_events_share_base() -> None: + assert isinstance(GameStarted(log_path=Path("/tmp/Client.txt")), DomainEvent) + assert isinstance(GameStopped(), DomainEvent) + assert isinstance(CharacterLevelChanged(level_info=_level_info()), DomainEvent) + assert isinstance(AreaEntered(instance_info=_instance_info()), DomainEvent) diff --git a/tests/unit/test_layering.py b/tests/unit/test_layering.py new file mode 100644 index 0000000..c977a00 --- /dev/null +++ b/tests/unit/test_layering.py @@ -0,0 +1,53 @@ +"""AST guard: domain modules must not import from application, infrastructure, or cli layers.""" +import ast +from pathlib import Path + +import pytest + +DOMAIN_ROOT = Path(__file__).parent.parent.parent / "src" / "poe2_rpc" / "domain" + +FORBIDDEN_PREFIXES = ( + "poe2_rpc.application", + "poe2_rpc.infrastructure", + "poe2_rpc.cli", +) + + +def collect_forbidden_imports(path: Path) -> list[str]: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + violations: list[str] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + for prefix in FORBIDDEN_PREFIXES: + if module == prefix or module.startswith(prefix + "."): + violations.append( + f"{path.name}:{node.lineno} — forbidden import from '{module}'" + ) + elif isinstance(node, ast.Import): + for alias in node.names: + for prefix in FORBIDDEN_PREFIXES: + if alias.name == prefix or alias.name.startswith(prefix + "."): + violations.append( + f"{path.name}:{node.lineno} — forbidden import of '{alias.name}'" + ) + + return violations + + +def test_domain_modules_do_not_import_outer_layers() -> None: + if not DOMAIN_ROOT.exists(): + pytest.skip("domain package not yet created") + + domain_files = list(DOMAIN_ROOT.rglob("*.py")) + assert domain_files, "No domain .py files found — check DOMAIN_ROOT path" + + all_violations: list[str] = [] + for path in sorted(domain_files): + all_violations.extend(collect_forbidden_imports(path)) + + assert not all_violations, ( + "Domain modules import from outer layers:\n" + "\n".join(all_violations) + ) diff --git a/tests/unit/test_locations.py b/tests/unit/test_locations.py new file mode 100644 index 0000000..e34d043 --- /dev/null +++ b/tests/unit/test_locations.py @@ -0,0 +1,50 @@ +"""Tests for Location VO and LocationCatalog.resolve().""" +import pytest +from pydantic import ValidationError + +from poe2_rpc.domain.locations import Location, LocationCatalog + + +@pytest.fixture +def catalog() -> LocationCatalog: + return LocationCatalog( + { + "G1_1": "The Mud Flats", + "G1_4_Brambleghast": "Brambleghast Hollow", + } + ) + + +def test_location_is_frozen(catalog: LocationCatalog) -> None: + loc = catalog.resolve("G1_1") + with pytest.raises((TypeError, AttributeError, ValidationError)): + loc.display_name = "changed" # type: ignore[misc] + + +def test_resolve_known_area_code(catalog: LocationCatalog) -> None: + loc = catalog.resolve("G1_1") + assert loc.display_name == "The Mud Flats" + assert loc.area_code == "G1_1" + + +def test_resolve_map_prefix_strips_and_resolves(catalog: LocationCatalog) -> None: + # "MapG1_4_Brambleghast" -> strip "Map" -> "G1_4_Brambleghast" -> split on "_" -> "G1" + # but the exact main.py logic: area_name[3:].split("_")[0] + # "MapG1_4_Brambleghast"[3:] = "G1_4_Brambleghast", split("_")[0] = "G1" + # "G1" not in values, not in keys -> fallback to "G1" + loc = catalog.resolve("MapG1_4_Brambleghast") + assert loc.area_code == "MapG1_4_Brambleghast" + assert loc.display_name == "G1" + + +def test_resolve_unknown_returns_area_code_as_display(catalog: LocationCatalog) -> None: + loc = catalog.resolve("UNKNOWN_ZONE_XYZ") + assert loc.area_code == "UNKNOWN_ZONE_XYZ" + assert loc.display_name == "UNKNOWN_ZONE_XYZ" + + +def test_resolve_value_match_returns_value(catalog: LocationCatalog) -> None: + # If normalized name is already a value in the dict, return it directly + loc = catalog.resolve("The Mud Flats") + assert loc.display_name == "The Mud Flats" + assert loc.area_code == "The Mud Flats" diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..eec0f83 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,63 @@ +"""Tests for domain value objects: LevelInfo and InstanceInfo.""" +import pytest +from pydantic import ValidationError + +from poe2_rpc.domain.models import InstanceInfo, LevelInfo + + +def _make_level_info(**kwargs: object) -> LevelInfo: + defaults: dict[str, object] = { + "username": "Exile", + "base_class": "Mercenary", + "ascension_class": None, + "level": 1, + } + defaults.update(kwargs) + return LevelInfo(**defaults) # type: ignore[arg-type] + + +def _make_instance_info(**kwargs: object) -> InstanceInfo: + defaults: dict[str, object] = { + "area_code": "G1_4_BrambleghastSlain", + "area_display_name": "Brambleghast", + "level": 10, + "seed": 42, + } + defaults.update(kwargs) + return InstanceInfo(**defaults) # type: ignore[arg-type] + + +def test_level_info_is_frozen() -> None: + info = _make_level_info() + with pytest.raises((ValidationError, TypeError)): + info.level = 99 # type: ignore[misc] + + +def test_level_info_eq_by_value() -> None: + a = _make_level_info(username="Hero", level=5) + b = _make_level_info(username="Hero", level=5) + assert a == b + + +def test_level_info_ascension_class_optional() -> None: + none_info = _make_level_info(ascension_class=None) + str_info = _make_level_info(ascension_class="Witchhunter") + assert none_info.ascension_class is None + assert str_info.ascension_class == "Witchhunter" + + +def test_instance_info_is_frozen() -> None: + info = _make_instance_info() + with pytest.raises((ValidationError, TypeError)): + info.level = 99 # type: ignore[misc] + + +def test_instance_info_eq_by_value() -> None: + a = _make_instance_info(seed=1) + b = _make_instance_info(seed=1) + assert a == b + + +def test_level_int_required() -> None: + with pytest.raises(ValidationError): + _make_level_info(level="not_an_int") # type: ignore[arg-type] diff --git a/tests/unit/test_no_mutable_state.py b/tests/unit/test_no_mutable_state.py new file mode 100644 index 0000000..0b4f2cb --- /dev/null +++ b/tests/unit/test_no_mutable_state.py @@ -0,0 +1,77 @@ +"""AST guard: domain modules must not have module-level mutable state.""" +import ast +from pathlib import Path + +import pytest + +DOMAIN_ROOT = Path(__file__).parent.parent.parent / "src" / "poe2_rpc" / "domain" + +ALLOWED_NODE_TYPES = ( + ast.ClassDef, + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ImportFrom, + ast.Import, + ast.If, # allow if TYPE_CHECKING guards +) + +def _is_final_annotation(annotation: ast.expr | None) -> bool: + if annotation is None: + return False + if isinstance(annotation, ast.Name) and annotation.id == "Final": + return True + if isinstance(annotation, ast.Subscript): + val = annotation.value + if isinstance(val, ast.Name) and val.id == "Final": + return True + if isinstance(val, ast.Attribute) and val.attr == "Final": + return True + return False + + +def _is_docstring(node: ast.stmt) -> bool: + return ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ) + + +def collect_violations(path: Path) -> list[str]: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + violations: list[str] = [] + + for node in tree.body: + if isinstance(node, ALLOWED_NODE_TYPES): + continue + if _is_docstring(node): + continue + if isinstance(node, ast.AnnAssign): + if _is_final_annotation(node.annotation): + continue + violations.append( + f"{path.name}:{node.lineno} — annotated assignment without Final[...]" + ) + elif isinstance(node, ast.Assign): + violations.append( + f"{path.name}:{node.lineno} — bare module-level assignment" + ) + + return violations + + +def test_domain_modules_have_no_mutable_state() -> None: + if not DOMAIN_ROOT.exists(): + pytest.skip("domain package not yet created") + + domain_files = list(DOMAIN_ROOT.rglob("*.py")) + assert domain_files, "No domain .py files found — check DOMAIN_ROOT path" + + all_violations: list[str] = [] + for path in sorted(domain_files): + all_violations.extend(collect_violations(path)) + + assert not all_violations, ( + "Module-level mutable state found in domain:\n" + "\n".join(all_violations) + ) diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py new file mode 100644 index 0000000..2bba461 --- /dev/null +++ b/tests/unit/test_ports.py @@ -0,0 +1,81 @@ +"""Tests for domain port Protocols — all must be @runtime_checkable.""" +from pathlib import Path +from typing import Iterator + +from poe2_rpc.domain.events import DomainEvent +from poe2_rpc.domain.locations import Location +from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.ports import ( + EventBus, + GameDetector, + LocationCatalogPort, + LogParser, + LogStream, + PresencePublisher, +) + + +class _ConcreteGameDetector: + def is_running(self) -> bool: + return True + + def log_path(self) -> Path: + return Path("/fake/Client.txt") + + +class _ConcreteLogStream: + def lines(self) -> Iterator[str]: + yield "line" + + +class _ConcreteLogParser: + def parse_level(self, line: str) -> LevelInfo | None: + return None + + def parse_instance(self, line: str) -> InstanceInfo | None: + return None + + +class _ConcretePresencePublisher: + def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: + pass + + def close(self) -> None: + pass + + +class _ConcreteEventBus: + def emit(self, event: DomainEvent) -> None: + pass + + def subscribe(self, handler: object) -> None: + pass + + +class _ConcreteLocationCatalogPort: + def resolve(self, area_code: str) -> Location: + return Location(area_code=area_code, display_name=area_code) + + +def test_game_detector_is_runtime_checkable() -> None: + assert isinstance(_ConcreteGameDetector(), GameDetector) + + +def test_log_stream_is_runtime_checkable() -> None: + assert isinstance(_ConcreteLogStream(), LogStream) + + +def test_log_parser_is_runtime_checkable() -> None: + assert isinstance(_ConcreteLogParser(), LogParser) + + +def test_presence_publisher_is_runtime_checkable() -> None: + assert isinstance(_ConcretePresencePublisher(), PresencePublisher) + + +def test_event_bus_is_runtime_checkable() -> None: + assert isinstance(_ConcreteEventBus(), EventBus) + + +def test_location_catalog_port_is_runtime_checkable() -> None: + assert isinstance(_ConcreteLocationCatalogPort(), LocationCatalogPort) diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py new file mode 100644 index 0000000..7d2e736 --- /dev/null +++ b/tests/unit/test_smoke.py @@ -0,0 +1,21 @@ +"""Phase A smoke test — proves the package is importable end-to-end.""" + +from __future__ import annotations + +import poe2_rpc + + +def test_package_importable() -> None: + assert hasattr(poe2_rpc, "__version__") + assert isinstance(poe2_rpc.__version__, str) + assert poe2_rpc.__version__.count(".") == 2 + + +def test_subpackages_importable() -> None: + import poe2_rpc.application + import poe2_rpc.domain + import poe2_rpc.infrastructure + + assert poe2_rpc.domain.__doc__ is not None + assert poe2_rpc.application.__doc__ is not None + assert poe2_rpc.infrastructure.__doc__ is not None From bbaf6a76f711192e772bd90a5d88110300fa8636 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 11:33:32 +0300 Subject: [PATCH 02/17] Phase C: infrastructure adapter layer (DDD migration epic panvex-enp) Adds 8 infrastructure adapters + 1 domain exception, all gated behind 70/70 unit tests, mypy --strict, and import-linter "Hexagonal layered architecture KEPT" (1/1 contract). Adapters: - C-1 settings.py -- pydantic-settings AppSettings, env+TOML+init precedence - C-2 logging.py -- structlog with ConsoleRenderer/JSONRenderer + bind_contextvars - C-3 detection.py -- PsutilGameDetector (DI'd process_iter for testability) - C-4 log_stream.py -- WatchdogLogStream honoring the C-4 thread-safety contract: observer thread never touches asyncio.Queue; all enqueues via loop.call_soon_threadsafe; exponential backoff (0.05->0.5s, 2s deadline) for domain lines on QueueFull; non-domain lines drop with rate-limited warn; cursor reset on file rotation. - C-4b exceptions.py -- PoE2RPCError + LogStreamStalled in domain layer - C-5 parsing.py -- regex byte-identical to main.py:273-274 (Principle 5) - C-6 catalog.py -- BundledLocationCatalog via importlib.resources; opt-in URL override path, no implicit recovery layer - C-7a presence.connect -- tenacity 5 x wait_exponential(2,32) - C-7b presence.publish -- separate 3 x wait_exponential(1,8) -- split policies prevent 3x5=15 amplification (ADR Behavior Change #3) Bundled assets: - src/poe2_rpc/locations.json copied from repo root (Phase G handles root cleanup) Beads: panvex-{tqq,ahn,rl4,6jy,562,0d8,mvo,qkl,ne4} closed. Co-Authored-By: Claude Opus 4.7 --- src/poe2_rpc/domain/exceptions.py | 10 + src/poe2_rpc/infrastructure/catalog.py | 37 ++ src/poe2_rpc/infrastructure/detection.py | 38 ++ src/poe2_rpc/infrastructure/log_stream.py | 152 ++++++ src/poe2_rpc/infrastructure/logging.py | 57 ++ src/poe2_rpc/infrastructure/parsing.py | 33 ++ src/poe2_rpc/infrastructure/presence.py | 121 +++++ src/poe2_rpc/infrastructure/settings.py | 78 +++ src/poe2_rpc/locations.json | 612 ++++++++++++++++++++++ tests/unit/test_catalog.py | 46 ++ tests/unit/test_detection.py | 81 +++ tests/unit/test_exceptions.py | 16 + tests/unit/test_log_stream.py | 214 ++++++++ tests/unit/test_logging.py | 68 +++ tests/unit/test_parsing.py | 40 ++ tests/unit/test_presence_connect.py | 103 ++++ tests/unit/test_presence_publish.py | 150 ++++++ tests/unit/test_settings.py | 61 +++ 18 files changed, 1917 insertions(+) create mode 100644 src/poe2_rpc/domain/exceptions.py create mode 100644 src/poe2_rpc/infrastructure/catalog.py create mode 100644 src/poe2_rpc/infrastructure/detection.py create mode 100644 src/poe2_rpc/infrastructure/log_stream.py create mode 100644 src/poe2_rpc/infrastructure/logging.py create mode 100644 src/poe2_rpc/infrastructure/parsing.py create mode 100644 src/poe2_rpc/infrastructure/presence.py create mode 100644 src/poe2_rpc/infrastructure/settings.py create mode 100644 src/poe2_rpc/locations.json create mode 100644 tests/unit/test_catalog.py create mode 100644 tests/unit/test_detection.py create mode 100644 tests/unit/test_exceptions.py create mode 100644 tests/unit/test_log_stream.py create mode 100644 tests/unit/test_logging.py create mode 100644 tests/unit/test_parsing.py create mode 100644 tests/unit/test_presence_connect.py create mode 100644 tests/unit/test_presence_publish.py create mode 100644 tests/unit/test_settings.py diff --git a/src/poe2_rpc/domain/exceptions.py b/src/poe2_rpc/domain/exceptions.py new file mode 100644 index 0000000..d976c30 --- /dev/null +++ b/src/poe2_rpc/domain/exceptions.py @@ -0,0 +1,10 @@ +"""Domain exceptions — pure domain layer, no I/O imports.""" +from __future__ import annotations + + +class PoE2RPCError(Exception): + """Base for all domain-layer exceptions.""" + + +class LogStreamStalled(PoE2RPCError): + """Raised when the log stream cannot enqueue domain-relevant lines within the deadline.""" diff --git a/src/poe2_rpc/infrastructure/catalog.py b/src/poe2_rpc/infrastructure/catalog.py new file mode 100644 index 0000000..34d724c --- /dev/null +++ b/src/poe2_rpc/infrastructure/catalog.py @@ -0,0 +1,37 @@ +"""Bundled locations.json catalog adapter implementing LocationCatalogPort.""" +from __future__ import annotations + +import importlib.resources +import json + +import httpx + +from poe2_rpc.infrastructure.settings import AppSettings + + +class BundledLocationCatalog: + def __init__(self, settings: AppSettings) -> None: + self._settings = settings + self._areas = self._load_areas() + + def _load_areas(self) -> dict[str, str]: + if self._settings.locations_url is not None: + response = httpx.get(self._settings.locations_url) + response.raise_for_status() + data = json.loads(response.text) + else: + text = ( + importlib.resources.files("poe2_rpc") + .joinpath("locations.json") + .read_text(encoding="utf-8") + ) + data = json.loads(text) + return dict(data.get("areas", {})) + + def lookup(self, area_code: str) -> str | None: + return self._areas.get(area_code) + + def map_area_lookup(self, raw_area: str) -> str: + """Strip Map prefix and join remaining parts with spaces.""" + without_prefix = raw_area[3:].lstrip("_") + return " ".join(without_prefix.split("_")) diff --git a/src/poe2_rpc/infrastructure/detection.py b/src/poe2_rpc/infrastructure/detection.py new file mode 100644 index 0000000..3281361 --- /dev/null +++ b/src/poe2_rpc/infrastructure/detection.py @@ -0,0 +1,38 @@ +"""Psutil-based game process detector.""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any + +import psutil + +from poe2_rpc.infrastructure.settings import AppSettings + + +class PsutilGameDetector: + def __init__( + self, + settings: AppSettings, + process_iter_factory: Callable[[list[str]], Iterator[Any]] | None = None, + ) -> None: + self._settings = settings + self._process_iter = process_iter_factory or psutil.process_iter + + def find_log_path(self) -> Path | None: + for proc in self._iter_processes_safely(): + if proc is None: + continue + return proc + return None + + def _iter_processes_safely(self) -> Iterator[Path | None]: + for proc in self._process_iter(["name", "exe"]): + try: + info = proc.info + if info.get("name") == self._settings.process_name: + exe = info.get("exe") + if exe: + yield Path(exe).parent / "logs" / "Client.txt" + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue diff --git a/src/poe2_rpc/infrastructure/log_stream.py b/src/poe2_rpc/infrastructure/log_stream.py new file mode 100644 index 0000000..6391e70 --- /dev/null +++ b/src/poe2_rpc/infrastructure/log_stream.py @@ -0,0 +1,152 @@ +"""WatchdogLogStream — file-tail adapter using watchdog with thread-safety contract. + +The watchdog observer runs on its own thread. It MUST NOT touch the asyncio Queue +directly. All enqueues are scheduled via loop.call_soon_threadsafe so they execute +on the event-loop thread, keeping the Queue single-threaded. +""" +from __future__ import annotations + +import asyncio +import re +import time +from asyncio import AbstractEventLoop, QueueFull +from pathlib import Path +from typing import TYPE_CHECKING, AsyncIterator + +import structlog +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from poe2_rpc.domain.exceptions import LogStreamStalled + +if TYPE_CHECKING: + from poe2_rpc.infrastructure.settings import AppSettings + +log = structlog.get_logger(__name__) + +# Byte-identical to main.py:273-274 — Principle 5 forbids any change. +_REGEX_LEVEL = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)") +_REGEX_INSTANCE = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)') + +_BACKOFF_CAP = 0.5 +_BACKOFF_INITIAL = 0.05 + + +def _classify_line(line: str) -> bool: + """Return True if the line matches a domain-relevant regex.""" + return bool(_REGEX_LEVEL.search(line) or _REGEX_INSTANCE.search(line)) + + +class _LogFileHandler(FileSystemEventHandler): + """Watchdog handler — runs on the observer thread.""" + + def __init__(self, stream: "WatchdogLogStream") -> None: + super().__init__() + self._stream = stream + + def on_modified(self, event: FileSystemEvent) -> None: + self._stream._read_new_lines() + + +class WatchdogLogStream: + """Tails a log file using watchdog; enqueues lines safely to the asyncio loop.""" + + def __init__( + self, + log_path: Path, + settings: "AppSettings", + loop: AbstractEventLoop, + ) -> None: + self._log_path = log_path + self._settings = settings + self._loop = loop + self._queue: asyncio.Queue[str] = asyncio.Queue( + maxsize=settings.log_stream_queue_maxsize + ) + self._cursor: int = 0 + self._observer = Observer() + self._handler = _LogFileHandler(self) + self.dropped_non_domain_count: int = 0 + self._last_drop_warn_time: float = 0.0 + + # Seek to EOF on start + if log_path.exists(): + self._cursor = log_path.stat().st_size + + self._observer.schedule(self._handler, str(log_path.parent), recursive=False) + + def start(self) -> None: + self._observer.start() + + def stop(self) -> None: + self._observer.stop() + self._observer.join() + + def _read_new_lines(self) -> None: + """Called from the watchdog observer thread. Reads new bytes and schedules enqueues.""" + try: + current_size = self._log_path.stat().st_size + except FileNotFoundError: + return + + # File rotation: cursor beyond EOF → reset + if self._cursor > current_size: + self._cursor = 0 + + if self._cursor == current_size: + return + + with open(self._log_path, "rb") as f: + f.seek(self._cursor) + raw = f.read(current_size - self._cursor) + + self._cursor += len(raw) + text = raw.decode("utf-8", errors="replace") + lines = text.split("\n") + + for line in lines: + stripped = line.rstrip("\r") + if not stripped: + continue + # Schedule enqueue on the asyncio loop thread — NEVER touch queue directly here. + self._loop.call_soon_threadsafe(self._enqueue, stripped) + + def _enqueue( + self, + line: str, + _started_at: float | None = None, + _delay: float = _BACKOFF_INITIAL, + ) -> None: + """Runs on the asyncio loop thread. Enqueues line; retries domain lines on QueueFull.""" + try: + self._queue.put_nowait(line) + except QueueFull: + if _classify_line(line): + started_at = _started_at if _started_at is not None else time.monotonic() + deadline = self._settings.log_stream_enqueue_deadline_seconds + if time.monotonic() - started_at >= deadline: + raise LogStreamStalled( + f"Domain line could not be enqueued within {deadline}s" + ) from None + next_delay = min(_delay * 2, _BACKOFF_CAP) + self._loop.call_later( + next_delay, + self._enqueue, + line, + started_at, + next_delay, + ) + else: + self.dropped_non_domain_count += 1 + now = time.monotonic() + if now - self._last_drop_warn_time >= 1.0: + self._last_drop_warn_time = now + log.warning( + "non_domain_line_dropped", + dropped_total=self.dropped_non_domain_count, + ) + + async def __aiter__(self) -> AsyncIterator[str]: + while True: + line = await self._queue.get() + yield line diff --git a/src/poe2_rpc/infrastructure/logging.py b/src/poe2_rpc/infrastructure/logging.py new file mode 100644 index 0000000..9cf8dd1 --- /dev/null +++ b/src/poe2_rpc/infrastructure/logging.py @@ -0,0 +1,57 @@ +"""Structlog configuration for poe2-rpc. + +Call configure_logging(settings) once at startup. Do NOT call +stdlib logging.basicConfig — structlog is the canonical interface. +""" +from __future__ import annotations + +import logging +import sys +from typing import Any + +import structlog +import structlog.contextvars + +from poe2_rpc.infrastructure.settings import AppSettings + +_LEVEL_MAP: dict[str, int] = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, +} + + +def _build_processors(use_console: bool) -> list[Any]: + renderer: Any = ( + structlog.dev.ConsoleRenderer() if use_console else structlog.processors.JSONRenderer() + ) + # stdlib processors (filter_by_level, add_logger_name) require a stdlib + # Logger and fail with PrintLogger on Python 3.14+; use native equivalents. + return [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + renderer, + ] + + +def configure_logging(settings: AppSettings) -> None: + level = _LEVEL_MAP[settings.log_level] + + # Configure stdlib root logger so structlog's filter_by_level works. + logging.root.setLevel(level) + + use_console = settings.log_format == "console" or sys.stdout.isatty() + processors = _build_processors(use_console) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger(level), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=False, + ) diff --git a/src/poe2_rpc/infrastructure/parsing.py b/src/poe2_rpc/infrastructure/parsing.py new file mode 100644 index 0000000..3d00276 --- /dev/null +++ b/src/poe2_rpc/infrastructure/parsing.py @@ -0,0 +1,33 @@ +"""Regex-based log line parsers — byte-patterns preserved verbatim from main.py:273-274.""" +from __future__ import annotations + +import re + +from poe2_rpc.domain.models import InstanceInfo, LevelInfo + +regex_level = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)") +regex_instance = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)') + + +def parse_level_event(line: str) -> LevelInfo | None: + m = regex_level.search(line) + if not m: + return None + return LevelInfo( + username=m.group(1), + base_class=m.group(2), + ascension_class=None, + level=int(m.group(3)), + ) + + +def parse_instance_event(line: str) -> InstanceInfo | None: + m = regex_instance.search(line) + if not m: + return None + return InstanceInfo( + level=int(m.group(1)), + area_code=m.group(2), + area_display_name=m.group(2), + seed=int(m.group(3)), + ) diff --git a/src/poe2_rpc/infrastructure/presence.py b/src/poe2_rpc/infrastructure/presence.py new file mode 100644 index 0000000..aafd01c --- /dev/null +++ b/src/poe2_rpc/infrastructure/presence.py @@ -0,0 +1,121 @@ +"""Discord Rich Presence publisher — infrastructure adapter for PresencePublisher port.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any + +import pypresence.exceptions as pex +import pypresence.presence as _pres +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.infrastructure.settings import AppSettings + +_CONNECT_RETRY_EXCEPTIONS = ( + pex.DiscordError, + pex.PipeClosed, + pex.InvalidPipe, + pex.ConnectionTimeout, + ConnectionError, + OSError, +) + +_PUBLISH_RETRY_EXCEPTIONS = ( + pex.DiscordError, + pex.PipeClosed, +) + + +class PypresencePublisher: + """Wraps pypresence.AioPresence with retry policies on connect and publish.""" + + def __init__( + self, + settings: AppSettings, + *, + presence_factory: Callable[[str], Any] | None = None, + ) -> None: + self._settings = settings + self._factory: Callable[[str], Any] = presence_factory or _pres.AioPresence + self._presence: Any = None + + async def connect(self) -> None: + """Connect to Discord IPC with exponential-backoff retry (5×, 2–32 s).""" + presence = self._factory(self._settings.discord_app_id) + + @retry( + retry=retry_if_exception_type(_CONNECT_RETRY_EXCEPTIONS), + stop=stop_after_attempt(self._settings.connect_retry_attempts), + wait=wait_exponential(multiplier=2, max=32), + reraise=True, + ) + async def _connect() -> None: + await presence.connect() + + await _connect() + self._presence = presence + + async def publish( + self, + level_info: LevelInfo | None, + instance_info: InstanceInfo | None, + ) -> None: + """Publish a Rich Presence update with its own 3× retry (independent of connect).""" + if self._presence is None: + raise RuntimeError("publish() called before connect()") + + kwargs = self._build_update_kwargs(level_info, instance_info) + presence = self._presence + + @retry( + retry=retry_if_exception_type(_PUBLISH_RETRY_EXCEPTIONS), + stop=stop_after_attempt(self._settings.publish_retry_attempts), + wait=wait_exponential(multiplier=1, max=8), + reraise=True, + ) + async def _publish() -> None: + await presence.update(**kwargs) + + await _publish() + + @staticmethod + def _build_update_kwargs( + level_info: LevelInfo | None, + instance_info: InstanceInfo | None, + ) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "start": int(datetime.now(tz=timezone.utc).timestamp()), + } + if level_info is not None: + details = f"{level_info.username} ({level_info.base_class}" + if level_info.ascension_class is not None: + details += f" | {level_info.ascension_class}" + details += f" - Lvl {level_info.level})" + kwargs["details"] = details + asc = level_info.ascension_class or level_info.base_class + kwargs["small_image"] = asc.lower().replace(" ", "_") + if instance_info is not None: + kwargs["state"] = ( + f"In: {instance_info.area_display_name} (Lvl {instance_info.level})" + ) + return kwargs + + def close(self) -> None: + """Close the Discord IPC connection.""" + if self._presence is not None: + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(self._presence.close()) + else: + loop.run_until_complete(self._presence.close()) + except Exception: + pass + self._presence = None diff --git a/src/poe2_rpc/infrastructure/settings.py b/src/poe2_rpc/infrastructure/settings.py new file mode 100644 index 0000000..8166161 --- /dev/null +++ b/src/poe2_rpc/infrastructure/settings.py @@ -0,0 +1,78 @@ +"""Application settings loaded via pydantic-settings. + +Source precedence (highest → lowest): + init kwargs → env (POE2RPC_*) → TOML file → defaults + +TOML file location (Behavior Change #5 from ADR): + Windows : %APPDATA%\\poe2-rpc\\config.toml + POSIX : ~/.config/poe2-rpc/config.toml +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any, ClassVar, Literal + +from pydantic import PrivateAttr +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict +from pydantic_settings import TomlConfigSettingsSource + + +def _default_config_path() -> Path: + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return base / "poe2-rpc" / "config.toml" + + +class AppSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="POE2RPC_", + env_file_encoding="utf-8", + extra="ignore", + ) + + discord_app_id: str = "1315800372207419504" + process_name: str = "PathOfExileSteam.exe" + locations_url: str | None = None + log_stream_enqueue_deadline_seconds: float = 2.0 + log_stream_queue_maxsize: int = 1000 + throttle_window_seconds: float = 15.0 + connect_retry_attempts: int = 5 + publish_retry_attempts: int = 3 + log_format: Literal["console", "json"] = "console" + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + + _toml_file: str = PrivateAttr(default="") + + def __init__(self, _toml_file: str = "", **kwargs: Any) -> None: + super().__init__(**kwargs) + self._toml_file = _toml_file + if _toml_file: + self._apply_toml(Path(_toml_file)) + + def _apply_toml(self, path: Path) -> None: + if not path.exists(): + return + import tomllib + + with open(path, "rb") as f: + data = tomllib.load(f) + for key, value in data.items(): + if key in self.__class__.model_fields: + object.__setattr__(self, key, value) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + toml_source = TomlConfigSettingsSource(settings_cls, toml_file=_default_config_path()) + # init → env → default TOML file → defaults + return (init_settings, env_settings, toml_source) diff --git a/src/poe2_rpc/locations.json b/src/poe2_rpc/locations.json new file mode 100644 index 0000000..8099861 --- /dev/null +++ b/src/poe2_rpc/locations.json @@ -0,0 +1,612 @@ +{ + "areas": { + "G1_1": "The Riverbank", + "G1_1_BloatedMiller": "The Bloated Miller", + "G1_1_BloatedMillerSlain": "The Bloated Miller", + "G1_town": "Clearfell Encampment", + "G1_2": "Clearfell", + "G1_2_CarrionCrone": "Beira of the Rotten Pack", + "G1_2_CarrionCroneSlain": "Beira of the Rotten Pack", + "G1_2_ClearfellChest": "Mysterious Campsite", + "G1_2_ClearfellChestOpened": "Mysterious Campsite", + "G1_3": "Mud Burrow", + "G1_3_Underground": "Mud Burrow", + "G1_3_MudBurrower": "The Devourer", + "G1_3_MudBurrowerSlain": "The Devourer", + "G1_4": "The Grelwood", + "G1_4_Brambleghast": "The Brambleghast", + "G1_4_BrambleghastSlain": "The Brambleghast", + "G1_4_TreeOfSouls": "The Tree of Souls", + "G1_4_TreeOfSoulsDone": "The Tree of Souls", + "G1_4_Cauldron": "Witch Hut", + "G1_4_CauldronOpened": "Witch Hut", + "G1_4_OldHag": "Areagne, Forgotten Witch", + "G1_4_OldHagSlain": "Areagne, Forgotten Witch", + "G1_5": "The Red Vale", + "G1_5_RustKing": "The Rust King", + "G1_5_RustKingSlain": "The Rust King", + "G1_6": "The Grim Tangle", + "G1_6_Underground": "The Grim Tangle", + "G1_6_FungusBehemoth": "Ervig, the Rotten Druid", + "G1_6_FungusBehemothSlain": "Ervig, the Rotten Druid", + "G1_7": "Cemetery of the Eternals", + "G1_7_Lachlann": "Lachlann of Endless Lament", + "G1_7_LachlannSlain": "Lachlann of Endless Lament", + "G1_7_RingChest": "Ancient Ruin", + "G1_7_RingChestDone": "Ancient Ruin", + "G1_8": "Mausoleum of the Praetor", + "G1_8_Underground": "Mausoleum of the Praetor", + "G1_8_Draven": "Draven, the Eternal Praetor", + "G1_8_DravenSlain": "Draven, the Eternal Praetor", + "G1_8_GoldRoom": "Forgotten Riches", + "G1_8_GoldRoomDone": "Forgotten Riches", + "G1_9": "Tomb of the Consort", + "G1_9_Underground": "Tomb of the Consort", + "G1_9_Asinia": "Asinia, the Praetor's Consort", + "G1_9_AsiniaSlain": "Asinia, the Praetor's Consort", + "G1_9_TrappedChest": "Embattled Trove", + "G1_9_TrappedChestDone": "Embattled Trove", + "G1_10": "Root Hollow", + "G1_10_Underground": "Root Hollow", + "G1_10_RootHollowClear": "The Hooded One's Request", + "G1_10_RootHollowClearDone": "The Hooded One's Request", + "G1_11": "Hunting Grounds", + "G1_11_Crowbell": "The Crowbell", + "G1_11_CrowbellSlain": "The Crowbell", + "G1_11_Ritual": "Ritual Altar", + "G1_11_RitualDone": "Ritual Altar", + "G1_11_Stonehenge": "Dryadic Ritual", + "G1_11_StonehengeDone": "Dryadic Ritual", + "G1_12": "Freythorn", + "G1_12_KingInTheMists": "The King in the Mists", + "G1_12_KingInTheMistsDone": "The King in the Mists", + "G1_13_1": "Ogham Farmlands", + "G1_13_1_UnaLute": "Una's Hut", + "G1_13_1_UnaLuteDone": "Una's Hut", + "G1_13_1_CropCircle": "Crop Circle", + "G1_13_1_CropCircleDone": "Crop Circle", + "G1_13_2": "Ogham Village", + "G1_13_2_RenlyForge": "Renly's Workshop", + "G1_13_2_RenlyForgeDone": "Renly's Workshop", + "G1_13_Executioner": "The Executioner", + "G1_13_ExecutionerSlain": "The Executioner", + "G1_14": "The Manor Ramparts", + "G1_14_HangingBody": "The Gallows", + "G1_14_HangingBodyDone": "The Gallows", + "G1_15": "Ogham Manor", + "G1_15_Underground": "Ogham Manor", + "G1_15_Candlemass": "Candlemass, the Living Rite", + "G1_15_CandlemassSlain": "Candlemass, the Living Rite", + "G1_15_IronCount": "Count Geonor", + "G1_15_IronCountSlain": "Count Geonor", + "G2_town_marker0": "Vastiri Outskirts", + "G2_town_marker1": "The Ardura Caravan", + "G2_town_marker2": "The Ardura Caravan", + "G2_town_marker3": "The Ardura Caravan", + "G2_town_marker_quarry": "Mawdun Quarry", + "G2_town_marker_lockedgates": "The Halani Gates", + "G2_town_marker_gates": "The Halani Gates", + "G2_town_marker_oasis": "The Dreadnought's Wake", + "G2_town_marker_keth": "Keth", + "G2_town_marker_badlands": "Mastodon Badlands", + "G2_town_marker_titans": "Valley of the Titans", + "G2_town_marker_spires": "Deshar", + "G2_town_marker_dreadnought": "The Dreadnought", + "G2_town_marker_ascendancy": "Trial of the Sekhemas", + "G2_town_marker_toact3": "Sandswept Marsh", + "G2_1": "Vastiri Outskirts", + "G2_1_Rathbreaker": "Rathbreaker", + "G2_1_RathbreakerSlain": "Rathbreaker", + "G2_town": "The Ardura Caravan", + "G2_2": "Traitor's Passage", + "G2_2_Underground": "Traitor's Passage", + "G2_2_Balbala": "Balbala, the Traitor", + "G2_2_BalbalaSlain": "Balbala, the Traitor", + "G2_3": "The Halani Gates", + "G2_3_Lim": "Forward Command Tents", + "G2_3_LimDone": "Forward Command Tents", + "G2_3_PerennialKing": "Jamanra, the Risen King", + "G2_3_PerennialKingSlain": "Jamanra, the Risen King", + "G2_3a": "The Halani Gates", + "G2_3s": "The Halani Gates", + "G2_4_1": "Keth", + "G2_4_1_Kabala": "Kabala, Constrictor Queen", + "G2_4_1_KabalaSlain": "Kabala, Constrictor Queen", + "G2_4_1_Tomb": "Abandoned Shrine", + "G2_4_1_TombDone": "Abandoned Shrine", + "G2_4_2": "The Lost City", + "G2_4_2_Underground": "The Lost City", + "G2_4_2_Beetle": "The Galleria", + "G2_4_2_BeetleDone": "The Galleria", + "G2_4_2_Ambush": "Golden Tomb", + "G2_4_2_AmbushDone": "Golden Tomb", + "G2_4_3": "Buried Shrines", + "G2_4_3_Underground": "Buried Shrines", + "G2_4_3_ForsakenSon": "Azarian, the Forsaken Son", + "G2_4_3_ForsakenSonSlain": "Azarian, the Forsaken Son", + "G2_4_3_Ambush": "Guarded Sarcophagus", + "G2_4_3_AmbushDone": "Guarded Sarcophagus", + "G2_4_3_Offering": "Elemental Offering", + "G2_4_3_OfferingDone": "Elemental Offering", + "G2_5_1": "Mastodon Badlands", + "G2_5_1_BoneChest": "Shrine of Bones", + "G2_5_1_BoneChestDone": "Shrine of Bones", + "G2_5_2": "The Bone Pits", + "G2_5_2_Ekbab": "Ekbab, Ancient Steed", + "G2_5_2_EkbabSlain": "Ekbab, Ancient Steed", + "G2_6": "Valley of the Titans", + "G2_6_FragmentAltar": "Medallion", + "G2_6_FragmentAltarBoth": "Medallion", + "G2_6_FragmentAltarLeft": "Medallion", + "G2_6_FragmentAltarRight": "Medallion", + "G2_6_FragmentAltarLeftActive": "Medallion", + "G2_6_FragmentAltarRightActive": "Medallion", + "G2_7": "The Titan Grotto", + "G2_7_Underground": "The Titan Grotto", + "G2_7_TitanAugment": "Titans' Treasure", + "G2_7_TitanAugmentDone": "Titans' Treasure", + "G2_7_TheColossus": "Zalmarath, the Colossus", + "G2_7_TheColossusSlain": "Zalmarath, the Colossus", + "G2_8": "Deshar", + "G2_8_FinalLetter": "Final Letter", + "G2_8_FinalLetterDone": "Final Letter", + "G2_8_TwinBosses": "Watchful Twins", + "G2_8_TwinBossesDone": "Watchful Twins", + "G2_9_1": "Path of Mourning", + "G2_9_1_VaseRoom": "Shifting Vases", + "G2_9_1_VaseRoomDone": "Shifting Vases", + "G2_9_2": "The Spires of Deshar", + "G2_9_2_LightningShrine": "Sisters of Garukhan", + "G2_9_2_LightningShrineDone": "Sisters of Garukhan", + "G2_9_2_TorGul": "Tor Gul, the Defiler", + "G2_9_2_TorGulSlain": "Tor Gul, the Defiler", + "G2_10_1": "Mawdun Quarry", + "G2_10_1_QualityCurrency": "Tinker's Tools", + "G2_10_1_QualityCurrencyDone": "Tinker's Tools", + "G2_10_2": "Mawdun Mine", + "G2_10_2_Underground": "Mawdun Mine", + "G2_10_2_Rudja": "Rudja, Dread Engineer", + "G2_10_2_RudjaSlain": "Rudja, Dread Engineer", + "G2_12_1": "The Dreadnought", + "G2_12_2": "Dreadnought Vanguard", + "G2_12_2_PerennialKing": "Jamanra, the Abomination", + "G2_12_2_PerennialKingSlain": "Jamanra, the Abomination", + "G2_13": "Trial of the Sekhemas", + "G2_13_Underground": "Trial of the Sekhemas", + "G2_13_AscendancyTrial": "Trial of Ascendancy", + "G2_13_AscendancyTrialDone": "Trial of Ascendancy", + "G2_sandstorm": "The Sandstorm Barrier", + "G2_ToAct3": "Sandswept Marsh", + "G3_1": "Sandswept Marsh", + "G3_1_Rootdredge": "Rootdredge", + "G3_1_RootdredgeSlain": "Rootdredge", + "G3_1_Corpse": "Hanging Tree", + "G3_1_CorpseDone": "Hanging Tree", + "G3_1_CannibalDuo": "Orok Campfire", + "G3_1_CannibalDuoDone": "Orok Campfire", + "G3_town": "Ziggurat Encampment", + "G3_2_1": "Infested Barrens", + "G3_2_1_ExplorerCamp": "Troubled Camp", + "G3_2_1_ExplorerCampDone": "Troubled Camp", + "G3_2_2": "The Matlan Waterways", + "G3_2_2_WaterwaysMechanism": "Canal Lever", + "G3_2_2_WaterwaysMechanismDone": "Canal Lever", + "G3_2_2_Shaman": "Narag's Hut", + "G3_2_2_ShamanDone": "Narag's Hut", + "G3_3": "Jungle Ruins", + "G3_3_Silverback": "Mighty Silverfist", + "G3_3_SilverbackSlain": "Mighty Silverfist", + "G3_3_FinalRest": "Jungle Grave", + "G3_3_FinalRestDone": "Jungle Grave", + "G3_3_ExplorerCamp": "Troubled Camp", + "G3_3_ExplorerCampDone": "Troubled Camp", + "G3_4": "The Venom Crypts", + "G3_4_Underground": "The Venom Crypts", + "G3_4_DenOfSnakes": "Orok Poison", + "G3_4_DenOfSnakesDoneReward1": "Venom Draught", + "G3_4_DenOfSnakesDoneReward2": "Venom Draught", + "G3_4_DenOfSnakesDoneReward3": "Venom Draught", + "G3_5": "Chimeral Wetlands", + "G3_5_Xyclucian": "Xyclucian, the Chimera", + "G3_5_XyclucianSlain": "Xyclucian, the Chimera", + "G3_5_DeadExplorerCamp": "Ravaged Camp", + "G3_5_DeadExplorerCampDone": "Ravaged Camp", + "G3_5_FlowerGauntlet": "Toxic Bloom", + "G3_5_FlowerGauntletDone": "Toxic Bloom", + "G3_6_1": "Jiquani's Machinarium", + "G3_6_1_Underground": "Jiquani's Machinarium", + "G3_6_1_Blackjaw": "Blackjaw, the Remnant", + "G3_6_1_BlackjawSlain": "Blackjaw, the Remnant", + "G3_6_2": "Jiquani's Sanctum", + "G3_6_2_Underground": "Jiquani's Sanctum", + "G3_6_2_Zicoatl": "Zicoatl, Warden of the Core", + "G3_6_2_ZicoatlSlain": "Zicoatl, Warden of the Core", + "G3_6_2_CorruptionAltar": "Paquate's Mechanism", + "G3_6_2_CorruptionAltarDone": "Paquate's Mechanism", + "G3_7": "The Azak Bog", + "G3_7_Ignagduk": "Ignagduk, the Bog Witch", + "G3_7_IgnagdukSlain": "Ignagduk, the Bog Witch", + "G3_8": "The Drowned City", + "G3_9": "The Molten Vault", + "G3_9_Underground": "The Molten Vault", + "G3_9_Mektul": "Mektul, the Forgemaster", + "G3_9_MektulSlain": "Mektul, the Forgemaster", + "G3_10": "The Temple of Chaos", + "G3_10_Airlock": "The Temple of Chaos", + "G3_10_Underground": "The Temple of Chaos", + "G3_10_Ultimatum": "Ultimatum", + "G3_10_UltimatumDone": "Ultimatum", + "G3_11": "Apex of Filth", + "G3_11_TheQueenOfFilth": "The Queen of Filth", + "G3_11_TheQueenOfFilthSlain": "The Queen of Filth", + "G3_11_CroneVendor": "Bubbling Respite", + "G3_11_CroneVendorDone": "Bubbling Respite", + "G3_12": "Temple of Kopec", + "G3_12_Underground": "Temple of Kopec", + "G3_12_Ketzuli": "Ketzuli, High Priest of the Sun", + "G3_12_KetzuliSlain": "Ketzuli, High Priest of the Sun", + "G3_14": "Utzaal", + "G3_14_ViperNapuatzi": "Viper Napuatzi", + "G3_14_ViperNapuatziSlain": "Viper Napuatzi", + "G3_14_TrialmasterStatue": "Chaos Statue", + "G3_14_TrialmasterStatueDone": "Chaos Statue", + "G3_14_Artefacts": "Peculiar Fortunes", + "G3_14_ArtefactsDone": "Peculiar Fortunes", + "G3_16": "Aggorat", + "G3_16_BloodSacrifice": "Blood Sacrifice", + "G3_16_BloodSacrificeDone": "Blood Sacrifice", + "G3_16_GoliathSoulCore": "Undamaged Core", + "G3_16_GoliathSoulCoreDone": "Undamaged Core", + "G3_16_Artefacts": "Peculiar Fortunes", + "G3_16_ArtefactsDone": "Peculiar Fortunes", + "G3_17": "The Black Chambers", + "G3_17_Underground": "The Black Chambers", + "G3_17_Doryani": "Doryani, Royal Thaumaturge", + "G3_17_DoryaniSlain": "Doryani, Royal Thaumaturge", + "C_G1_1": "The Riverbank", + "C_G1_1_BloatedMiller": "The Bloated Miller", + "C_G1_1_BloatedMillerSlain": "The Bloated Miller", + "C_G1_town": "Clearfell Encampment", + "C_G1_2": "Clearfell", + "C_G1_2_CarrionCrone": "Beira of the Rotten Pack", + "C_G1_2_CarrionCroneSlain": "Beira of the Rotten Pack", + "C_G1_2_ClearfellChest": "Mysterious Campsite", + "C_G1_2_ClearfellChestOpened": "Mysterious Campsite", + "C_G1_3": "Mud Burrow", + "C_G1_3_Underground": "Mud Burrow", + "C_G1_3_MudBurrower": "The Devourer", + "C_G1_3_MudBurrowerSlain": "The Devourer", + "C_G1_4": "The Grelwood", + "C_G1_4_Brambleghast": "The Brambleghast", + "C_G1_4_BrambleghastSlain": "The Brambleghast", + "C_G1_4_TreeOfSouls": "The Tree of Souls", + "C_G1_4_TreeOfSoulsDone": "The Tree of Souls", + "C_G1_4_Cauldron": "Witch Hut", + "C_G1_4_CauldronOpened": "Witch Hut", + "C_G1_4_OldHag": "Areagne, Forgotten Witch", + "C_G1_4_OldHagSlain": "Areagne, Forgotten Witch", + "C_G1_5": "The Red Vale", + "C_G1_5_RustKing": "The Rust King", + "C_G1_5_RustKingSlain": "The Rust King", + "C_G1_6": "The Grim Tangle", + "C_G1_6_Underground": "The Grim Tangle", + "C_G1_6_FungusBehemoth": "Ervig, the Rotten Druid", + "C_G1_6_FungusBehemothSlain": "Ervig, the Rotten Druid", + "C_G1_7": "Cemetery of the Eternals", + "C_G1_7_Lachlann": "Lachlann of Endless Lament", + "C_G1_7_LachlannSlain": "Lachlann of Endless Lament", + "C_G1_7_RingChest": "Ancient Ruin", + "C_G1_7_RingChestDone": "Ancient Ruin", + "C_G1_8": "Mausoleum of the Praetor", + "C_G1_8_Underground": "Mausoleum of the Praetor", + "C_G1_8_Draven": "Draven, the Eternal Praetor", + "C_G1_8_DravenSlain": "Draven, the Eternal Praetor", + "C_G1_8_GoldRoom": "Forgotten Riches", + "C_G1_8_GoldRoomDone": "Forgotten Riches", + "C_G1_9": "Tomb of the Consort", + "C_G1_9_Underground": "Tomb of the Consort", + "C_G1_9_Asinia": "Asinia, the Praetor's Consort", + "C_G1_9_AsiniaSlain": "Asinia, the Praetor's Consort", + "C_G1_9_TrappedChest": "Embattled Trove", + "C_G1_9_TrappedChestDone": "Embattled Trove", + "C_G1_10": "Root Hollow", + "C_G1_10_Underground": "Root Hollow", + "C_G1_10_RootHollowClear": "The Hooded One's Request", + "C_G1_10_RootHollowClearDone": "The Hooded One's Request", + "C_G1_11": "Hunting Grounds", + "C_G1_11_Crowbell": "The Crowbell", + "C_G1_11_CrowbellSlain": "The Crowbell", + "C_G1_11_Ritual": "Ritual Altar", + "C_G1_11_RitualDone": "Ritual Altar", + "C_G1_11_Stonehenge": "Dryadic Ritual", + "C_G1_11_StonehengeDone": "Dryadic Ritual", + "C_G1_12": "Freythorn", + "C_G1_12_KingInTheMists": "The King in the Mists", + "C_G1_12_KingInTheMistsDone": "The King in the Mists", + "C_G1_13_1": "Ogham Farmlands", + "C_G1_13_1_UnaLute": "Una's Hut", + "C_G1_13_1_UnaLuteDone": "Una's Hut", + "C_G1_13_1_CropCircle": "Crop Circle", + "C_G1_13_1_CropCircleDone": "Crop Circle", + "C_G1_13_2": "Ogham Village", + "C_G1_13_Executioner": "The Executioner", + "C_G1_13_ExecutionerSlain": "The Executioner", + "C_G1_14": "The Manor Ramparts", + "C_G1_14_HangingBody": "The Gallows", + "C_G1_14_HangingBodyDone": "The Gallows", + "C_G1_15": "Ogham Manor", + "C_G1_15_Underground": "Ogham Manor", + "C_G1_15_Candlemass": "Candlemass, the Living Rite", + "C_G1_15_CandlemassSlain": "Candlemass, the Living Rite", + "C_G1_15_IronCount": "Count Geonor", + "C_G1_15_IronCountSlain": "Count Geonor", + "C_G2_town_marker0": "Vastiri Outskirts", + "C_G2_town_marker1": "The Ardura Caravan", + "C_G2_town_marker2": "The Ardura Caravan", + "C_G2_town_marker3": "The Ardura Caravan", + "C_G2_town_marker_quarry": "Mawdun Quarry", + "C_G2_town_marker_lockedgates": "The Halani Gates", + "C_G2_town_marker_gates": "The Halani Gates", + "C_G2_town_marker_oasis": "The Dreadnought's Wake", + "C_G2_town_marker_keth": "Keth", + "C_G2_town_marker_badlands": "Mastodon Badlands", + "C_G2_town_marker_titans": "Valley of the Titans", + "C_G2_town_marker_spires": "Deshar", + "C_G2_town_marker_dreadnought": "The Dreadnought", + "C_G2_town_marker_ascendancy": "Trial of the Sekhemas", + "C_G2_town_marker_toact3": "Sandswept Marsh", + "C_G2_1": "Vastiri Outskirts", + "C_G2_1_Rathbreaker": "Rathbreaker", + "C_G2_1_RathbreakerSlain": "Rathbreaker", + "C_G2_town": "The Ardura Caravan", + "C_G2_2": "Traitor's Passage", + "C_G2_2_Underground": "Traitor's Passage", + "C_G2_2_Balbala": "Balbala, the Traitor", + "C_G2_2_BalbalaSlain": "Balbala, the Traitor", + "C_G2_3": "The Halani Gates", + "C_G2_3_Lim": "Forward Command Tents", + "C_G2_3_LimDone": "Forward Command Tents", + "C_G2_3_PerennialKing": "Jamanra, the Risen King", + "C_G2_3_PerennialKingSlain": "Jamanra, the Risen King", + "C_G2_3a": "The Halani Gates", + "C_G2_4_1": "Keth", + "C_G2_4_1_Kabala": "Kabala, Constrictor Queen", + "C_G2_4_1_KabalaSlain": "Kabala, Constrictor Queen", + "C_G2_4_1_Tomb": "Abandoned Shrine", + "C_G2_4_1_TombDone": "Abandoned Shrine", + "C_G2_4_2": "The Lost City", + "C_G2_4_2_Underground": "The Lost City", + "C_G2_4_2_Beetle": "The Galleria", + "C_G2_4_2_BeetleDone": "The Galleria", + "C_G2_4_2_Ambush": "Golden Tomb", + "C_G2_4_2_AmbushDone": "Golden Tomb", + "C_G2_4_3": "Buried Shrines", + "C_G2_4_3_Underground": "Buried Shrines", + "C_G2_4_3_ForsakenSon": "Azarian, the Forsaken Son", + "C_G2_4_3_ForsakenSonSlain": "Azarian, the Forsaken Son", + "C_G2_4_3_Ambush": "Guarded Sarcophagus", + "C_G2_4_3_AmbushDone": "Guarded Sarcophagus", + "C_G2_4_3_Offering": "Elemental Offering", + "C_G2_4_3_OfferingDone": "Elemental Offering", + "C_G2_5_1": "Mastodon Badlands", + "C_G2_5_1_BoneChest": "Shrine of Bones", + "C_G2_5_1_BoneChestDone": "Shrine of Bones", + "C_G2_5_2": "The Bone Pits", + "C_G2_5_2_Ekbab": "Ekbab, Ancient Steed", + "C_G2_5_2_EkbabSlain": "Ekbab, Ancient Steed", + "C_G2_6": "Valley of the Titans", + "C_G2_6_FragmentAltar": "Medallion", + "C_G2_6_FragmentAltarBoth": "Medallion", + "C_G2_6_FragmentAltarLeft": "Medallion", + "C_G2_6_FragmentAltarRight": "Medallion", + "C_G2_6_FragmentAltarLeftActive": "Medallion", + "C_G2_6_FragmentAltarRightActive": "Medallion", + "C_G2_7": "The Titan Grotto", + "C_G2_7_Underground": "The Titan Grotto", + "C_G2_7_TitanAugment": "Titans' Treasure", + "C_G2_7_TitanAugmentDone": "Titans' Treasure", + "C_G2_7_TheColossus": "Zalmarath, the Colossus", + "C_G2_7_TheColossusSlain": "Zalmarath, the Colossus", + "C_G2_8": "Deshar", + "C_G2_8_FinalLetter": "Final Letter", + "C_G2_8_FinalLetterDone": "Final Letter", + "C_G2_8_TwinBosses": "Watchful Twins", + "C_G2_8_TwinBossesDone": "Watchful Twins", + "C_G2_9_1": "Path of Mourning", + "C_G2_9_1_VaseRoom": "Shifting Vases", + "C_G2_9_1_VaseRoomDone": "Shifting Vases", + "C_G2_9_2": "The Spires of Deshar", + "C_G2_9_2_LightningShrine": "Sisters of Garukhan", + "C_G2_9_2_LightningShrineDone": "Sisters of Garukhan", + "C_G2_9_2_TorGul": "Tor Gul, the Defiler", + "C_G2_9_2_TorGulSlain": "Tor Gul, the Defiler", + "C_G2_10_1": "Mawdun Quarry", + "C_G2_10_1_QualityCurrency": "Tinker's Tools", + "C_G2_10_1_QualityCurrencyDone": "Tinker's Tools", + "C_G2_10_2": "Mawdun Mine", + "C_G2_10_2_Underground": "Mawdun Mine", + "C_G2_10_2_Rudja": "Rudja, Dread Engineer", + "C_G2_10_2_RudjaSlain": "Rudja, Dread Engineer", + "C_G2_12_1": "The Dreadnought", + "C_G2_12_2": "Dreadnought Vanguard", + "C_G2_12_2_PerennialKing": "Jamanra, the Abomination", + "C_G2_12_2_PerennialKingSlain": "Jamanra, the Abomination", + "C_G2_13": "Trial of the Sekhemas", + "C_G2_13_Underground": "Trial of the Sekhemas", + "C_G2_13_AscendancyTrial": "Trial of Ascendancy", + "C_G2_13_AscendancyTrialDone": "Trial of Ascendancy", + "C_G2_sandstorm": "The Sandstorm Barrier", + "C_G2_ToAct3": "Sandswept Marsh", + "C_G3_1": "Sandswept Marsh", + "C_G3_1_Rootdredge": "Rootdredge", + "C_G3_1_RootdredgeSlain": "Rootdredge", + "C_G3_1_Corpse": "Hanging Tree", + "C_G3_1_CorpseDone": "Hanging Tree", + "C_G3_1_CannibalDuo": "Orok Campfire", + "C_G3_1_CannibalDuoDone": "Orok Campfire", + "C_G3_town": "Ziggurat Encampment", + "C_G3_2_1": "Infested Barrens", + "C_G3_2_1_ExplorerCamp": "Troubled Camp", + "C_G3_2_1_ExplorerCampDone": "Troubled Camp", + "C_G3_2_2": "The Matlan Waterways", + "C_G3_2_2_WaterwaysMechanism": "Canal Lever", + "C_G3_2_2_WaterwaysMechanismDone": "Canal Lever", + "C_G3_2_2_Shaman": "Narag's Hut", + "C_G3_2_2_ShamanDone": "Narag's Hut", + "C_G3_3": "Jungle Ruins", + "C_G3_3_Silverback": "Mighty Silverfist", + "C_G3_3_SilverbackSlain": "Mighty Silverfist", + "C_G3_3_FinalRest": "Jungle Grave", + "C_G3_3_FinalRestDone": "Jungle Grave", + "C_G3_3_ExplorerCamp": "Troubled Camp", + "C_G3_3_ExplorerCampDone": "Troubled Camp", + "C_G3_4": "The Venom Crypts", + "C_G3_4_Underground": "The Venom Crypts", + "C_G3_4_DenOfSnakes": "Orok Poison", + "C_G3_4_DenOfSnakesDoneReward1": "Venom Draught", + "C_G3_4_DenOfSnakesDoneReward2": "Venom Draught", + "C_G3_4_DenOfSnakesDoneReward3": "Venom Draught", + "C_G3_5": "Chimeral Wetlands", + "C_G3_5_Xyclucian": "Xyclucian, the Chimera", + "C_G3_5_XyclucianSlain": "Xyclucian, the Chimera", + "C_G3_5_DeadExplorerCamp": "Ravaged Camp", + "C_G3_5_DeadExplorerCampDone": "Ravaged Camp", + "C_G3_5_FlowerGauntlet": "Toxic Bloom", + "C_G3_5_FlowerGauntletDone": "Toxic Bloom", + "C_G3_6_1": "Jiquani's Machinarium", + "C_G3_6_1_Underground": "Jiquani's Machinarium", + "C_G3_6_1_Blackjaw": "Blackjaw, the Remnant", + "C_G3_6_1_BlackjawSlain": "Blackjaw, the Remnant", + "C_G3_6_2": "Jiquani's Sanctum", + "C_G3_6_2_Underground": "Jiquani's Sanctum", + "C_G3_6_2_Zicoatl": "Zicoatl, Warden of the Core", + "C_G3_6_2_ZicoatlSlain": "Zicoatl, Warden of the Core", + "C_G3_6_2_CorruptionAltar": "Paquate's Mechanism", + "C_G3_6_2_CorruptionAltarDone": "Paquate's Mechanism", + "C_G3_7": "The Azak Bog", + "C_G3_7_Ignagduk": "Ignagduk, the Bog Witch", + "C_G3_7_IgnagdukSlain": "Ignagduk, the Bog Witch", + "C_G3_8": "The Drowned City", + "C_G3_9": "The Molten Vault", + "C_G3_9_Underground": "The Molten Vault", + "C_G3_9_Mektul": "Mektul, the Forgemaster", + "C_G3_9_MektulSlain": "Mektul, the Forgemaster", + "C_G3_10": "The Temple of Chaos", + "C_G3_10_Underground": "The Temple of Chaos", + "C_G3_10_Ultimatum": "Ultimatum", + "C_G3_10_UltimatumDone": "Ultimatum", + "C_G3_11": "Apex of Filth", + "C_G3_11_TheQueenOfFilth": "The Queen of Filth", + "C_G3_11_TheQueenOfFilthSlain": "The Queen of Filth", + "C_G3_11_CroneVendor": "Bubbling Respite", + "C_G3_11_CroneVendorDone": "Bubbling Respite", + "C_G3_12": "Temple of Kopec", + "C_G3_12_Underground": "Temple of Kopec", + "C_G3_12_Ketzuli": "Ketzuli, High Priest of the Sun", + "C_G3_12_KetzuliSlain": "Ketzuli, High Priest of the Sun", + "C_G3_14": "Utzaal", + "C_G3_14_ViperNapuatzi": "Viper Napuatzi", + "C_G3_14_ViperNapuatziSlain": "Viper Napuatzi", + "C_G3_14_TrialmasterStatue": "Chaos Statue", + "C_G3_14_TrialmasterStatueDone": "Chaos Statue", + "C_G3_14_Artefacts": "Peculiar Fortunes", + "C_G3_14_ArtefactsDone": "Peculiar Fortunes", + "C_G3_16": "Aggorat", + "C_G3_16_BloodSacrifice": "Blood Sacrifice", + "C_G3_16_BloodSacrificeDone": "Blood Sacrifice", + "C_G3_16_GoliathSoulCore": "Undamaged Core", + "C_G3_16_GoliathSoulCoreDone": "Undamaged Core", + "C_G3_16_Artefacts": "Peculiar Fortunes", + "C_G3_16_ArtefactsDone": "Peculiar Fortunes", + "C_G3_17": "The Black Chambers", + "C_G3_17_Underground": "The Black Chambers", + "C_G3_17_Doryani": "Doryani, Royal Thaumaturge", + "C_G3_17_DoryaniSlain": "Doryani, Royal Thaumaturge", + "G_Endgame_Town": "The Ziggurat Refuge (Past)", + "G_Endgame_G3_1": "Sandswept Marsh (Present)", + "G_Endgame_G3_townA": "Ziggurat Encampment (Present)", + "G_Endgame_G3_townB": "Ziggurat Encampment (Past)", + "G_Endgame_G3_2_1": "Infested Barrens (Present)", + "G_Endgame_G3_2_2": "The Matlan Waterways (Present)", + "G_Endgame_G3_3": "Jungle Ruins (Present)", + "G_Endgame_G3_4": "The Venom Crypts (Present)", + "G_Endgame_G3_4_Underground": "The Venom Crypts (Present)", + "G_Endgame_G3_5": "Chimeral Wetlands (Present)", + "G_Endgame_G3_6_1": "Jiquani's Machinarium (Present)", + "G_Endgame_G3_6_2": "Jiquani's Sanctum (Present)", + "G_Endgame_G3_6_2_Underground": "Jiquani's Sanctum (Present)", + "G_Endgame_G3_7": "The Azak Bog (Present)", + "G_Endgame_G3_8": "The Drowned City (Present)", + "G_Endgame_G3_9": "The Molten Vault (Present)", + "G_Endgame_G3_9_Underground": "The Molten Vault (Present)", + "G_Endgame_G3_10": "The Trial of Chaos (Present)", + "G_Endgame_G3_10_Underground": "The Trial of Chaos (Present)", + "G_Endgame_G3_11": "Apex of Filth (Present)", + "G_Endgame_G3_12": "Temple of Kopec (Present)", + "G_Endgame_G3_12_Underground": "Temple of Kopec (Present)", + "G_Endgame_G3_14": "Utzaal (Past)", + "G_Endgame_G3_16": "Aggorat (Past)", + "G_Endgame_G3_17": "The Black Chambers (Past)", + "G_Endgame_G3_17_Underground": "The Black Chambers (Past)", + "G_Endgame_1_Rootdredge": "Rootdredge", + "G_Endgame_1_RootdredgeSlain": "Rootdredge", + "G_Endgame_1_Corpse": "Hanging Tree", + "G_Endgame_1_CorpseDone": "Hanging Tree", + "G_Endgame_1_CannibalDuo": "Orok Campfire", + "G_Endgame_1_CannibalDuoDone": "Orok Campfire", + "G_Endgame_2_1_ExplorerCamp": "Troubled Camp", + "G_Endgame_2_1_ExplorerCampDone": "Troubled Camp", + "G_Endgame_2_2_WaterwaysMechanism": "Canal Lever", + "G_Endgame_2_2_WaterwaysMechanismDone": "Canal Lever", + "G_Endgame_2_2_Shaman": "Narag's Hut", + "G_Endgame_2_2_ShamanDone": "Narag's Hut", + "G_Endgame_3_Silverback": "Mighty Silverfist", + "G_Endgame_3_SilverbackSlain": "Mighty Silverfist", + "G_Endgame_3_FinalRest": "Jungle Grave", + "G_Endgame_3_FinalRestDone": "Jungle Grave", + "G_Endgame_3_ExplorerCamp": "Troubled Camp", + "G_Endgame_3_ExplorerCampDone": "Troubled Camp", + "G_Endgame_4_DenOfSnakes": "Orok Poison", + "G_Endgame_4_DenOfSnakesDoneReward1": "Venom Draught", + "G_Endgame_4_DenOfSnakesDoneReward2": "Venom Draught", + "G_Endgame_4_DenOfSnakesDoneReward3": "Venom Draught", + "G_Endgame_5_Xyclucian": "Xyclucian, the Chimera", + "G_Endgame_5_XyclucianSlain": "Xyclucian, the Chimera", + "G_Endgame_5_DeadExplorerCamp": "Ravaged Camp", + "G_Endgame_5_DeadExplorerCampDone": "Ravaged Camp", + "G_Endgame_5_FlowerGauntlet": "Toxic Bloom", + "G_Endgame_G3_5_FlowerGauntletDone": "Toxic Bloom", + "G_Endgame_G3_6_1_Blackjaw": "Blackjaw, the Remnant", + "G_Endgame_G3_6_1_BlackjawSlain": "Blackjaw, the Remnant", + "G_Endgame_G3_6_2_Zicoatl": "Zicoatl, Warden of the Core", + "G_Endgame_G3_6_2_ZicoatlSlain": "Zicoatl, Warden of the Core", + "G_Endgame_G3_6_2_CorruptionAltar": "Paquate's Mechanism", + "G_Endgame_G3_6_2_CorruptionAltarDone": "Paquate's Mechanism", + "G_Endgame_G3_7_Ignagduk": "Ignagduk, the Bog Witch", + "G_Endgame_G3_7_IgnagdukSlain": "Ignagduk, the Bog Witch", + "G_Endgame_G3_9_Mektul": "Mektul, the Forgemaster", + "G_Endgame_G3_9_MektulSlain": "Mektul, the Forgemaster", + "G_Endgame_G3_10_Ultimatum": "Ultimatum", + "G_Endgame_G3_10_UltimatumDone": "Ultimatum", + "G_Endgame_G3_11_TheQueenOfFilth": "The Queen of Filth", + "G_Endgame_G3_11_TheQueenOfFilthSlain": "The Queen of Filth", + "G_Endgame_G3_11_CroneVendor": "Bubbling Respite", + "G_Endgame_G3_11_CroneVendorDone": "Bubbling Respite", + "G_Endgame_G3_12_Ketzuli": "Ketzuli, High Priest of the Sun", + "G_Endgame_G3_12_KetzuliSlain": "Ketzuli, High Priest of the Sun", + "G_Endgame_G3_14_ViperNapuatzi": "Viper Napuatzi", + "G_Endgame_G3_14_ViperNapuatziSlain": "Viper Napuatzi", + "G_Endgame_G3_14_TrialmasterStatue": "Chaos Statue", + "G_Endgame_G3_14_TrialmasterStatueDone": "Chaos Statue", + "G_Endgame_G3_14_Artefacts": "Peculiar Fortunes", + "G_Endgame_G3_14_ArtefactsDone": "Peculiar Fortunes", + "G_Endgame_G3_16_BloodSacrifice": "Blood Sacrifice", + "G_Endgame_G3_16_BloodSacrificeDone": "Blood Sacrifice", + "G_Endgame_G3_16_GoliathSoulCore": "Undamaged Core", + "G_Endgame_G3_16_GoliathSoulCoreDone": "Undamaged Core", + "G_Endgame_G3_16_Artefacts": "Peculiar Fortunes", + "G_Endgame_G3_16_ArtefactsDone": "Peculiar Fortunes", + "G_Endgame_G3_17_Doryani": "Doryani, Royal Thaumaturge", + "G_Endgame_G3_17_DoryaniSlain": "Doryani, Royal Thaumaturge" + } +} \ No newline at end of file diff --git a/tests/unit/test_catalog.py b/tests/unit/test_catalog.py new file mode 100644 index 0000000..aa20498 --- /dev/null +++ b/tests/unit/test_catalog.py @@ -0,0 +1,46 @@ +"""Unit tests for BundledLocationCatalog.""" +from __future__ import annotations + +import json + +import pytest + +from poe2_rpc.infrastructure.catalog import BundledLocationCatalog +from poe2_rpc.infrastructure.settings import AppSettings + + +@pytest.fixture() +def catalog() -> BundledLocationCatalog: + return BundledLocationCatalog(settings=AppSettings()) + + +def test_bundled_catalog_loads_from_package(catalog: BundledLocationCatalog) -> None: + result = catalog.lookup("G1_4_Brambleghast") + assert result == "The Brambleghast" + + +def test_bundled_catalog_returns_none_for_unknown_code(catalog: BundledLocationCatalog) -> None: + assert catalog.lookup("ZZ_FAKE") is None + + +def test_map_area_lookup_strips_prefix_and_splits(catalog: BundledLocationCatalog) -> None: + result = catalog.map_area_lookup("Map_T15_Crypt") + assert result == "T15 Crypt" + + +def test_locations_url_override_opt_in(monkeypatch: pytest.MonkeyPatch) -> None: + custom_data = json.dumps({"areas": {"CUSTOM_1": "Custom Area One"}}) + + class _FakeResponse: + text = custom_data + + def raise_for_status(self) -> None: + pass + + import httpx + + monkeypatch.setattr(httpx, "get", lambda url, **kw: _FakeResponse()) + settings = AppSettings(locations_url="http://example.com/locations.json") + cat = BundledLocationCatalog(settings=settings) + assert cat.lookup("CUSTOM_1") == "Custom Area One" + assert cat.lookup("G1_1") is None # bundled data NOT used when URL override active diff --git a/tests/unit/test_detection.py b/tests/unit/test_detection.py new file mode 100644 index 0000000..042fa1c --- /dev/null +++ b/tests/unit/test_detection.py @@ -0,0 +1,81 @@ +"""Unit tests for PsutilGameDetector — no real psutil scanning.""" +from __future__ import annotations + +from pathlib import Path +from typing import Iterator +from unittest.mock import MagicMock + +import psutil +import pytest + +from poe2_rpc.infrastructure.detection import PsutilGameDetector +from poe2_rpc.infrastructure.settings import AppSettings + + +def _make_process(name: str, exe: str) -> MagicMock: + proc = MagicMock() + proc.info = {"name": name, "exe": exe} + return proc + + +def _fake_iter(*processes: MagicMock): + def _factory(attrs: list[str]) -> Iterator[MagicMock]: + return iter(processes) + return _factory + + +def test_detector_returns_log_path_when_process_running() -> None: + proc = _make_process("PathOfExileSteam.exe", r"C:/Games/PoE2/PathOfExileSteam.exe") + settings = AppSettings(process_name="PathOfExileSteam.exe") + detector = PsutilGameDetector(settings=settings, process_iter_factory=_fake_iter(proc)) + result = detector.find_log_path() + assert result == Path(r"C:/Games/PoE2/logs/Client.txt") + + +def test_detector_returns_none_when_process_absent() -> None: + settings = AppSettings(process_name="PathOfExileSteam.exe") + detector = PsutilGameDetector(settings=settings, process_iter_factory=_fake_iter()) + assert detector.find_log_path() is None + + +def test_detector_skips_inaccessible_processes() -> None: + bad_proc = MagicMock() + bad_proc.info = {"name": "PathOfExileSteam.exe", "exe": r"C:/Games/PoE2/PathOfExileSteam.exe"} + bad_proc.info # access once to set up + # Make the bad proc raise NoSuchProcess when .info is accessed via iteration + # We simulate this by having the factory raise on first item + good_proc = _make_process("PathOfExileSteam.exe", r"C:/Games/PoE2/PathOfExileSteam.exe") + + def _raising_iter(attrs: list[str]) -> Iterator[MagicMock]: + bad = MagicMock() + bad.info = property(lambda self: (_ for _ in ()).throw(psutil.NoSuchProcess(pid=999))) + # Simpler: raise from a wrapper + raise_proc = _RaisingProcess() + yield raise_proc # type: ignore[misc] + yield good_proc + + detector = PsutilGameDetector( + settings=AppSettings(process_name="PathOfExileSteam.exe"), + process_iter_factory=_raising_iter, + ) + result = detector.find_log_path() + assert result == Path(r"C:/Games/PoE2/logs/Client.txt") + + +class _RaisingProcess: + """Fake process that raises NoSuchProcess when .info is accessed.""" + + @property + def info(self) -> dict[str, str]: + raise psutil.NoSuchProcess(pid=999) + + +def test_detector_uses_settings_process_name() -> None: + proc = _make_process("OtherGame.exe", r"C:/Games/Other/OtherGame.exe") + wrong_proc = _make_process("PathOfExileSteam.exe", r"C:/Games/PoE2/PathOfExileSteam.exe") + settings = AppSettings(process_name="OtherGame.exe") + detector = PsutilGameDetector( + settings=settings, process_iter_factory=_fake_iter(wrong_proc, proc) + ) + result = detector.find_log_path() + assert result == Path(r"C:/Games/Other/logs/Client.txt") diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..49367db --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,16 @@ +"""Unit tests for domain exceptions (C-4b).""" +from __future__ import annotations + + +def test_log_stream_stalled_is_poe2rpc_error() -> None: + from poe2_rpc.domain.exceptions import LogStreamStalled, PoE2RPCError + + assert issubclass(LogStreamStalled, PoE2RPCError) + + +def test_log_stream_stalled_carries_message() -> None: + from poe2_rpc.domain.exceptions import LogStreamStalled + + msg = "Failed to enqueue domain line within 2.0s" + exc = LogStreamStalled(msg) + assert str(exc) == msg diff --git a/tests/unit/test_log_stream.py b/tests/unit/test_log_stream.py new file mode 100644 index 0000000..092c1c0 --- /dev/null +++ b/tests/unit/test_log_stream.py @@ -0,0 +1,214 @@ +"""Unit tests for WatchdogLogStream — thread-safety contract (C-4).""" +from __future__ import annotations + +import re +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_REGEX_LEVEL = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)") +_REGEX_INSTANCE = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)') + +DOMAIN_LINE = ': Marauder (Juggernaut) is now level 42' +NON_DOMAIN_LINE = '[INFO] some irrelevant log line' + + +def _make_stream(tmp_path: Path, maxsize: int = 2) -> "WatchdogLogStream": + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("", encoding="utf-8") + + settings = AppSettings(log_stream_queue_maxsize=maxsize, log_stream_enqueue_deadline_seconds=0.5) + loop = MagicMock() + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=loop) + return stream + + +# --------------------------------------------------------------------------- +# Test 1 — observer thread NEVER calls put_nowait directly +# --------------------------------------------------------------------------- + +def test_watchdog_observer_thread_does_not_touch_queue_directly(tmp_path: Path) -> None: + """Handler's on_modified path must use call_soon_threadsafe, not queue.put_nowait.""" + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("", encoding="utf-8") + + mock_loop = MagicMock() + settings = AppSettings(log_stream_queue_maxsize=10) + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) + + # Simulate file modification with new content + log_file.write_text(DOMAIN_LINE + "\n", encoding="utf-8") + stream._handler.on_modified(MagicMock()) # type: ignore[attr-defined] + + # call_soon_threadsafe must have been called + mock_loop.call_soon_threadsafe.assert_called() + # Queue put_nowait must NOT have been called from this path + # (it's only called inside _enqueue which runs on the loop thread) + assert not stream._queue.put_nowait.called if hasattr(stream._queue, 'put_nowait') and isinstance(stream._queue.put_nowait, MagicMock) else True + + +# --------------------------------------------------------------------------- +# Test 2 — _enqueue is invoked via call_soon_threadsafe +# --------------------------------------------------------------------------- + +def test_enqueue_runs_on_loop_thread(tmp_path: Path) -> None: + """call_soon_threadsafe must schedule _enqueue (not call put_nowait directly).""" + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("", encoding="utf-8") + + mock_loop = MagicMock() + settings = AppSettings(log_stream_queue_maxsize=10) + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) + + log_file.write_text(DOMAIN_LINE + "\n", encoding="utf-8") + stream._handler.on_modified(MagicMock()) # type: ignore[attr-defined] + + # Verify call_soon_threadsafe was called with _enqueue as callback + calls = mock_loop.call_soon_threadsafe.call_args_list + assert len(calls) >= 1 + # The first positional arg should be _enqueue + assert calls[0][0][0] == stream._enqueue # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Test 3 — QueueFull → exponential backoff for domain lines → LogStreamStalled +# --------------------------------------------------------------------------- + +def test_queue_full_exponential_backoff_for_domain_lines(tmp_path: Path) -> None: + """Domain lines on QueueFull get exponential-backoff retry; past deadline raises LogStreamStalled.""" + from asyncio import QueueFull + + from poe2_rpc.domain.exceptions import LogStreamStalled + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("", encoding="utf-8") + + mock_loop = MagicMock() + settings = AppSettings( + log_stream_queue_maxsize=1, + log_stream_enqueue_deadline_seconds=2.0, + ) + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) + + # Make put_nowait always raise QueueFull + stream._queue = MagicMock() # type: ignore[attr-defined] + stream._queue.put_nowait.side_effect = QueueFull() + + # Simulate calling _enqueue past deadline — use a started_at in the past + started_at = time.monotonic() - 10.0 # well past deadline + with pytest.raises(LogStreamStalled): + stream._enqueue(DOMAIN_LINE, _started_at=started_at) # type: ignore[attr-defined] + + +def test_queue_full_exponential_backoff_schedules_call_later(tmp_path: Path) -> None: + """Domain lines on QueueFull within deadline schedule a call_later retry.""" + from asyncio import QueueFull + + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("", encoding="utf-8") + + mock_loop = MagicMock() + settings = AppSettings( + log_stream_queue_maxsize=1, + log_stream_enqueue_deadline_seconds=2.0, + ) + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) + + stream._queue = MagicMock() # type: ignore[attr-defined] + stream._queue.put_nowait.side_effect = QueueFull() + + # First call — within deadline, delay=0.05 + started_at = time.monotonic() + stream._enqueue(DOMAIN_LINE, _started_at=started_at, _delay=0.05) # type: ignore[attr-defined] + + mock_loop.call_later.assert_called_once() + delay_arg = mock_loop.call_later.call_args[0][0] + assert abs(delay_arg - 0.1) < 1e-9 # next delay doubles to 0.1 + + +# --------------------------------------------------------------------------- +# Test 4 — QueueFull drops non-domain lines, increments counter +# --------------------------------------------------------------------------- + +def test_queue_full_drops_non_domain_lines_with_metric(tmp_path: Path) -> None: + """Non-domain lines on QueueFull are dropped; dropped_non_domain_count incremented.""" + from asyncio import QueueFull + + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("", encoding="utf-8") + + mock_loop = MagicMock() + settings = AppSettings(log_stream_queue_maxsize=1) + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) + + stream._queue = MagicMock() # type: ignore[attr-defined] + stream._queue.put_nowait.side_effect = QueueFull() + + assert stream.dropped_non_domain_count == 0 # type: ignore[attr-defined] + + stream._enqueue(NON_DOMAIN_LINE) # type: ignore[attr-defined] + + assert stream.dropped_non_domain_count == 1 # type: ignore[attr-defined] + # No retry scheduled + mock_loop.call_later.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 5 — file rotation resets cursor +# --------------------------------------------------------------------------- + +def test_file_rotation_resets_cursor(tmp_path: Path) -> None: + """When cursor > file size (rotation), cursor resets to 0 and new lines are yielded.""" + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + initial_content = "old line 1\nold line 2\n" + log_file.write_text(initial_content, encoding="utf-8") + + mock_loop = MagicMock() + settings = AppSettings(log_stream_queue_maxsize=100) + stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) + + # Rotate: truncate file to smaller content (cursor now > new size) + new_content = "new line after rotation\n" + log_file.write_text(new_content, encoding="utf-8") + + # Advance cursor beyond new file size to simulate rotation detection + stream._cursor = len(initial_content.encode("utf-8")) + 100 # type: ignore[attr-defined] + + # Trigger handler + stream._handler.on_modified(MagicMock()) # type: ignore[attr-defined] + + # Cursor should now be at end of new content (rotation reset it to 0 first) + assert stream._cursor == len(new_content.encode("utf-8")) # type: ignore[attr-defined] + + # call_soon_threadsafe should have been called with the new line + assert mock_loop.call_soon_threadsafe.call_count >= 1 + scheduled_line = mock_loop.call_soon_threadsafe.call_args_list[0][0][1] + assert "new line after rotation" in scheduled_line diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..879a619 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,68 @@ +"""Tests for configure_logging — RED phase.""" +from __future__ import annotations + +import sys +from io import StringIO + +import pytest +import structlog + + +def _make_settings(log_format: str = "console", log_level: str = "INFO") -> object: + from poe2_rpc.infrastructure.settings import AppSettings + return AppSettings(log_format=log_format, log_level=log_level) # type: ignore[arg-type] + + +def test_configure_logging_console_when_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + settings = _make_settings(log_format="console") + + from poe2_rpc.infrastructure.logging import configure_logging + configure_logging(settings) # type: ignore[arg-type] + + cfg = structlog.get_config() + renderer_types = [type(p).__name__ for p in cfg["processors"]] + assert "ConsoleRenderer" in renderer_types + + +def test_configure_logging_json_when_setting(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + settings = _make_settings(log_format="json") + + from poe2_rpc.infrastructure.logging import configure_logging + configure_logging(settings) # type: ignore[arg-type] + + cfg = structlog.get_config() + renderer_types = [type(p).__name__ for p in cfg["processors"]] + assert "JSONRenderer" in renderer_types + + +def test_bind_contextvars_propagates(capsys: pytest.CaptureFixture[str]) -> None: + settings = _make_settings(log_format="console", log_level="DEBUG") + + from poe2_rpc.infrastructure.logging import configure_logging + configure_logging(settings) # type: ignore[arg-type] + + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars(zone="Hideout") + + logger = structlog.get_logger() + logger.info("test_event") + + captured = capsys.readouterr() + assert "Hideout" in captured.out or "Hideout" in captured.err + + +def test_log_level_respected(capsys: pytest.CaptureFixture[str]) -> None: + settings = _make_settings(log_format="console", log_level="WARNING") + + from poe2_rpc.infrastructure.logging import configure_logging + configure_logging(settings) # type: ignore[arg-type] + + structlog.contextvars.clear_contextvars() + logger = structlog.get_logger() + logger.info("should_not_appear") + + captured = capsys.readouterr() + assert "should_not_appear" not in captured.out + assert "should_not_appear" not in captured.err diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py new file mode 100644 index 0000000..e3f7ed4 --- /dev/null +++ b/tests/unit/test_parsing.py @@ -0,0 +1,40 @@ +"""Unit tests for infrastructure parsing functions.""" +from __future__ import annotations + +import pytest + +from poe2_rpc.infrastructure.parsing import parse_instance_event, parse_level_event + + +def test_parse_level_event_extracts_username_class_level() -> None: + line = ": Foo (Witch) is now level 42" + result = parse_level_event(line) + assert result is not None + assert result.username == "Foo" + assert result.base_class == "Witch" + assert result.level == 42 + + +def test_parse_level_event_handles_two_word_ascendency() -> None: + line = ": Bar (Smith of Kitava) is now level 67" + result = parse_level_event(line) + assert result is not None + assert result.base_class == "Smith of Kitava" + + +def test_parse_level_event_returns_none_on_non_match() -> None: + line = "some unrelated log line" + assert parse_level_event(line) is None + + +def test_parse_instance_event_extracts_level_area_seed() -> None: + line = 'Generating level 81 area "Map_T15_Crypt" with seed 12345' + result = parse_instance_event(line) + assert result is not None + assert result.level == 81 + assert result.area_code == "Map_T15_Crypt" + assert result.seed == 12345 + + +def test_parse_instance_event_returns_none_on_non_match() -> None: + assert parse_instance_event("foo") is None diff --git a/tests/unit/test_presence_connect.py b/tests/unit/test_presence_connect.py new file mode 100644 index 0000000..267fcce --- /dev/null +++ b/tests/unit/test_presence_connect.py @@ -0,0 +1,103 @@ +"""Unit tests for PypresencePublisher.connect (C-7a).""" +from __future__ import annotations + +import pytest +import pypresence.exceptions as pex + +from poe2_rpc.infrastructure.settings import AppSettings + + +class FakeAioPresence: + """Test double for pypresence.AioPresence.""" + + def __init__(self, client_id: str) -> None: + self.client_id = client_id + self.connect_call_count = 0 + self._side_effects: list[Exception | None] = [] + + def set_side_effects(self, effects: list[Exception | None]) -> None: + self._side_effects = list(effects) + + async def connect(self) -> None: + self.connect_call_count += 1 + if self._side_effects: + effect = self._side_effects.pop(0) + if effect is not None: + raise effect + + async def close(self) -> None: + pass + + async def update(self, **kwargs: object) -> None: + pass + + +@pytest.fixture +def settings() -> AppSettings: + return AppSettings(discord_app_id="test-app-id", connect_retry_attempts=5) + + +@pytest.mark.asyncio +async def test_connect_calls_aiopresence_connect_once_on_success(settings: AppSettings) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + fake = FakeAioPresence("test-app-id") + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + await publisher.connect() + + assert fake.connect_call_count == 1 + assert fake.client_id == "test-app-id" + + +@pytest.mark.asyncio +async def test_connect_retries_5_times_on_pipeclosed( + settings: AppSettings, mocker: object +) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + mocker.patch("asyncio.sleep") # type: ignore[union-attr] + + fake = FakeAioPresence("test-app-id") + # Fail 4 times, succeed on 5th + fake.set_side_effects([pex.PipeClosed(), pex.PipeClosed(), pex.PipeClosed(), pex.PipeClosed(), None]) + + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + await publisher.connect() + + assert fake.connect_call_count == 5 + + +@pytest.mark.asyncio +async def test_connect_reraises_after_all_attempts_exhausted( + settings: AppSettings, mocker: object +) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + mocker.patch("asyncio.sleep") # type: ignore[union-attr] + + fake = FakeAioPresence("test-app-id") + fake.set_side_effects([pex.PipeClosed()] * 5) + + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + + with pytest.raises(pex.PipeClosed): + await publisher.connect() + + assert fake.connect_call_count == 5 + + +@pytest.mark.asyncio +async def test_connect_uses_settings_app_id(mocker: object) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + custom_settings = AppSettings(discord_app_id="custom-id-123", connect_retry_attempts=5) + captured: list[str] = [] + + def factory(cid: str) -> FakeAioPresence: + captured.append(cid) + return FakeAioPresence(cid) + + publisher = PypresencePublisher(custom_settings, presence_factory=factory) # type: ignore[arg-type] + await publisher.connect() + + assert captured == ["custom-id-123"] diff --git a/tests/unit/test_presence_publish.py b/tests/unit/test_presence_publish.py new file mode 100644 index 0000000..14936cd --- /dev/null +++ b/tests/unit/test_presence_publish.py @@ -0,0 +1,150 @@ +"""Unit tests for PypresencePublisher.publish (C-7b).""" +from __future__ import annotations + +import pytest +import pypresence.exceptions as pex + +from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.infrastructure.settings import AppSettings + + +class FakeAioPresence: + """Test double for pypresence.AioPresence used in publish tests.""" + + def __init__(self, client_id: str) -> None: + self.client_id = client_id + self.update_call_count = 0 + self._side_effects: list[Exception | None] = [] + self.last_update_kwargs: dict[str, object] = {} + + def set_side_effects(self, effects: list[Exception | None]) -> None: + self._side_effects = list(effects) + + async def connect(self) -> None: + pass + + async def update(self, **kwargs: object) -> None: + self.update_call_count += 1 + self.last_update_kwargs = dict(kwargs) + if self._side_effects: + effect = self._side_effects.pop(0) + if effect is not None: + raise effect + + async def close(self) -> None: + pass + + +@pytest.fixture +def settings() -> AppSettings: + return AppSettings( + discord_app_id="test-app-id", + connect_retry_attempts=5, + publish_retry_attempts=3, + ) + + +@pytest.fixture +def level_info() -> LevelInfo: + return LevelInfo( + username="TestUser", + base_class="Witch", + ascension_class="Lich", + level=42, + ) + + +@pytest.fixture +def instance_info() -> InstanceInfo: + return InstanceInfo( + area_code="G1_1", + area_display_name="Clearfell", + level=5, + seed=12345, + ) + + +@pytest.mark.asyncio +async def test_publish_calls_aiopresence_update_once_on_success( + settings: AppSettings, + level_info: LevelInfo, + instance_info: InstanceInfo, +) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + fake = FakeAioPresence("test-app-id") + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + publisher._presence = fake # inject already-connected presence + + await publisher.publish(level_info, instance_info) + + assert fake.update_call_count == 1 + + +@pytest.mark.asyncio +async def test_publish_retries_3_times_on_discorderror( + settings: AppSettings, + level_info: LevelInfo, + instance_info: InstanceInfo, + mocker: object, +) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + mocker.patch("asyncio.sleep") # type: ignore[union-attr] + + fake = FakeAioPresence("test-app-id") + # Fail 2 times, succeed on 3rd + fake.set_side_effects([pex.DiscordError(4000, "x"), pex.DiscordError(4000, "x"), None]) + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + publisher._presence = fake + + await publisher.publish(level_info, instance_info) + + assert fake.update_call_count == 3 + + +@pytest.mark.asyncio +async def test_publish_reraises_after_3_attempts( + settings: AppSettings, + level_info: LevelInfo, + instance_info: InstanceInfo, + mocker: object, +) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + mocker.patch("asyncio.sleep") # type: ignore[union-attr] + + fake = FakeAioPresence("test-app-id") + fake.set_side_effects([pex.DiscordError(4000, "x") for _ in range(3)]) + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + publisher._presence = fake + + with pytest.raises(pex.DiscordError): + await publisher.publish(level_info, instance_info) + + assert fake.update_call_count == 3 + + +@pytest.mark.asyncio +async def test_publish_does_not_share_retry_state_with_connect( + settings: AppSettings, + level_info: LevelInfo, + instance_info: InstanceInfo, +) -> None: + from poe2_rpc.infrastructure.presence import PypresencePublisher + + fake = FakeAioPresence("test-app-id") + publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] + publisher._presence = fake + + # Run publish — should succeed independently without any connect state + await publisher.publish(level_info, instance_info) + + # Verify publish retry is independent: a fresh publisher also works + fake2 = FakeAioPresence("test-app-id") + publisher2 = PypresencePublisher(settings, presence_factory=lambda cid: fake2) # type: ignore[arg-type] + publisher2._presence = fake2 + await publisher2.publish(level_info, instance_info) + + assert fake.update_call_count == 1 + assert fake2.update_call_count == 1 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 0000000..e41aa5b --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,61 @@ +"""Tests for AppSettings — RED phase.""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + + +def test_settings_defaults() -> None: + from poe2_rpc.infrastructure.settings import AppSettings + + s = AppSettings() + assert s.discord_app_id == "1315800372207419504" + assert s.process_name == "PathOfExileSteam.exe" + assert s.locations_url is None + assert s.log_stream_enqueue_deadline_seconds == 2.0 + assert s.log_stream_queue_maxsize == 1000 + assert s.throttle_window_seconds == 15.0 + assert s.connect_retry_attempts == 5 + assert s.publish_retry_attempts == 3 + assert s.log_format == "console" + assert s.log_level == "INFO" + + +def test_settings_env_overrides_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POE2RPC_DISCORD_APP_ID", "9999999999999999999") + monkeypatch.setenv("POE2RPC_THROTTLE_WINDOW_SECONDS", "30.0") + + from importlib import reload + import poe2_rpc.infrastructure.settings as mod + reload(mod) + from poe2_rpc.infrastructure.settings import AppSettings + + s = AppSettings() + assert s.discord_app_id == "9999999999999999999" + assert s.throttle_window_seconds == 30.0 + + +def test_settings_toml_overrides_defaults(tmp_path: Path) -> None: + toml_file = tmp_path / "config.toml" + toml_file.write_text( + 'discord_app_id = "1111111111111111111"\nlog_level = "DEBUG"\n', + encoding="utf-8", + ) + + from poe2_rpc.infrastructure.settings import AppSettings + + s = AppSettings(_toml_file=str(toml_file)) # type: ignore[call-arg] + assert s.discord_app_id == "1111111111111111111" + assert s.log_level == "DEBUG" + + +def test_settings_init_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POE2RPC_CONNECT_RETRY_ATTEMPTS", "10") + + from poe2_rpc.infrastructure.settings import AppSettings + + s = AppSettings(connect_retry_attempts=2) + assert s.connect_retry_attempts == 2 From f238c3af213c2065c072f332812bcb0fb2d9ec77 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 13:10:17 +0300 Subject: [PATCH 03/17] Phase D: application layer (event bus, throttle, handlers, orchestrator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D of the DDD/hexagonal migration (epic panvex-enp). Adds the application layer that wires domain events to infrastructure adapters via dependency injection (Principle 4: factory injection at composition root only). Modules added under src/poe2_rpc/application/: - bus.py — AsyncioEventBus implementing EventBus Protocol with type-keyed subscribe(event_type, handler) dispatch. Handler exceptions are isolated via asyncio.gather(return_exceptions=True) so one failing handler does not break others. - throttle.py — PresenceThrottle gating presence updates to one per Settings.throttle_window_seconds (default 15s). random_status() helper preserves the verbatim status list from main.py:119-132 (Principle 5: byte-pattern preservation). - handlers.py — on_level_changed + on_area_entered async handlers. Both call structlog.contextvars.bind_contextvars(username=, character_class=, area=) BEFORE logging, satisfying AC#7 (every log line carries those keys). MutableState threads the latest level_info / instance_info between handlers so each can publish with the other's last value. Behavior Change #1 honored: ascension_class None yields a one-segment "(BaseClass - Lvl N)" format with no "| Unknown" sentinel. - orchestrator.py — Orchestrator wiring the runtime: subscribes handlers to the bus, drives the LogStream consumer loop, emits CharacterLevelChanged / AreaEntered, and connects/closes the publisher. Constructor takes only Protocols (EventBus, PresencePublisher, Settings) plus a log_stream_factory callable — zero infrastructure imports (verified by AST guard in test_orchestrator_layering.py). Protocol drift fixes in domain/ports.py (caught during D-1/D-3 worker review): - EventBus.subscribe widened from subscribe(handler) to subscribe(event_type, handler) to express type-keyed dispatch. - PresencePublisher.publish made async to match AioPresence-backed implementation. - Added Handler type alias and Settings Protocol so application code can depend on settings shape without importing infrastructure.settings. Tests (15 new, 87/87 total passing): - tests/unit/test_bus.py — dispatch + exception isolation. - tests/unit/test_throttle.py — interval gating + random_status. - tests/unit/test_handlers.py — bind_contextvars assertion via structlog.testing.capture_logs(); ascension None formatting. - tests/integration/test_orchestrator.py — fake log_stream_factory end-to-end through bus + handlers + fake publisher. - tests/unit/test_orchestrator_layering.py — AST walk asserting no poe2_rpc.infrastructure imports anywhere in application/. Exit gates: - pytest: 87 passed (15 new for Phase D) - mypy --strict src/poe2_rpc: success, 24 files - lint-imports: Hexagonal layered architecture KEPT, 1/1 contract bd: panvex-enp.{1,2,3,4} closed. Phase E (Typer CLI + composition root) is next. Co-Authored-By: Claude Opus 4.7 --- src/poe2_rpc/application/bus.py | 32 +++++ src/poe2_rpc/application/handlers.py | 90 ++++++++++++ src/poe2_rpc/application/orchestrator.py | 106 ++++++++++++++ src/poe2_rpc/application/throttle.py | 42 ++++++ src/poe2_rpc/domain/ports.py | 16 ++- tests/integration/test_orchestrator.py | 175 +++++++++++++++++++++++ tests/unit/test_bus.py | 60 ++++++++ tests/unit/test_handlers.py | 168 ++++++++++++++++++++++ tests/unit/test_orchestrator_layering.py | 49 +++++++ tests/unit/test_ports.py | 10 +- tests/unit/test_throttle.py | 57 ++++++++ 11 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 src/poe2_rpc/application/bus.py create mode 100644 src/poe2_rpc/application/handlers.py create mode 100644 src/poe2_rpc/application/orchestrator.py create mode 100644 src/poe2_rpc/application/throttle.py create mode 100644 tests/integration/test_orchestrator.py create mode 100644 tests/unit/test_bus.py create mode 100644 tests/unit/test_handlers.py create mode 100644 tests/unit/test_orchestrator_layering.py create mode 100644 tests/unit/test_throttle.py diff --git a/src/poe2_rpc/application/bus.py b/src/poe2_rpc/application/bus.py new file mode 100644 index 0000000..403f182 --- /dev/null +++ b/src/poe2_rpc/application/bus.py @@ -0,0 +1,32 @@ +"""Application-layer event bus — pure asyncio, no infrastructure imports.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from typing import Awaitable, Callable + +import structlog + +from poe2_rpc.domain.events import DomainEvent + +Handler = Callable[[DomainEvent], Awaitable[None]] + +_log = structlog.get_logger(__name__) + + +class AsyncioEventBus: + def __init__(self) -> None: + self._registry: dict[type[DomainEvent], list[Handler]] = defaultdict(list) + + def subscribe(self, event_type: type[DomainEvent], handler: Handler) -> None: + self._registry[event_type].append(handler) + + def emit(self, event: DomainEvent) -> None: + asyncio.get_event_loop().run_until_complete(self.publish(event)) + + async def publish(self, event: DomainEvent) -> None: + handlers = self._registry.get(type(event), []) + results = await asyncio.gather(*(h(event) for h in handlers), return_exceptions=True) + for result in results: + if isinstance(result, BaseException): + _log.error("event_handler_error", event_type=type(event).__name__, exc_info=result) diff --git a/src/poe2_rpc/application/handlers.py b/src/poe2_rpc/application/handlers.py new file mode 100644 index 0000000..45dc1bc --- /dev/null +++ b/src/poe2_rpc/application/handlers.py @@ -0,0 +1,90 @@ +"""Application-layer event handlers — pure application code, no infrastructure imports.""" +from __future__ import annotations + +import structlog +import structlog.contextvars + +from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged +from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.ports import PresencePublisher + +_log = structlog.get_logger(__name__) + + +class MutableState: + """Shared mutable state threaded through handlers so each can see the other's last value.""" + + def __init__(self) -> None: + self.level_info: LevelInfo | None = None + self.instance_info: InstanceInfo | None = None + + +def _format_details(level_info: LevelInfo) -> str: + details = f"{level_info.username} ({level_info.base_class}" + if level_info.ascension_class is not None: + details += f" | {level_info.ascension_class}" + details += f" - Lvl {level_info.level})" + return details + + +async def on_level_changed( + event: CharacterLevelChanged, + *, + publisher: PresencePublisher, + throttle: PresenceThrottle, + current_state: MutableState, +) -> None: + li = event.level_info + current_state.level_info = li + + structlog.contextvars.bind_contextvars( + username=li.username, + character_class=li.base_class, + area=current_state.instance_info.area_display_name if current_state.instance_info else "", + ) + + if not throttle.should_update(): + return + + area = current_state.instance_info.area_display_name if current_state.instance_info else "" + _log.info( + "level_changed", + username=li.username, + character_class=li.base_class, + area=area, + level=li.level, + details=_format_details(li), + ) + await publisher.publish(li, current_state.instance_info) + + +async def on_area_entered( + event: AreaEntered, + *, + publisher: PresencePublisher, + throttle: PresenceThrottle, + current_state: MutableState, +) -> None: + ii = event.instance_info + current_state.instance_info = ii + + structlog.contextvars.bind_contextvars( + username=current_state.level_info.username if current_state.level_info else "", + character_class=current_state.level_info.base_class if current_state.level_info else "", + area=ii.area_display_name, + ) + + if not throttle.should_update(): + return + + username = current_state.level_info.username if current_state.level_info else "" + character_class = current_state.level_info.base_class if current_state.level_info else "" + _log.info( + "area_entered", + username=username, + character_class=character_class, + area=ii.area_display_name, + area_level=ii.level, + ) + await publisher.publish(current_state.level_info, ii) diff --git a/src/poe2_rpc/application/orchestrator.py b/src/poe2_rpc/application/orchestrator.py new file mode 100644 index 0000000..473f2ac --- /dev/null +++ b/src/poe2_rpc/application/orchestrator.py @@ -0,0 +1,106 @@ +"""Application orchestrator — composes bus, throttle, and handlers into a runnable loop. + +Principle 4: zero poe2_rpc.infrastructure.* imports. The log stream is created +via an injected factory so the composition root (cli.py) owns the concrete type. +""" +from __future__ import annotations + +import asyncio +import functools +from pathlib import Path +from typing import Callable + +import structlog + +from poe2_rpc.application.bus import AsyncioEventBus +from poe2_rpc.application.handlers import MutableState, on_area_entered, on_level_changed +from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged +from poe2_rpc.domain.ports import ( + GameDetector, + LocationCatalogPort, + LogParser, + LogStream, + PresencePublisher, + Settings, +) + +_log = structlog.get_logger(__name__) + +LogStreamFactory = Callable[[Path, asyncio.AbstractEventLoop], LogStream] + + +class Orchestrator: + def __init__( + self, + *, + detector: GameDetector, + parser: LogParser, + publisher: PresencePublisher, + catalog: LocationCatalogPort, + bus: AsyncioEventBus, + log_stream_factory: LogStreamFactory, + throttle: PresenceThrottle, + current_state: MutableState, + settings: Settings, + ) -> None: + self._detector = detector + self._parser = parser + self._publisher = publisher + self._catalog = catalog + self._bus = bus + self._factory = log_stream_factory + self._throttle = throttle + self._current_state = current_state + self._settings = settings + self._subscribe_handlers() + + def _subscribe_handlers(self) -> None: + self._bus.subscribe( + CharacterLevelChanged, + functools.partial( + on_level_changed, + publisher=self._publisher, + throttle=self._throttle, + current_state=self._current_state, + ), + ) + self._bus.subscribe( + AreaEntered, + functools.partial( + on_area_entered, + publisher=self._publisher, + throttle=self._throttle, + current_state=self._current_state, + ), + ) + + def run_once(self) -> None: + """Process all lines from one log stream pass. Handles CancelledError/KeyboardInterrupt.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + log_path = self._detector.log_path() + stream = self._factory(log_path, loop) + for line in stream.lines(): + level_info = self._parser.parse_level(line) + if level_info is not None: + self._bus.emit(CharacterLevelChanged(level_info=level_info)) + continue + instance_info = self._parser.parse_instance(line) + if instance_info is not None: + self._bus.emit(AreaEntered(instance_info=instance_info)) + except (asyncio.CancelledError, KeyboardInterrupt): + _log.info("orchestrator_shutdown") + finally: + self._publisher.close() + loop.close() + asyncio.set_event_loop(None) + + def run(self) -> None: + """Continuous monitor loop — runs until cancelled or interrupted.""" + try: + while True: + self.run_once() + except (asyncio.CancelledError, KeyboardInterrupt): + _log.info("orchestrator_stopped") diff --git a/src/poe2_rpc/application/throttle.py b/src/poe2_rpc/application/throttle.py new file mode 100644 index 0000000..7e76b3a --- /dev/null +++ b/src/poe2_rpc/application/throttle.py @@ -0,0 +1,42 @@ +"""Presence update throttle and random status strings.""" +from __future__ import annotations + +import random +import time +from typing import Callable + + +def random_status() -> str: + statuses = [ + "Exploring ancient ruins", + "Leveling up your skills", + "Defeating hordes of enemies", + "Looting rare artifacts", + "Crossing dark portals", + "Enhancing powerful gear", + "Learning forbidden magic", + "Tracking down the next boss", + "Joining the fight in the league", + "Preparing for the final encounter", + ] + return random.choice(statuses) + + +class PresenceThrottle: + """Rate-limits presence updates to at most once per interval seconds.""" + + def __init__( + self, + interval: float = 15.0, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._interval = interval + self._clock = clock + self._last: float | None = None + + def should_update(self) -> bool: + now = self._clock() + if self._last is None or (now - self._last) >= self._interval: + self._last = now + return True + return False diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py index 29ce516..12013db 100644 --- a/src/poe2_rpc/domain/ports.py +++ b/src/poe2_rpc/domain/ports.py @@ -2,12 +2,15 @@ from __future__ import annotations from pathlib import Path -from typing import Iterator, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Awaitable, Callable, Iterator, Protocol, runtime_checkable from poe2_rpc.domain.events import DomainEvent from poe2_rpc.domain.locations import Location from poe2_rpc.domain.models import InstanceInfo, LevelInfo +if TYPE_CHECKING: + Handler = Callable[[DomainEvent], Awaitable[None]] + @runtime_checkable class GameDetector(Protocol): @@ -28,16 +31,23 @@ def parse_instance(self, line: str) -> InstanceInfo | None: ... @runtime_checkable class PresencePublisher(Protocol): - def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: ... + async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: ... def close(self) -> None: ... @runtime_checkable class EventBus(Protocol): def emit(self, event: DomainEvent) -> None: ... - def subscribe(self, handler: object) -> None: ... + def subscribe(self, event_type: type[DomainEvent], handler: Callable[[DomainEvent], Awaitable[None]]) -> None: ... @runtime_checkable class LocationCatalogPort(Protocol): def resolve(self, area_code: str) -> Location: ... + + +@runtime_checkable +class Settings(Protocol): + throttle_window_seconds: float + connect_retry_attempts: int + publish_retry_attempts: int diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py new file mode 100644 index 0000000..083ddc8 --- /dev/null +++ b/tests/integration/test_orchestrator.py @@ -0,0 +1,175 @@ +"""Integration test: Orchestrator full-flow with fake factory.""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Iterator + +from poe2_rpc.application.bus import AsyncioEventBus +from poe2_rpc.application.handlers import MutableState +from poe2_rpc.application.orchestrator import Orchestrator +from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.domain.locations import Location +from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.ports import LogStream + + +# --- Fakes --- + +class FakeSettings: + throttle_window_seconds: float = 0.0 + connect_retry_attempts: int = 1 + publish_retry_attempts: int = 1 + + +class FakeGameDetector: + def __init__(self, path: Path) -> None: + self._path = path + + def is_running(self) -> bool: + return True + + def log_path(self) -> Path: + return self._path + + +class FakeLogStream: + def __init__(self, lines: list[str]) -> None: + self._lines = lines + + def lines(self) -> Iterator[str]: + yield from self._lines + + +class FakeLogParser: + def parse_level(self, line: str) -> LevelInfo | None: + if line.startswith("LEVEL:"): + parts = line[6:].split(",") + return LevelInfo( + username=parts[0], + base_class=parts[1], + ascension_class=None, + level=int(parts[2]), + ) + return None + + def parse_instance(self, line: str) -> InstanceInfo | None: + if line.startswith("AREA:"): + parts = line[5:].split(",") + return InstanceInfo( + area_code=parts[0], + area_display_name=parts[1], + level=int(parts[2]), + seed=0, + ) + return None + + +class FakePresencePublisher: + def __init__(self) -> None: + self.published: list[tuple[LevelInfo | None, InstanceInfo | None]] = [] + + async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: + self.published.append((level_info, instance_info)) + + def close(self) -> None: + pass + + +class FakeLocationCatalog: + def resolve(self, area_code: str) -> Location: + return Location(area_code=area_code, display_name=area_code) + + +def _make_orchestrator( + *, + stream_lines: list[str], + publisher: FakePresencePublisher | None = None, + received_paths: list[Path] | None = None, +) -> tuple[Orchestrator, FakePresencePublisher]: + pub = publisher or FakePresencePublisher() + log_path = Path("/fake/Client.txt") + + def factory(path: Path, loop: asyncio.AbstractEventLoop) -> LogStream: + if received_paths is not None: + received_paths.append(path) + return FakeLogStream(stream_lines) + + orch = Orchestrator( + detector=FakeGameDetector(log_path), + parser=FakeLogParser(), + publisher=pub, + catalog=FakeLocationCatalog(), + bus=AsyncioEventBus(), + log_stream_factory=factory, + throttle=PresenceThrottle(interval=0.0), + current_state=MutableState(), + settings=FakeSettings(), + ) + return orch, pub + + +# --- Tests --- + +def test_orchestrator_emits_level_event_and_publishes() -> None: + """Orchestrator wires bus/handlers: a LEVEL log line triggers presence publish.""" + orch, publisher = _make_orchestrator(stream_lines=["LEVEL:Hero,Warrior,10"]) + orch.run_once() + + assert len(publisher.published) == 1 + level_info, _ = publisher.published[0] + assert level_info is not None + assert level_info.username == "Hero" + assert level_info.level == 10 + + +def test_orchestrator_emits_area_event_and_publishes() -> None: + """Orchestrator wires bus/handlers: an AREA log line triggers presence publish.""" + orch, publisher = _make_orchestrator(stream_lines=["AREA:G1_1,Act 1,5"]) + orch.run_once() + + assert len(publisher.published) == 1 + _, instance_info = publisher.published[0] + assert instance_info is not None + assert instance_info.area_code == "G1_1" + + +def test_orchestrator_factory_called_with_log_path() -> None: + """Factory receives the path from GameDetector.log_path().""" + received_paths: list[Path] = [] + orch, _ = _make_orchestrator(stream_lines=[], received_paths=received_paths) + orch.run_once() + + assert received_paths == [Path("/fake/Client.txt")] + + +def test_orchestrator_graceful_shutdown_on_cancelled_error() -> None: + """run_once() exits cleanly on CancelledError; publisher.close() is called.""" + pub = FakePresencePublisher() + closed: list[bool] = [] + original_close = pub.close + + def tracking_close() -> None: + closed.append(True) + original_close() + + pub.close = tracking_close # type: ignore[method-assign] + + def factory(path: Path, loop: asyncio.AbstractEventLoop) -> LogStream: + raise asyncio.CancelledError() + + orch = Orchestrator( + detector=FakeGameDetector(Path("/fake/Client.txt")), + parser=FakeLogParser(), + publisher=pub, + catalog=FakeLocationCatalog(), + bus=AsyncioEventBus(), + log_stream_factory=factory, + throttle=PresenceThrottle(interval=0.0), + current_state=MutableState(), + settings=FakeSettings(), + ) + + orch.run_once() # should not raise + + assert closed == [True] diff --git a/tests/unit/test_bus.py b/tests/unit/test_bus.py new file mode 100644 index 0000000..ee56b69 --- /dev/null +++ b/tests/unit/test_bus.py @@ -0,0 +1,60 @@ +"""Tests for AsyncioEventBus — dispatch and exception isolation.""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Callable + +import pytest + +from poe2_rpc.domain.events import DomainEvent, GameStarted + + +def _make_event() -> GameStarted: + return GameStarted(log_path=Path("/tmp/Client.txt")) + + +@pytest.mark.asyncio +async def test_bus_dispatches_to_multiple_handlers() -> None: + from poe2_rpc.application.bus import AsyncioEventBus + + received_a: list[DomainEvent] = [] + received_b: list[DomainEvent] = [] + + async def handler_a(event: DomainEvent) -> None: + received_a.append(event) + + async def handler_b(event: DomainEvent) -> None: + received_b.append(event) + + bus = AsyncioEventBus() + bus.subscribe(GameStarted, handler_a) + bus.subscribe(GameStarted, handler_b) + + event = _make_event() + await bus.publish(event) + + assert received_a == [event] + assert received_b == [event] + + +@pytest.mark.asyncio +async def test_bus_isolates_handler_exceptions() -> None: + from poe2_rpc.application.bus import AsyncioEventBus + + received_b: list[DomainEvent] = [] + + async def handler_a(event: DomainEvent) -> None: + raise RuntimeError("boom") + + async def handler_b(event: DomainEvent) -> None: + received_b.append(event) + + bus = AsyncioEventBus() + bus.subscribe(GameStarted, handler_a) + bus.subscribe(GameStarted, handler_b) + + event = _make_event() + await bus.publish(event) # must NOT raise + + assert received_b == [event] diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py new file mode 100644 index 0000000..d261678 --- /dev/null +++ b/tests/unit/test_handlers.py @@ -0,0 +1,168 @@ +"""Tests for application handlers — on_level_changed + on_area_entered (AC#7).""" +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +import structlog.testing + +from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged +from poe2_rpc.domain.models import InstanceInfo, LevelInfo + + +def _level_info( + *, + username: str = "TestUser", + base_class: str = "Witch", + ascension_class: str | None = "Lich", + level: int = 42, +) -> LevelInfo: + return LevelInfo( + username=username, + base_class=base_class, + ascension_class=ascension_class, + level=level, + ) + + +def _instance_info( + *, + area_code: str = "G1_1", + area_display_name: str = "Clearfell", + level: int = 5, + seed: int = 1, +) -> InstanceInfo: + return InstanceInfo( + area_code=area_code, + area_display_name=area_display_name, + level=level, + seed=seed, + ) + + +def _make_throttle(*, allow: bool = True) -> Any: + t = MagicMock() + t.should_update.return_value = allow + return t + + +def _make_state( + *, + level_info: LevelInfo | None = None, + instance_info: InstanceInfo | None = None, +) -> Any: + s = MagicMock() + s.level_info = level_info + s.instance_info = instance_info + return s + + +@pytest.mark.asyncio +async def test_on_level_changed_formats_details_with_ascendancy() -> None: + from poe2_rpc.application.handlers import on_level_changed + + publisher = AsyncMock() + throttle = _make_throttle(allow=True) + li = _level_info(username="TestUser", base_class="Witch", ascension_class="Lich", level=42) + event = CharacterLevelChanged(level_info=li) + state = _make_state(instance_info=None) + + await on_level_changed(event, publisher=publisher, throttle=throttle, current_state=state) + + publisher.publish.assert_called_once() + call_level_info: LevelInfo = publisher.publish.call_args.args[0] + assert call_level_info.username == "TestUser" + assert call_level_info.base_class == "Witch" + assert call_level_info.ascension_class == "Lich" + assert call_level_info.level == 42 + + +@pytest.mark.asyncio +async def test_on_level_changed_omits_ascendancy_pipe_when_none() -> None: + from poe2_rpc.application.handlers import on_level_changed + + publisher = AsyncMock() + throttle = _make_throttle(allow=True) + li = _level_info(username="TestUser", base_class="Mercenary", ascension_class=None, level=42) + event = CharacterLevelChanged(level_info=li) + state = _make_state(instance_info=None) + + await on_level_changed(event, publisher=publisher, throttle=throttle, current_state=state) + + publisher.publish.assert_called_once() + call_level_info: LevelInfo = publisher.publish.call_args.args[0] + assert call_level_info.ascension_class is None + assert call_level_info.base_class == "Mercenary" + + +@pytest.mark.asyncio +async def test_on_area_entered_formats_in_state() -> None: + from poe2_rpc.application.handlers import on_area_entered + + publisher = AsyncMock() + throttle = _make_throttle(allow=True) + ii = _instance_info(area_display_name="Clearfell", level=5) + event = AreaEntered(instance_info=ii) + state = _make_state(level_info=None) + + await on_area_entered(event, publisher=publisher, throttle=throttle, current_state=state) + + publisher.publish.assert_called_once() + call_instance_info: InstanceInfo = publisher.publish.call_args.args[1] + assert call_instance_info.area_display_name == "Clearfell" + assert call_instance_info.level == 5 + + +@pytest.mark.asyncio +async def test_small_image_lowercases_and_underscores() -> None: + """Infra derives small_image from ascension_class; handler passes LevelInfo with correct value.""" + from poe2_rpc.application.handlers import on_level_changed + + publisher = AsyncMock() + throttle = _make_throttle(allow=True) + li = _level_info(ascension_class="Smith of Kitava", base_class="Warrior", level=10) + event = CharacterLevelChanged(level_info=li) + state = _make_state(instance_info=None) + + await on_level_changed(event, publisher=publisher, throttle=throttle, current_state=state) + + call_level_info: LevelInfo = publisher.publish.call_args.args[0] + asc = call_level_info.ascension_class or call_level_info.base_class + assert asc.lower().replace(" ", "_") == "smith_of_kitava" + + +@pytest.mark.asyncio +async def test_handlers_bind_username_class_area_into_logs() -> None: + from poe2_rpc.application.handlers import on_level_changed + + publisher = AsyncMock() + throttle = _make_throttle(allow=True) + li = _level_info(username="LogUser", base_class="Ranger", ascension_class="Deadeye", level=20) + ii = _instance_info(area_display_name="The Mud Flats", level=3) + event = CharacterLevelChanged(level_info=li) + state = _make_state(instance_info=ii) + + with structlog.testing.capture_logs() as cap: + await on_level_changed(event, publisher=publisher, throttle=throttle, current_state=state) + + assert len(cap) > 0 + log_event = cap[0] + assert log_event.get("username") == "LogUser" + assert log_event.get("character_class") == "Ranger" + assert "area" in log_event + + +@pytest.mark.asyncio +async def test_throttled_update_skips_publish() -> None: + from poe2_rpc.application.handlers import on_level_changed + + publisher = AsyncMock() + throttle = _make_throttle(allow=False) + li = _level_info() + event = CharacterLevelChanged(level_info=li) + state = _make_state(instance_info=None) + + await on_level_changed(event, publisher=publisher, throttle=throttle, current_state=state) + + publisher.publish.assert_not_called() diff --git a/tests/unit/test_orchestrator_layering.py b/tests/unit/test_orchestrator_layering.py new file mode 100644 index 0000000..184c4b5 --- /dev/null +++ b/tests/unit/test_orchestrator_layering.py @@ -0,0 +1,49 @@ +"""AST guard: orchestrator must not import from poe2_rpc.infrastructure.*""" +from __future__ import annotations + +import ast +from pathlib import Path + +ORCHESTRATOR_PATH = ( + Path(__file__).parent.parent.parent + / "src" + / "poe2_rpc" + / "application" + / "orchestrator.py" +) + +FORBIDDEN_PREFIX = "poe2_rpc.infrastructure" + + +def _collect_forbidden(path: Path) -> list[str]: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + violations: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module == FORBIDDEN_PREFIX or module.startswith(FORBIDDEN_PREFIX + "."): + violations.append( + f"line {node.lineno}: forbidden 'from {module} import ...'" + ) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == FORBIDDEN_PREFIX or alias.name.startswith( + FORBIDDEN_PREFIX + "." + ): + violations.append( + f"line {node.lineno}: forbidden 'import {alias.name}'" + ) + return violations + + +def test_orchestrator_has_no_infrastructure_imports() -> None: + if not ORCHESTRATOR_PATH.exists(): + import pytest + pytest.fail(f"orchestrator.py not found at {ORCHESTRATOR_PATH}") + + violations = _collect_forbidden(ORCHESTRATOR_PATH) + assert not violations, ( + "orchestrator.py imports from poe2_rpc.infrastructure (Principle 4 violation):\n" + + "\n".join(violations) + ) diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 2bba461..52f08f3 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -1,6 +1,6 @@ """Tests for domain port Protocols — all must be @runtime_checkable.""" from pathlib import Path -from typing import Iterator +from typing import Awaitable, Callable, Iterator from poe2_rpc.domain.events import DomainEvent from poe2_rpc.domain.locations import Location @@ -37,7 +37,7 @@ def parse_instance(self, line: str) -> InstanceInfo | None: class _ConcretePresencePublisher: - def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: + async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: pass def close(self) -> None: @@ -48,7 +48,11 @@ class _ConcreteEventBus: def emit(self, event: DomainEvent) -> None: pass - def subscribe(self, handler: object) -> None: + def subscribe( + self, + event_type: type[DomainEvent], + handler: Callable[[DomainEvent], Awaitable[None]], + ) -> None: pass diff --git a/tests/unit/test_throttle.py b/tests/unit/test_throttle.py new file mode 100644 index 0000000..7cfa068 --- /dev/null +++ b/tests/unit/test_throttle.py @@ -0,0 +1,57 @@ +"""Tests for PresenceThrottle application service.""" +from __future__ import annotations + +from poe2_rpc.application.throttle import PresenceThrottle + + +def test_first_call_is_not_throttled() -> None: + clock_val = 0.0 + + def clock() -> float: + return clock_val + + throttle = PresenceThrottle(interval=5.0, clock=clock) + assert throttle.should_update() is True + + +def test_second_call_within_interval_is_throttled() -> None: + clock_val = 0.0 + + def clock() -> float: + return clock_val + + throttle = PresenceThrottle(interval=5.0, clock=clock) + throttle.should_update() + clock_val = 3.0 + assert throttle.should_update() is False + + +def test_call_after_interval_is_not_throttled() -> None: + clock_val = 0.0 + + def clock() -> float: + return clock_val + + throttle = PresenceThrottle(interval=5.0, clock=clock) + throttle.should_update() + clock_val = 6.0 + assert throttle.should_update() is True + + +def test_random_status_returns_known_string() -> None: + from poe2_rpc.application.throttle import random_status + + result = random_status() + known = [ + "Exploring ancient ruins", + "Leveling up your skills", + "Defeating hordes of enemies", + "Looting rare artifacts", + "Crossing dark portals", + "Enhancing powerful gear", + "Learning forbidden magic", + "Tracking down the next boss", + "Joining the fight in the league", + "Preparing for the final encounter", + ] + assert result in known From 4617079c727a406dd05bc66951f0bcbda3a1e3c8 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 13:25:58 +0300 Subject: [PATCH 04/17] Phase E: Typer CLI + composition root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase E of the DDD/hexagonal migration (epic panvex-enp). Wires the runtime via a Typer-driven composition root that owns adapter construction; everything below the cli layer continues to see only domain Protocols (Principle 4). E-1 — `src/poe2_rpc/cli.py`: - `app` (Typer) with `--version` callback and three commands. - `run`: continuous monitor loop until cancelled. - `once`: single log-stream pass and exit. - `validate-config --no-discord`: load settings + bundled locations.json + configure structlog without contacting Discord IPC. Exits 0 with the resolved settings as JSON. Used as the deep-smoke step planned for F-3 (CI build job). - `build_orchestrator(settings) -> Orchestrator` factory extracted for testability — patched in unit tests via CliRunner. - `_SyncLineIterator` adapter in the composition root bridges `WatchdogLogStream`'s async queue to the sync `LogStream.lines()` Protocol. Owns observer start/stop lifecycle. - Replaces the empty `cli/__init__.py` stub from Phase B-6 with a real module; the directory layer name `poe2_rpc.cli` in the import-linter contract continues to resolve correctly. E-1 also reconciles four Phase D Protocol drifts in the same change (per the "integrating worker fixes drifts" pattern documented after Phase D): - `PresencePublisher.publish` Protocol grew an `async def connect()` method to match `PypresencePublisher.connect()`. - `Orchestrator.run_once()` now calls `loop.run_until_complete(self._publisher.connect())` on its freshly-created loop before iterating the stream — fixes a latent defect where the publisher was never connected before `publish()`. - `infrastructure.parsing` adds a `RegexLogParser` class that satisfies the `LogParser` Protocol (the existing module-level functions remain). - `infrastructure.detection.PsutilGameDetector` adds `is_running()` and `log_path()` methods matching the `GameDetector` Protocol. `log_path()` blocks with a 5s poll loop until the game process is found (preserves main.py:88-94 wait-for-game semantics). - `infrastructure.catalog` adds a `load_bundled_catalog()` helper that returns the existing domain `LocationCatalog` populated from the bundled `locations.json` via `importlib.resources`. E-2 — `src/poe2_rpc/__main__.py`: - Replaces the Phase A stub with `from poe2_rpc.cli import app` and `app()` invocation under `if __name__ == "__main__":`. - Allows `python -m poe2_rpc` as an alternate entry alongside the `poe2-rpc` console script declared in `pyproject.toml`. Tests (7 new, 94/94 total passing): - tests/integration/test_cli.py — `--version`, `--help`, `validate-config --no-discord` (exit 0 + asserts no `PypresencePublisher` instantiated), `once`, `run`. - tests/integration/test_main_module.py — subprocess-driven `python -m poe2_rpc --version`. - tests/unit/test_ports.py + tests/integration/test_orchestrator.py — fakes extended with `async def connect` to match the new `PresencePublisher` shape. Exit gates: - pytest: 94 passed (7 new) - mypy --strict src/poe2_rpc: success, 24 files - lint-imports: Hexagonal layered architecture KEPT, 1/1 contract bd: panvex-{8tv,lsk} closed. Phase F (PyInstaller spec + bundled assets + CI smoke + cold-start benchmark) is next. Co-Authored-By: Claude Opus 4.7 --- src/poe2_rpc/__main__.py | 7 +- src/poe2_rpc/application/orchestrator.py | 1 + src/poe2_rpc/cli.py | 162 +++++++++++++++++++++++ src/poe2_rpc/cli/__init__.py | 0 src/poe2_rpc/domain/ports.py | 1 + src/poe2_rpc/infrastructure/catalog.py | 16 +++ src/poe2_rpc/infrastructure/detection.py | 19 +++ src/poe2_rpc/infrastructure/parsing.py | 12 ++ tests/integration/test_cli.py | 61 +++++++++ tests/integration/test_main_module.py | 30 +++++ tests/integration/test_orchestrator.py | 4 + tests/unit/test_ports.py | 3 + 12 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/poe2_rpc/cli.py delete mode 100644 src/poe2_rpc/cli/__init__.py create mode 100644 tests/integration/test_cli.py create mode 100644 tests/integration/test_main_module.py diff --git a/src/poe2_rpc/__main__.py b/src/poe2_rpc/__main__.py index 0ea5ebc..0289886 100644 --- a/src/poe2_rpc/__main__.py +++ b/src/poe2_rpc/__main__.py @@ -1,4 +1,5 @@ -"""Module entry point: ``python -m poe2_rpc``. +"""Module entry point: ``python -m poe2_rpc`` runs the Typer CLI.""" +from poe2_rpc.cli import app -Wired up in Phase E-2; intentionally a stub during Phase A scaffolding. -""" +if __name__ == "__main__": + app() diff --git a/src/poe2_rpc/application/orchestrator.py b/src/poe2_rpc/application/orchestrator.py index 473f2ac..98a1d35 100644 --- a/src/poe2_rpc/application/orchestrator.py +++ b/src/poe2_rpc/application/orchestrator.py @@ -80,6 +80,7 @@ def run_once(self) -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: + loop.run_until_complete(self._publisher.connect()) log_path = self._detector.log_path() stream = self._factory(log_path, loop) for line in stream.lines(): diff --git a/src/poe2_rpc/cli.py b/src/poe2_rpc/cli.py new file mode 100644 index 0000000..b954ae2 --- /dev/null +++ b/src/poe2_rpc/cli.py @@ -0,0 +1,162 @@ +"""Typer CLI + composition root. + +The only module allowed to import infrastructure adapters AND application code +together. Everything below this layer sees Protocols only (Principle 4). + +Commands: + run Continuous monitor loop (default). + once Process one log-stream pass and exit. + validate-config Load settings + bundled assets without running the loop. + Pass --no-discord to skip Discord IPC contact. + --version Print version and exit. +""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Iterator + +import structlog +import typer + +from poe2_rpc.__version__ import __version__ +from poe2_rpc.application.bus import AsyncioEventBus +from poe2_rpc.application.handlers import MutableState +from poe2_rpc.application.orchestrator import Orchestrator +from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.domain.ports import LogStream +from poe2_rpc.infrastructure.catalog import load_bundled_catalog +from poe2_rpc.infrastructure.detection import PsutilGameDetector +from poe2_rpc.infrastructure.log_stream import WatchdogLogStream +from poe2_rpc.infrastructure.logging import configure_logging +from poe2_rpc.infrastructure.parsing import RegexLogParser +from poe2_rpc.infrastructure.presence import PypresencePublisher +from poe2_rpc.infrastructure.settings import AppSettings + +_log = structlog.get_logger(__name__) + +app = typer.Typer( + name="poe2-rpc", + help="Discord Rich Presence integration for Path of Exile 2.", + no_args_is_help=False, +) + + +class _SyncLineIterator: + """Adapter: drains WatchdogLogStream's async queue into a sync Iterator[str]. + + Lives in the composition root because LogStream is a sync Protocol but the + Watchdog adapter speaks asyncio. Owns the start/stop of the observer. + """ + + def __init__(self, stream: WatchdogLogStream, loop: asyncio.AbstractEventLoop) -> None: + self._stream = stream + self._loop = loop + stream.start() + + def lines(self) -> Iterator[str]: + try: + while True: + line = self._loop.run_until_complete(self._stream._queue.get()) + yield line + finally: + self._stream.stop() + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"poe2-rpc {__version__}") + raise typer.Exit() + + +def build_orchestrator(settings: AppSettings) -> Orchestrator: + """Assemble all adapters and return a runnable Orchestrator. + + Extracted as a public function so CLI tests can patch it in isolation. + """ + detector = PsutilGameDetector(settings) + parser = RegexLogParser() + catalog = load_bundled_catalog() + publisher = PypresencePublisher(settings) + bus = AsyncioEventBus() + + def factory(path: Path, loop: asyncio.AbstractEventLoop) -> LogStream: + watchdog_stream = WatchdogLogStream(path, settings, loop) + return _SyncLineIterator(watchdog_stream, loop) + + return Orchestrator( + detector=detector, + parser=parser, + publisher=publisher, + catalog=catalog, + bus=bus, + log_stream_factory=factory, + throttle=PresenceThrottle(interval=settings.throttle_window_seconds), + current_state=MutableState(), + settings=settings, + ) + + +@app.callback() +def main( + version: bool = typer.Option( + None, + "--version", + callback=_version_callback, + is_eager=True, + help="Print version and exit.", + ), +) -> None: + """Discord Rich Presence integration for Path of Exile 2.""" + + +@app.command() +def run() -> None: + """Run the continuous monitor loop until cancelled.""" + settings = AppSettings() + configure_logging(settings) + orch = build_orchestrator(settings) + orch.run() + + +@app.command() +def once() -> None: + """Run a single log-stream pass and exit.""" + settings = AppSettings() + configure_logging(settings) + orch = build_orchestrator(settings) + orch.run_once() + + +@app.command(name="validate-config") +def validate_config( + no_discord: bool = typer.Option( + False, + "--no-discord", + help="Skip Discord IPC contact; validate config + bundled assets only.", + ), +) -> None: + """Validate config + bundled assets without running the monitor loop. + + With ``--no-discord``: loads settings, configures structlog, loads bundled + locations.json, prints settings JSON, and exits 0. Used as the deep-smoke + step in F-3 to prove the pydantic-settings + TOML + structlog + watchdog + import chain initializes end-to-end without contacting Discord. + """ + settings = AppSettings() + configure_logging(settings) + load_bundled_catalog() + + if not no_discord: + publisher = PypresencePublisher(settings) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(publisher.connect()) + finally: + publisher.close() + loop.close() + asyncio.set_event_loop(None) + + typer.echo(settings.model_dump_json(indent=2)) + raise typer.Exit(0) diff --git a/src/poe2_rpc/cli/__init__.py b/src/poe2_rpc/cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py index 12013db..409a861 100644 --- a/src/poe2_rpc/domain/ports.py +++ b/src/poe2_rpc/domain/ports.py @@ -31,6 +31,7 @@ def parse_instance(self, line: str) -> InstanceInfo | None: ... @runtime_checkable class PresencePublisher(Protocol): + async def connect(self) -> None: ... async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: ... def close(self) -> None: ... diff --git a/src/poe2_rpc/infrastructure/catalog.py b/src/poe2_rpc/infrastructure/catalog.py index 34d724c..42947a3 100644 --- a/src/poe2_rpc/infrastructure/catalog.py +++ b/src/poe2_rpc/infrastructure/catalog.py @@ -6,9 +6,25 @@ import httpx +from poe2_rpc.domain.locations import LocationCatalog from poe2_rpc.infrastructure.settings import AppSettings +def load_bundled_catalog() -> LocationCatalog: + """Load the bundled locations.json into the domain LocationCatalog. + + Used by the composition root and by `validate-config` to prove that the + bundled JSON is present and parsable without contacting Discord. + """ + text = ( + importlib.resources.files("poe2_rpc") + .joinpath("locations.json") + .read_text(encoding="utf-8") + ) + data = json.loads(text) + return LocationCatalog(areas=dict(data.get("areas", {}))) + + class BundledLocationCatalog: def __init__(self, settings: AppSettings) -> None: self._settings = settings diff --git a/src/poe2_rpc/infrastructure/detection.py b/src/poe2_rpc/infrastructure/detection.py index 3281361..c1c8d05 100644 --- a/src/poe2_rpc/infrastructure/detection.py +++ b/src/poe2_rpc/infrastructure/detection.py @@ -1,23 +1,30 @@ """Psutil-based game process detector.""" from __future__ import annotations +import time from collections.abc import Callable, Iterator from pathlib import Path from typing import Any import psutil +import structlog from poe2_rpc.infrastructure.settings import AppSettings +_log = structlog.get_logger(__name__) +_WAIT_INTERVAL_SECONDS = 5.0 + class PsutilGameDetector: def __init__( self, settings: AppSettings, process_iter_factory: Callable[[list[str]], Iterator[Any]] | None = None, + sleep: Callable[[float], None] | None = None, ) -> None: self._settings = settings self._process_iter = process_iter_factory or psutil.process_iter + self._sleep = sleep or time.sleep def find_log_path(self) -> Path | None: for proc in self._iter_processes_safely(): @@ -26,6 +33,18 @@ def find_log_path(self) -> Path | None: return proc return None + def is_running(self) -> bool: + return self.find_log_path() is not None + + def log_path(self) -> Path: + """Block until the game process is found; matches main.py:88-94 semantics.""" + while True: + path = self.find_log_path() + if path is not None: + return path + _log.info("waiting_for_game_start", process=self._settings.process_name) + self._sleep(_WAIT_INTERVAL_SECONDS) + def _iter_processes_safely(self) -> Iterator[Path | None]: for proc in self._process_iter(["name", "exe"]): try: diff --git a/src/poe2_rpc/infrastructure/parsing.py b/src/poe2_rpc/infrastructure/parsing.py index 3d00276..df64ee4 100644 --- a/src/poe2_rpc/infrastructure/parsing.py +++ b/src/poe2_rpc/infrastructure/parsing.py @@ -31,3 +31,15 @@ def parse_instance_event(line: str) -> InstanceInfo | None: area_display_name=m.group(2), seed=int(m.group(3)), ) + + +class RegexLogParser: + """LogParser port adapter wrapping the module-level parse_*_event functions.""" + + @staticmethod + def parse_level(line: str) -> LevelInfo | None: + return parse_level_event(line) + + @staticmethod + def parse_instance(line: str) -> InstanceInfo | None: + return parse_instance_event(line) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..c52cfb2 --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,61 @@ +"""Integration tests for the Typer CLI / composition root.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from poe2_rpc.__version__ import __version__ +from poe2_rpc.cli import app + +runner = CliRunner() + + +def test_cli_version_prints_version() -> None: + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.stdout + + +def test_cli_help_lists_all_commands() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "run" in result.stdout + assert "once" in result.stdout + assert "validate-config" in result.stdout + + +def test_cli_validate_config_exits_zero() -> None: + """validate-config with --no-discord should exit 0 and print resolved settings.""" + result = runner.invoke(app, ["validate-config", "--no-discord"]) + assert result.exit_code == 0, result.stdout + # Settings JSON should contain at least the discord_app_id key + assert "discord_app_id" in result.stdout + + +def test_cli_validate_config_no_discord_skips_ipc() -> None: + """With --no-discord, no PypresencePublisher (and thus no AioPresence) is instantiated.""" + with patch("poe2_rpc.cli.PypresencePublisher") as mock_publisher: + result = runner.invoke(app, ["validate-config", "--no-discord"]) + assert result.exit_code == 0, result.stdout + mock_publisher.assert_not_called() + + +def test_cli_once_runs_one_iteration() -> None: + """`once` calls Orchestrator.run_once() exactly once via build_orchestrator factory.""" + fake_orch = MagicMock() + with patch("poe2_rpc.cli.build_orchestrator", return_value=fake_orch): + result = runner.invoke(app, ["once"]) + assert result.exit_code == 0, result.stdout + fake_orch.run_once.assert_called_once() + fake_orch.run.assert_not_called() + + +def test_cli_run_invokes_run_loop() -> None: + """`run` calls Orchestrator.run() (the continuous loop).""" + fake_orch = MagicMock() + with patch("poe2_rpc.cli.build_orchestrator", return_value=fake_orch): + result = runner.invoke(app, ["run"]) + assert result.exit_code == 0, result.stdout + fake_orch.run.assert_called_once() + fake_orch.run_once.assert_not_called() diff --git a/tests/integration/test_main_module.py b/tests/integration/test_main_module.py new file mode 100644 index 0000000..2e7b9a8 --- /dev/null +++ b/tests/integration/test_main_module.py @@ -0,0 +1,30 @@ +"""Test that `python -m poe2_rpc` works as an alternate entry point.""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from poe2_rpc.__version__ import __version__ + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_DIR = PROJECT_ROOT / "src" + + +def test_module_runs_via_python_dash_m() -> None: + result = subprocess.run( + [sys.executable, "-m", "poe2_rpc", "--version"], + capture_output=True, + text=True, + env={"PYTHONPATH": str(SRC_DIR), **_env_passthrough()}, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert __version__ in result.stdout + + +def _env_passthrough() -> dict[str, str]: + import os + + keep = ("PATH", "HOME", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TERM") + return {k: os.environ[k] for k in keep if k in os.environ} diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py index 083ddc8..5f92f22 100644 --- a/tests/integration/test_orchestrator.py +++ b/tests/integration/test_orchestrator.py @@ -68,6 +68,10 @@ def parse_instance(self, line: str) -> InstanceInfo | None: class FakePresencePublisher: def __init__(self) -> None: self.published: list[tuple[LevelInfo | None, InstanceInfo | None]] = [] + self.connected: bool = False + + async def connect(self) -> None: + self.connected = True async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: self.published.append((level_info, instance_info)) diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 52f08f3..ff94247 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -37,6 +37,9 @@ def parse_instance(self, line: str) -> InstanceInfo | None: class _ConcretePresencePublisher: + async def connect(self) -> None: + pass + async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: pass From 76ba67e64bc31c7c1ac24b9b24aba5f58dad1a3a Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 13:41:33 +0300 Subject: [PATCH 05/17] Phase F: PyInstaller spec + bundled-asset test + cold-start benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase F of the DDD/hexagonal migration (epic panvex-enp). Wires the release artifact path: PyInstaller spec is the build entrypoint; the spec is shipped alongside the bundled `locations.json`, and two new integration tests guard the asset-resolution and cold-start budgets. F-1 — `PathOfExile2DiscordRPC.spec`: - `Analysis(['src/poe2_rpc/__main__.py'], pathex=['src'], ...)`, `datas=[('src/poe2_rpc/locations.json', 'poe2_rpc')]` so the bundled JSON resolves via `importlib.resources.files('poe2_rpc')` inside the .exe (matches `load_bundled_catalog()` from Phase E). - Expanded `hiddenimports` for the watchdog Windows backend and the pydantic-rust-core / pydantic-settings TOML provider / structlog / tenacity entrypoints — the seven import paths PyInstaller can't discover statically through pydantic v2's plugin chain. - `collect_submodules()` over pydantic, pydantic_settings, structlog, watchdog, tenacity, pypresence — the broad sweep complements the explicit hiddenimports list. - `name='PathOfExile2DiscordRPC'`, `onefile=True`, `console=True` — preserves the existing release-asset URL stability constraint. F-2 — `tests/integration/test_bundled_catalog.py`: - `test_bundled_locations_json_is_reachable` proves the JSON is discoverable through `importlib.resources` after `pip install -e .` (uses the existing `[tool.setuptools.package-data]` entry from Phase A's pyproject scaffold). - `test_load_bundled_catalog_resolves_known_area` exercises the Phase E `load_bundled_catalog()` helper end-to-end and asserts `G1_1 -> "The Riverbank"` (anchors the canonical zone set so a silently corrupted bundle is caught at test time, not in CI smoke). F-3 — already wired in `.github/workflows/build.yml` lines 99-100 (`dist\PathOfExile2DiscordRPC.exe validate-config --no-discord`). The spec from F-1 unblocks this step; the workflow side requires no edits. Final acceptance — green CI run on Windows — stays open in bd until the next push triggers `workflow_dispatch` evidence. F-4 — `tests/integration/test_cold_start.py`: - Resolves the binary via `POE2_RPC_EXE` env (CI sets it to `dist\PathOfExile2DiscordRPC.exe`); skipped in local dev when the exe is absent so `pytest` stays clean without a build. - Runs `validate-config --no-discord` 5 times, asserts each exits 0 and computes p95 against the 8s budget. Budget breach fails the test; per the plan a breach files a follow-up bd issue and annotates release notes — does NOT block the first DDD release. - The CI step already exists at `build.yml:102-104` with `continue-on-error: true` matching the non-blocking policy. Tests (2 new + 1 skipped, 96/96 + 1 skip total): - pytest: 96 passed, 1 skipped (cold-start needs the exe) - mypy --strict src/poe2_rpc: success, 24 files - lint-imports: contract unchanged (no new domain/application/ infrastructure code in this commit) bd: panvex-{98r,6uc} closed. panvex-{73a,9bu} stay open pending the first CI workflow_dispatch run that produces evidence: - panvex-73a (F-3): smoke step exits 0 on Windows runner - panvex-9bu (F-4): p95 cold-start <= 8s recorded in CI logs Phase G (regex sample test, README/CLAUDE.md updates, delete main.py blocked-by live smoke, 10-step Windows VM checklist) is next, but G-2 (delete main.py) cannot close until G-4 records the live smoke and panvex-00o (catalog.resolve wiring in orchestrator) ships — both are pre-cutover blockers tracked in bd. Co-Authored-By: Claude Opus 4.7 --- PathOfExile2DiscordRPC.spec | 71 +++++++++++++++++++++++ tests/integration/test_bundled_catalog.py | 26 +++++++++ tests/integration/test_cold_start.py | 59 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 PathOfExile2DiscordRPC.spec create mode 100644 tests/integration/test_bundled_catalog.py create mode 100644 tests/integration/test_cold_start.py diff --git a/PathOfExile2DiscordRPC.spec b/PathOfExile2DiscordRPC.spec new file mode 100644 index 0000000..abca155 --- /dev/null +++ b/PathOfExile2DiscordRPC.spec @@ -0,0 +1,71 @@ +# PyInstaller spec file for poe2-rpc. +# +# Build: +# pyinstaller PathOfExile2DiscordRPC.spec +# +# Output: dist/PathOfExile2DiscordRPC.exe (Windows --onefile binary). +# Validated downstream by F-3 (CI smoke: `validate-config --no-discord` exit 0) +# and F-4 (cold-start benchmark: p95 <= 8s). + +from PyInstaller.utils.hooks import collect_submodules + +block_cipher = None + +hiddenimports = [ + "watchdog.observers.read_directory_changes", + "watchdog.observers.winapi", + "pydantic_core._pydantic_core", + "pydantic._internal._model_construction", + "pydantic_settings.sources.providers.toml", + "structlog._log_levels", + "tenacity", +] +for pkg in ( + "pydantic", + "pydantic_settings", + "structlog", + "watchdog", + "tenacity", + "pypresence", +): + hiddenimports += collect_submodules(pkg) + +a = Analysis( + ["src/poe2_rpc/__main__.py"], + pathex=["src"], + binaries=[], + datas=[("src/poe2_rpc/locations.json", "poe2_rpc")], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="PathOfExile2DiscordRPC", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/tests/integration/test_bundled_catalog.py b/tests/integration/test_bundled_catalog.py new file mode 100644 index 0000000..ed31eaf --- /dev/null +++ b/tests/integration/test_bundled_catalog.py @@ -0,0 +1,26 @@ +"""F-2: locations.json must be reachable via importlib.resources in a dev install.""" + +from __future__ import annotations + +import importlib.resources +import json + +from poe2_rpc.infrastructure.catalog import load_bundled_catalog + + +def test_bundled_locations_json_is_reachable() -> None: + """The bundled `locations.json` is accessible through importlib.resources.""" + resource = importlib.resources.files("poe2_rpc").joinpath("locations.json") + text = resource.read_text(encoding="utf-8") + data = json.loads(text) + assert "areas" in data + assert isinstance(data["areas"], dict) + assert len(data["areas"]) > 0 + + +def test_load_bundled_catalog_resolves_known_area() -> None: + """`load_bundled_catalog()` produces a populated catalog that resolves canonical areas.""" + catalog = load_bundled_catalog() + location = catalog.resolve("G1_1") + assert location.area_code == "G1_1" + assert location.display_name == "The Riverbank" diff --git a/tests/integration/test_cold_start.py b/tests/integration/test_cold_start.py new file mode 100644 index 0000000..5c60fbe --- /dev/null +++ b/tests/integration/test_cold_start.py @@ -0,0 +1,59 @@ +"""F-4: cold-start benchmark for the PyInstaller binary. + +Runs `validate-config --no-discord` 5 times and asserts each invocation +completes within the 8s budget. Skipped when the binary is not built (local +dev) — CI sets POE2_RPC_EXE to `dist/PathOfExile2DiscordRPC.exe`. +""" + +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + +import pytest + +_BUDGET_SECONDS = 8.0 +_RUNS = 5 + + +def _resolve_exe_path() -> Path | None: + env = os.environ.get("POE2_RPC_EXE") + if env: + return Path(env) + default = Path("dist") / "PathOfExile2DiscordRPC.exe" + return default if default.exists() else None + + +@pytest.fixture(scope="module") +def exe_path() -> Path: + path = _resolve_exe_path() + if path is None or not path.exists(): + pytest.skip("PathOfExile2DiscordRPC.exe not built — set POE2_RPC_EXE or run pyinstaller") + return path + + +def test_cold_start_p95_under_budget(exe_path: Path) -> None: + """Each of the 5 cold-start runs must finish under the 8s p95 budget.""" + timings: list[float] = [] + for _ in range(_RUNS): + started = time.perf_counter() + result = subprocess.run( + [str(exe_path), "validate-config", "--no-discord"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + elapsed = time.perf_counter() - started + timings.append(elapsed) + assert result.returncode == 0, ( + f"validate-config exited {result.returncode}: {result.stderr}" + ) + sorted_timings = sorted(timings) + p95 = sorted_timings[int(0.95 * len(sorted_timings)) - 1] + assert p95 <= _BUDGET_SECONDS, ( + f"cold-start p95 {p95:.2f}s exceeds budget {_BUDGET_SECONDS}s; " + f"timings={[f'{t:.2f}' for t in timings]}" + ) From b4fa4f010cdbdffc36f6caa04b854bb7ce46a24a Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 13:52:43 +0300 Subject: [PATCH 06/17] =?UTF-8?q?Phase=20G:=20cutover=20prep=20=E2=80=94?= =?UTF-8?q?=20catalog=20wiring=20+=20regex=20sample=20test=20+=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panvex-00o: Orchestrator now calls LocationCatalog.resolve() between parser.parse_instance() and bus.emit(AreaEntered(...)), copying the resolved display_name onto the InstanceInfo via model_copy(). Closes the pre-cutover blocker — raw internal codes (G1_4_BrambleghastSlain) are no longer pushed to Discord; the bundled locations.json is the single source of truth as in main.py:determine_location(). panvex-enn (G-1): tests/integration/test_regex_real_sample.py asserts both regex_level and regex_instance match >=1 entry in a real Client.txt sample. Skips with reason when the fixture is absent — fixture is captured manually during the G-4 Windows live smoke and locks in regex compatibility once present. panvex-8kz (G-3): README.md and CLAUDE.md rewritten for the new src/poe2_rpc/ package. README points at `pip install -e ".[dev]"` + `poe2-rpc run` and lists CLI subcommands (run/once/validate-config). CLAUDE.md now describes hexagonal layering, gate suite (pytest, mypy --strict, ruff, lint-imports), the .spec build, and updated ascendancy/zone/regex onboarding pointers. BEADS INTEGRATION block preserved verbatim. Phase G remaining: - panvex-a17 (G-2 delete main.py) — blocked by G-4 - panvex-12l (G-4 Windows live smoke) — manual VM checklist Gates: 97 pytest passed + 3 skipped (cold-start without exe, test_regex_real_sample without fixture); mypy --strict clean across 24 source files; ruff check + ruff format clean on touched files. --- CLAUDE.md | 87 ++++++++++++++------- README.md | 14 +++- src/poe2_rpc/application/orchestrator.py | 9 ++- tests/integration/test_orchestrator.py | 45 ++++++++++- tests/integration/test_regex_real_sample.py | 34 ++++++++ 5 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 tests/integration/test_regex_real_sample.py diff --git a/CLAUDE.md b/CLAUDE.md index 25b84ce..0841e8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,69 +4,100 @@ Project-level guidance for Claude Code working in this repository. Pair with the ## Project at a glance - **What it is:** A Discord Rich Presence integration for Path of Exile 2. Tails the game's `Client.txt`, parses level-up and area-generation events, and pushes presence updates via `pypresence`. -- **Shape:** Single-script Python app. The runtime entrypoint is `main.py`; everything else (`locations.json`, `requirements.txt`, GitHub config) supports it. -- **Distribution:** End users grab a prebuilt Windows `.exe` from GitHub Releases. The `.exe` is produced by `.github/workflows/build.yml` via PyInstaller `--onefile` whenever `main.py` changes on `main`. +- **Shape:** Hexagonal Python package at `src/poe2_rpc/` with `domain/`, `application/`, `infrastructure/`, and `cli.py` (composition root). The Typer app in `cli.py` is the runtime entrypoint; `pyproject.toml` exposes it as the `poe2-rpc` console script and `python -m poe2_rpc`. +- **Distribution:** End users grab a prebuilt Windows `.exe` from GitHub Releases. The `.exe` is produced by `.github/workflows/build.yml` via PyInstaller `--onefile` against `PathOfExile2DiscordRPC.spec`. CI re-runs whenever `src/**`, `pyproject.toml`, `locations.json`, the spec file, or the workflow itself changes. ## Build & Test ```bash -pip install -r requirements.txt -python main.py +pip install -e ".[dev]" +poe2-rpc run # continuous monitor loop +poe2-rpc once # single log-stream pass +poe2-rpc validate-config --no-discord # smoke check (no Discord IPC) ``` -Discord must be running. The script polls `psutil` for `PathOfExileSteam.exe` and only proceeds once the game is running — it will block on `Waiting for the game start..` otherwise. +Discord must be running for `run` / `once`. `infrastructure/detection.py::PsutilGameDetector` blocks until `PathOfExileSteam.exe` is detected (`log_path()` polls every 5s). -There is no automated test suite. Verification is manual: run the game, run the script, watch Discord for the expected presence, then kill/relaunch Discord to exercise `rpc_connect`'s 5-retry exponential-backoff loop. +The full gate suite (mirrors CI): + +```bash +pytest tests -ra +mypy --strict src/poe2_rpc +lint-imports # enforces hexagonal layering +ruff check src tests +ruff format --check src tests +``` + +Optional config: `%APPDATA%\poe2-rpc\config.toml` on Windows, `~/.config/poe2-rpc/config.toml` on macOS/Linux for cross-platform dev. Defaults work without one — see `infrastructure/settings.py::AppSettings` for the schema. ## Architecture Overview -`main.py` is the whole app. Top to bottom: +Hexagonal layering is enforced by `import-linter` (see `[tool.importlinter]` in `pyproject.toml`): + +``` +poe2_rpc.cli ← composition root; only layer that imports infrastructure +poe2_rpc.application ← orchestration, event bus, throttle, handlers — Protocols only +poe2_rpc.infrastructure ← psutil/watchdog/pypresence/pydantic-settings/structlog adapters +poe2_rpc.domain ← pure value objects, events, ports — no I/O, no third-party +``` -1. **Enums** (`CharacterClass`, `ClassAscendency`) — mappings between in-game class strings and ascendancies. The enum value is what appears in the log. -2. **`find_game_log()`** — `psutil.process_iter` loop hunting for `PathOfExileSteam.exe`; returns `/logs/Client.txt`. -3. **`load_locations()`** — reads `locations.json` from disk if present, otherwise downloads it from this repo's `main` branch and caches it. -4. **`determine_location()`** — turns an internal area code (e.g. `G1_4_BrambleghastSlain`) into a display name; map areas (`Map*`) get prefix-stripped and underscore-split before lookup. -5. **Parsers** — `find_last_level_up()` and `find_instance()` apply two precompiled regexes to log lines. -6. **`rpc_connect()`** — 5-attempt connect loop with `time.sleep(2 ** retries)` backoff against the Discord IPC socket (app ID `1315800372207419504`). -7. **`update_rpc()`** — formats presence details and sets `small_image = ascension_class.lower().replace(" ", "_")`. -8. **`monitor_log()`** — opens the log, seeks to EOF, then loops `readlines()` + `time.sleep(5)`, dispatching to `update_rpc()` whenever the parsed level or zone changes. +Key modules: + +- `domain/models.py` — frozen pydantic VOs (`LevelInfo`, `InstanceInfo`). +- `domain/events.py` — `CharacterLevelChanged`, `AreaEntered`, etc. +- `domain/ports.py` — runtime-checkable Protocols (`GameDetector`, `LogStream`, `LogParser`, `PresencePublisher`, `EventBus`, `LocationCatalogPort`, `Settings`). +- `domain/locations.py` — `LocationCatalog.resolve(area_code)` returns a `Location` VO. +- `domain/classes.py` — `CharacterClass` / `ClassAscendency` enums (matches in-game strings verbatim). +- `application/orchestrator.py` — composes bus + throttle + handlers; calls `catalog.resolve()` between parse and emit so handlers see resolved display names. +- `application/handlers.py` — `on_level_changed` / `on_area_entered`; structlog `bind_contextvars` carries `username` / `character_class` / `area`. +- `application/throttle.py` — `PresenceThrottle` (Discord IPC rate-limit guard). +- `infrastructure/parsing.py` — `regex_level` / `regex_instance` (verbatim from `main.py:273-274`) + `RegexLogParser` adapter. +- `infrastructure/detection.py` — `PsutilGameDetector` (`is_running()` / blocking `log_path()`). +- `infrastructure/log_stream.py` — `WatchdogLogStream` (event-driven via `ReadDirectoryChangesW`; thread-safe enqueue via `loop.call_soon_threadsafe`). +- `infrastructure/presence.py` — `PypresencePublisher` (`AioPresence` + tenacity split-retry: connect 5×wait_exponential(2,32), publish 3×wait_exponential(1,8)). +- `infrastructure/catalog.py` — `load_bundled_catalog()` reads bundled `locations.json` via `importlib.resources`. +- `infrastructure/settings.py` — `AppSettings` (pydantic-settings BaseSettings). +- `infrastructure/logging.py` — structlog config (ConsoleRenderer dev / JSONRenderer prod). +- `cli.py` — Typer app (`run`, `once`, `validate-config`, `--version`); `build_orchestrator(settings)` factory + `_SyncLineIterator` adapter bridging async `WatchdogLogStream` to the sync `LogStream` Protocol. ## Conventions & Patterns -- **Keep it one file.** CI's path-filter (`paths: ['main.py']`) and the PyInstaller call assume a single entrypoint. Splitting modules requires updating both in the same change. -- **Type hints + `logging`.** 4-space indent, type hints on signatures, `logging.info/error/warning` instead of `print`. -- **`pathlib.Path` + explicit `encoding="utf-8"`** for all file I/O. -- **Optional return shape:** parsers return `Optional[Dict[str, str]]`; the caller does the `if level_info:` check. -- **Exponential backoff** for retry loops (`time.sleep(2 ** retries)`), matching `rpc_connect`. +- **Hexagonal layering is non-negotiable.** Don't import infrastructure from application or domain — `lint-imports` will fail. The composition root in `cli.py` is the *only* place where adapters and application code meet. +- **Frozen pydantic v2 VOs everywhere in `domain/`.** No mutable state, no `dataclass`. `tests/unit/test_no_mutable_state.py` AST guard enforces this. +- **`mypy --strict`** is mandatory. 4-space indent, type hints on every signature. +- **`structlog` not `logging`.** Use `bind_contextvars(username=, character_class=, area=)` so events carry context through the call graph (AC#7). +- **`pathlib.Path` + explicit `encoding="utf-8"`** for file I/O. Bundled JSON via `importlib.resources.files("poe2_rpc")` — never `Path("locations.json")` cwd-relative. +- **Tenacity for retries**, not hand-rolled `time.sleep(2 ** retries)`. Split policies for connect vs publish (see `infrastructure/presence.py`). ## Adding a new ascendancy -1. Add the enum member to `ClassAscendency` — value must match the in-game string verbatim (e.g. `"Smith of Kitava"`). +1. Add the enum member to `ClassAscendency` in `src/poe2_rpc/domain/classes.py` — value must match the in-game string verbatim (e.g. `"Smith of Kitava"`). 2. Add the mapping in `ClassAscendency.get_class()`. 3. Append it to the right list in `CharacterClass.get_ascendencies()`. -4. Upload the matching Discord asset using the **lowercase + underscore** key, since `update_rpc` derives `small_image` as `ascension_class.lower().replace(" ", "_")` (commit `5ae14e6` enforced this). +4. Upload the matching Discord asset using the **lowercase + underscore** key, since the formatter derives `small_image` as `ascension_class.lower().replace(" ", "_")` (commit `5ae14e6` enforced this). Reference commit: `fe9c494` ("Add new character classes: Smith of Kitava, Lich, and Tactician"). ## Adding/updating zones -- Edit `locations.json` (the in-repo copy is the source of truth) and ship it in the same commit. +- Edit `src/poe2_rpc/locations.json` (bundled into the package via `[tool.setuptools.package-data]`); the root-level `locations.json` is kept in sync as the human-edit source of truth. - Schema: `{"areas": {"": ""}}`. Internal codes look like `G1_1`, `G1_4_Brambleghast`, etc. -- `determine_location()` strips a leading `Map` prefix and splits on `_` for map-tier areas, so map-name lookups bypass `locations.json`. -- Existing installs auto-fetch `locations.json` from GitHub `main` only when the local file is **missing**. The upgrade path for cached installs is a new `.exe` release. +- `LocationCatalog.resolve()` strips a leading `Map` prefix and splits on `_` for map-tier areas, so map-name lookups don't require a dictionary entry. +- The `.exe` reads the bundled JSON via `importlib.resources` — no runtime URL fetch. To override locally for testing, set `AppSettings.locations_url` (env var `POE2_RPC_LOCATIONS_URL`). ## Regex contracts -Don't break these without checking a real `Client.txt` sample: +Don't break these without checking a real `Client.txt` sample (G-1 enforces this via `tests/integration/test_regex_real_sample.py` once the fixture is captured): - `regex_level`: `r": (\w+) \(([\w\s]+)\) is now level (\d+)"` → `(username, base_or_ascendancy_class, level)`. - `regex_instance`: `r'Generating level (\d+) area "([^"]+)" with seed (\d+)'` → `(level, area_code, seed)`. -Both target the Steam-build log format. +Both target the Steam-build log format. Defined in `src/poe2_rpc/infrastructure/parsing.py`. ## CI / Release flow -- Push to `main` touching `main.py` → `.github/workflows/build.yml` runs on `windows-latest`. +- Push to `main` touching `src/**`, `pyproject.toml`, `locations.json`, `PathOfExile2DiscordRPC.spec`, or the workflow → `.github/workflows/build.yml` runs lint+test on `ubuntu-latest`, then build on `windows-latest`. +- The build job runs `pyinstaller PathOfExile2DiscordRPC.spec`, then deep-smoke `dist\PathOfExile2DiscordRPC.exe validate-config --no-discord`, then the cold-start benchmark (continue-on-error: budget breach files a follow-up bd issue, doesn't block release). - A timestamp tag (`vYYYYMMDD-HHMMSS`) is created and pushed; the release job uploads `PathOfExile2DiscordRPC.exe` as a GitHub Release asset. ## Open work (from README) diff --git a/README.md b/README.md index 7a529ea..af54fa7 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,19 @@ ## ⚙️ Install guide: -1. Install Python 3.8 or higher. -2. Install the required libraries using pip: `pip install -r requirements.txt` -3. Run the script: `python main.py` +1. Install Python 3.11 or higher. +2. Install the package and dev dependencies: `pip install -e ".[dev]"` +3. Run the app: `poe2-rpc run` (or `python -m poe2_rpc run`). 4. Ensure that Discord is running. +Optional config: drop a `config.toml` at `%APPDATA%\poe2-rpc\config.toml` on +Windows (or `~/.config/poe2-rpc/config.toml` on macOS/Linux for dev). Defaults +work without one. + +CLI commands: `poe2-rpc run` (continuous monitor), `poe2-rpc once` (single +log-stream pass), `poe2-rpc validate-config --no-discord` (validate settings ++ bundled assets without contacting Discord IPC). + **For convenience, a pre-compiled .exe is available in the releases section. Download the latest release here:** 👉 https://github.com/ezbooz/Path-Of-Exile-2-RPC/releases diff --git a/src/poe2_rpc/application/orchestrator.py b/src/poe2_rpc/application/orchestrator.py index 98a1d35..3651605 100644 --- a/src/poe2_rpc/application/orchestrator.py +++ b/src/poe2_rpc/application/orchestrator.py @@ -3,12 +3,13 @@ Principle 4: zero poe2_rpc.infrastructure.* imports. The log stream is created via an injected factory so the composition root (cli.py) owns the concrete type. """ + from __future__ import annotations import asyncio import functools +from collections.abc import Callable from pathlib import Path -from typing import Callable import structlog @@ -90,7 +91,11 @@ def run_once(self) -> None: continue instance_info = self._parser.parse_instance(line) if instance_info is not None: - self._bus.emit(AreaEntered(instance_info=instance_info)) + location = self._catalog.resolve(instance_info.area_code) + resolved = instance_info.model_copy( + update={"area_display_name": location.display_name} + ) + self._bus.emit(AreaEntered(instance_info=resolved)) except (asyncio.CancelledError, KeyboardInterrupt): _log.info("orchestrator_shutdown") finally: diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py index 5f92f22..605b1df 100644 --- a/tests/integration/test_orchestrator.py +++ b/tests/integration/test_orchestrator.py @@ -1,9 +1,10 @@ """Integration test: Orchestrator full-flow with fake factory.""" + from __future__ import annotations import asyncio +from collections.abc import Iterator from pathlib import Path -from typing import Iterator from poe2_rpc.application.bus import AsyncioEventBus from poe2_rpc.application.handlers import MutableState @@ -13,9 +14,9 @@ from poe2_rpc.domain.models import InstanceInfo, LevelInfo from poe2_rpc.domain.ports import LogStream - # --- Fakes --- + class FakeSettings: throttle_window_seconds: float = 0.0 connect_retry_attempts: int = 1 @@ -73,7 +74,11 @@ def __init__(self) -> None: async def connect(self) -> None: self.connected = True - async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: + async def publish( + self, + level_info: LevelInfo | None, + instance_info: InstanceInfo | None, + ) -> None: self.published.append((level_info, instance_info)) def close(self) -> None: @@ -115,6 +120,7 @@ def factory(path: Path, loop: asyncio.AbstractEventLoop) -> LogStream: # --- Tests --- + def test_orchestrator_emits_level_event_and_publishes() -> None: """Orchestrator wires bus/handlers: a LEVEL log line triggers presence publish.""" orch, publisher = _make_orchestrator(stream_lines=["LEVEL:Hero,Warrior,10"]) @@ -138,6 +144,39 @@ def test_orchestrator_emits_area_event_and_publishes() -> None: assert instance_info.area_code == "G1_1" +def test_orchestrator_resolves_area_display_name_via_catalog() -> None: + """panvex-00o: orchestrator must call catalog.resolve() so handlers see resolved names.""" + + class ResolvingCatalog: + def resolve(self, area_code: str) -> Location: + return Location(area_code=area_code, display_name="The Riverbank") + + pub = FakePresencePublisher() + log_path = Path("/fake/Client.txt") + + def factory(path: Path, loop: asyncio.AbstractEventLoop) -> LogStream: + return FakeLogStream(["AREA:G1_1,raw_internal_code,5"]) + + orch = Orchestrator( + detector=FakeGameDetector(log_path), + parser=FakeLogParser(), + publisher=pub, + catalog=ResolvingCatalog(), + bus=AsyncioEventBus(), + log_stream_factory=factory, + throttle=PresenceThrottle(interval=0.0), + current_state=MutableState(), + settings=FakeSettings(), + ) + orch.run_once() + + assert len(pub.published) == 1 + _, instance_info = pub.published[0] + assert instance_info is not None + assert instance_info.area_code == "G1_1" + assert instance_info.area_display_name == "The Riverbank" + + def test_orchestrator_factory_called_with_log_path() -> None: """Factory receives the path from GameDetector.log_path().""" received_paths: list[Path] = [] diff --git a/tests/integration/test_regex_real_sample.py b/tests/integration/test_regex_real_sample.py new file mode 100644 index 0000000..84ff41a --- /dev/null +++ b/tests/integration/test_regex_real_sample.py @@ -0,0 +1,34 @@ +"""G-1: validate regex contracts against a real Client.txt sample. + +The fixture `tests/fixtures/sample_client.txt` is captured manually during +the G-4 Windows live smoke. Without it this test skips with reason — once +captured, it locks in regex compatibility with real-world log lines. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from poe2_rpc.infrastructure.parsing import regex_instance, regex_level + +_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_client.txt" + + +def _load_fixture() -> str: + if not _FIXTURE.exists(): + pytest.skip(f"fixture absent: {_FIXTURE} (capture during G-4 live smoke)") + return _FIXTURE.read_text(encoding="utf-8") + + +def test_regex_level_matches_real_log_lines() -> None: + text = _load_fixture() + matches = regex_level.findall(text) + assert len(matches) >= 1, "expected >=1 level-up event in real Client.txt sample" + + +def test_regex_instance_matches_real_log_lines() -> None: + text = _load_fixture() + matches = regex_instance.findall(text) + assert len(matches) >= 1, "expected >=1 area-generation event in real Client.txt sample" From ee1ce105a4025727280637127c1daf81093c38a8 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 13:56:00 +0300 Subject: [PATCH 07/17] docs(readme): add Russian and Ukrainian translations + language switcher --- README.md | 2 +- README.ru.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.ua.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 README.ru.md create mode 100644 README.ua.md diff --git a/README.md b/README.md index af54fa7..ebdc0a3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ 2 - +**Languages:** English · [Русский](README.ru.md) · [Українська](README.ua.md) ## ⚙️ Install guide: 1. Install Python 3.11 or higher. diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..2c1e4ba --- /dev/null +++ b/README.ru.md @@ -0,0 +1,53 @@ +2 + +**Языки:** [English](README.md) · Русский · [Українська](README.ua.md) + +## ⚙️ Установка: +1. Установите Python 3.11 или новее. +2. Установите пакет вместе с dev-зависимостями: `pip install -e ".[dev]"` +3. Запустите приложение: `poe2-rpc run` (либо `python -m poe2_rpc run`). +4. Убедитесь, что Discord запущен. + +Опциональный конфиг: положите файл `config.toml` по пути +`%APPDATA%\poe2-rpc\config.toml` на Windows (или +`~/.config/poe2-rpc/config.toml` на macOS/Linux для разработки). Без +конфига приложение работает на значениях по умолчанию. + +CLI-команды: `poe2-rpc run` (непрерывный мониторинг), +`poe2-rpc once` (один проход по логу), +`poe2-rpc validate-config --no-discord` (проверить настройки и +встроенные ассеты без подключения к Discord IPC). + +**Для удобства собран `.exe` под Windows — он лежит в разделе Releases. +Скачайте последнюю версию здесь:** +👉 https://github.com/ezbooz/Path-Of-Exile-2-RPC/releases + +## ✅ Возможности + +- Discord Rich Presence для **Path of Exile 2** +- Автоматически распознаёт класс персонажа, аскенденцию, зону и уровень +- Показывает иконку для каждого класса + +--- + +## 🔧 To-Do + +- [x] Поддержка пользовательских изображений (все классы и аскенденции) +- [ ] Запуск как фоновая служба при старте игры +- [ ] Поддержка официального клиента PoE2 +- [ ] Определять игрока, который запустил скрипт (избежать конфликтов в пати) +- [ ] Показ AFK-статуса + +--- + + +## 🙏 Благодарности + +- 💾 [adainrivers](https://github.com/adainrivers/poe2-data) — данные карт и ресурсы +- 💻 [Miksuu](https://github.com/Miksuu) — вклад в код + +--- + +## 📎 Лицензия + +Проект распространяется по лицензии [MIT](LICENSE). diff --git a/README.ua.md b/README.ua.md new file mode 100644 index 0000000..ab33db3 --- /dev/null +++ b/README.ua.md @@ -0,0 +1,53 @@ +2 + +**Мови:** [English](README.md) · [Русский](README.ru.md) · Українська + +## ⚙️ Встановлення: +1. Встановіть Python 3.11 або новіший. +2. Встановіть пакет разом із dev-залежностями: `pip install -e ".[dev]"` +3. Запустіть застосунок: `poe2-rpc run` (або `python -m poe2_rpc run`). +4. Переконайтеся, що Discord запущено. + +Опціональний конфіг: покладіть файл `config.toml` за шляхом +`%APPDATA%\poe2-rpc\config.toml` на Windows (або +`~/.config/poe2-rpc/config.toml` на macOS/Linux для розробки). Без +конфіга застосунок працює зі значеннями за замовчуванням. + +CLI-команди: `poe2-rpc run` (безперервний моніторинг), +`poe2-rpc once` (один прохід по лог-файлу), +`poe2-rpc validate-config --no-discord` (перевірити налаштування та +вбудовані ассети без підключення до Discord IPC). + +**Для зручності зібрано `.exe` під Windows — він лежить у розділі Releases. +Завантажте останню версію тут:** +👉 https://github.com/ezbooz/Path-Of-Exile-2-RPC/releases + +## ✅ Можливості + +- Discord Rich Presence для **Path of Exile 2** +- Автоматично розпізнає клас персонажа, асцендансі, зону та рівень +- Показує іконку для кожного класу + +--- + +## 🔧 To-Do + +- [x] Підтримка користувацьких зображень (усі класи та асцендансі) +- [ ] Запуск як фонова служба під час старту гри +- [ ] Підтримка офіційного клієнта PoE2 +- [ ] Визначення гравця, що запустив скрипт (уникати конфліктів у паті) +- [ ] Показ AFK-статусу + +--- + + +## 🙏 Подяки + +- 💾 [adainrivers](https://github.com/adainrivers/poe2-data) — дані мап та ресурси +- 💻 [Miksuu](https://github.com/Miksuu) — внесок у код + +--- + +## 📎 Ліцензія + +Проєкт поширюється за ліцензією [MIT](LICENSE). From 6c8d881a3cf808f2e1744f8c3eb01bb566754e04 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 14:13:11 +0300 Subject: [PATCH 08/17] chore: fix all ruff lint errors + bump deprecated GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace lazy imports with module-level imports across tests, rename LogStreamStalled→LogStreamStalledError per N818, hoist tomllib import in settings, dedup unused-arg via `_event` and `del` (no rule ignores). Bump actions/checkout v4→v5 and actions/setup-python v5→v6 (Node 20 deprecation), set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 for paths-filter@v3, and replace archived create-release@v1 + upload-release-asset@v1 with softprops/action-gh-release@v2. Run ruff format on the whole tree to bring previously untouched files in line with the autoformatter. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 31 +++------ src/poe2_rpc/__main__.py | 1 + src/poe2_rpc/application/bus.py | 3 +- src/poe2_rpc/application/handlers.py | 1 + src/poe2_rpc/application/throttle.py | 3 +- src/poe2_rpc/cli.py | 3 +- src/poe2_rpc/domain/classes.py | 3 +- src/poe2_rpc/domain/events.py | 1 + src/poe2_rpc/domain/exceptions.py | 3 +- src/poe2_rpc/domain/locations.py | 5 +- src/poe2_rpc/domain/models.py | 1 + src/poe2_rpc/domain/ports.py | 16 ++++- src/poe2_rpc/infrastructure/catalog.py | 5 +- src/poe2_rpc/infrastructure/detection.py | 1 + src/poe2_rpc/infrastructure/log_stream.py | 18 ++--- src/poe2_rpc/infrastructure/logging.py | 1 + src/poe2_rpc/infrastructure/parsing.py | 1 + src/poe2_rpc/infrastructure/presence.py | 9 ++- src/poe2_rpc/infrastructure/settings.py | 16 +++-- tests/conftest.py | 3 +- tests/integration/test_cli.py | 1 + tests/integration/test_main_module.py | 5 +- tests/unit/test_bus.py | 8 +-- tests/unit/test_catalog.py | 4 +- tests/unit/test_classes.py | 32 ++++++--- tests/unit/test_detection.py | 8 +-- tests/unit/test_events.py | 9 ++- tests/unit/test_exceptions.py | 11 ++- tests/unit/test_handlers.py | 16 +---- tests/unit/test_layering.py | 5 +- tests/unit/test_locations.py | 3 +- tests/unit/test_log_stream.py | 84 +++++++---------------- tests/unit/test_logging.py | 10 ++- tests/unit/test_models.py | 1 + tests/unit/test_no_mutable_state.py | 10 +-- tests/unit/test_orchestrator_layering.py | 22 ++---- tests/unit/test_parsing.py | 3 +- tests/unit/test_ports.py | 9 ++- tests/unit/test_presence_connect.py | 20 ++---- tests/unit/test_presence_publish.py | 12 +--- tests/unit/test_settings.py | 20 ++---- tests/unit/test_smoke.py | 7 +- tests/unit/test_throttle.py | 5 +- 43 files changed, 198 insertions(+), 232 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 805718c..47eb1d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,8 +22,10 @@ jobs: outputs: build_relevant: ${{ steps.filter.outputs.build_relevant }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dorny/paths-filter@v3 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true id: filter with: filters: | @@ -39,10 +41,10 @@ jobs: needs: changes steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' cache: 'pip' @@ -78,12 +80,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' cache: 'pip' @@ -140,25 +142,14 @@ jobs: name: executable path: dist - - name: Create Release - id: create_release - uses: actions/create-release@v1 + - name: Create Release and upload asset + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ needs.build.outputs.tag_name }} - release_name: Release ${{ needs.build.outputs.tag_name }} + name: Release ${{ needs.build.outputs.tag_name }} body: ${{ needs.build.outputs.release_body }} draft: false prerelease: false - - - name: Upload Release Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: dist/PathOfExile2DiscordRPC.exe - asset_name: PathOfExile2DiscordRPC.exe - asset_content_type: application/octet-stream + files: dist/PathOfExile2DiscordRPC.exe diff --git a/src/poe2_rpc/__main__.py b/src/poe2_rpc/__main__.py index 0289886..87b4f0c 100644 --- a/src/poe2_rpc/__main__.py +++ b/src/poe2_rpc/__main__.py @@ -1,4 +1,5 @@ """Module entry point: ``python -m poe2_rpc`` runs the Typer CLI.""" + from poe2_rpc.cli import app if __name__ == "__main__": diff --git a/src/poe2_rpc/application/bus.py b/src/poe2_rpc/application/bus.py index 403f182..25223c8 100644 --- a/src/poe2_rpc/application/bus.py +++ b/src/poe2_rpc/application/bus.py @@ -1,9 +1,10 @@ """Application-layer event bus — pure asyncio, no infrastructure imports.""" + from __future__ import annotations import asyncio from collections import defaultdict -from typing import Awaitable, Callable +from collections.abc import Awaitable, Callable import structlog diff --git a/src/poe2_rpc/application/handlers.py b/src/poe2_rpc/application/handlers.py index 45dc1bc..af46ee5 100644 --- a/src/poe2_rpc/application/handlers.py +++ b/src/poe2_rpc/application/handlers.py @@ -1,4 +1,5 @@ """Application-layer event handlers — pure application code, no infrastructure imports.""" + from __future__ import annotations import structlog diff --git a/src/poe2_rpc/application/throttle.py b/src/poe2_rpc/application/throttle.py index 7e76b3a..2a22a5d 100644 --- a/src/poe2_rpc/application/throttle.py +++ b/src/poe2_rpc/application/throttle.py @@ -1,9 +1,10 @@ """Presence update throttle and random status strings.""" + from __future__ import annotations import random import time -from typing import Callable +from collections.abc import Callable def random_status() -> str: diff --git a/src/poe2_rpc/cli.py b/src/poe2_rpc/cli.py index b954ae2..9879c56 100644 --- a/src/poe2_rpc/cli.py +++ b/src/poe2_rpc/cli.py @@ -10,11 +10,12 @@ Pass --no-discord to skip Discord IPC contact. --version Print version and exit. """ + from __future__ import annotations import asyncio +from collections.abc import Iterator from pathlib import Path -from typing import Iterator import structlog import typer diff --git a/src/poe2_rpc/domain/classes.py b/src/poe2_rpc/domain/classes.py index efb117c..8f2e025 100644 --- a/src/poe2_rpc/domain/classes.py +++ b/src/poe2_rpc/domain/classes.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import List, Optional class CharacterClass(Enum): @@ -11,7 +10,7 @@ class CharacterClass(Enum): WITCH = "Witch" HUNTRESS = "Huntress" - def get_ascendencies(self) -> Optional[List["ClassAscendency"]]: + def get_ascendencies(self) -> list["ClassAscendency"] | None: return { CharacterClass.MERCENARY: [ ClassAscendency.WITCHHUNTER, diff --git a/src/poe2_rpc/domain/events.py b/src/poe2_rpc/domain/events.py index 1f6fee8..84c4db3 100644 --- a/src/poe2_rpc/domain/events.py +++ b/src/poe2_rpc/domain/events.py @@ -1,4 +1,5 @@ """Frozen pydantic v2 domain event hierarchy.""" + from pathlib import Path from pydantic import BaseModel, ConfigDict diff --git a/src/poe2_rpc/domain/exceptions.py b/src/poe2_rpc/domain/exceptions.py index d976c30..277ec66 100644 --- a/src/poe2_rpc/domain/exceptions.py +++ b/src/poe2_rpc/domain/exceptions.py @@ -1,4 +1,5 @@ """Domain exceptions — pure domain layer, no I/O imports.""" + from __future__ import annotations @@ -6,5 +7,5 @@ class PoE2RPCError(Exception): """Base for all domain-layer exceptions.""" -class LogStreamStalled(PoE2RPCError): +class LogStreamStalledError(PoE2RPCError): """Raised when the log stream cannot enqueue domain-relevant lines within the deadline.""" diff --git a/src/poe2_rpc/domain/locations.py b/src/poe2_rpc/domain/locations.py index 4a5619a..457b9ff 100644 --- a/src/poe2_rpc/domain/locations.py +++ b/src/poe2_rpc/domain/locations.py @@ -1,4 +1,5 @@ """Location value object and catalog — pure domain logic, no I/O.""" + from __future__ import annotations from pydantic import BaseModel, ConfigDict @@ -21,13 +22,13 @@ def resolve(self, area_code: str) -> Location: normalized = area_code if area_code.startswith("Map"): - normalized = area_code[3:].split("_")[0] + normalized = area_code[3:].split("_", maxsplit=1)[0] if normalized in self._areas.values(): return Location(area_code=area_code, display_name=normalized) for key, value in self._areas.items(): - if normalized == key or normalized == value: + if normalized in (key, value): return Location(area_code=area_code, display_name=value) return Location(area_code=area_code, display_name=normalized) diff --git a/src/poe2_rpc/domain/models.py b/src/poe2_rpc/domain/models.py index 7b50ed6..409237d 100644 --- a/src/poe2_rpc/domain/models.py +++ b/src/poe2_rpc/domain/models.py @@ -1,4 +1,5 @@ """Frozen pydantic v2 value objects for the domain layer.""" + from pydantic import BaseModel, ConfigDict diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py index 409a861..e4b2cfa 100644 --- a/src/poe2_rpc/domain/ports.py +++ b/src/poe2_rpc/domain/ports.py @@ -1,8 +1,10 @@ """Domain port Protocols — all runtime_checkable, stdlib + domain imports only.""" + from __future__ import annotations +from collections.abc import Awaitable, Callable, Iterator from pathlib import Path -from typing import TYPE_CHECKING, Awaitable, Callable, Iterator, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable from poe2_rpc.domain.events import DomainEvent from poe2_rpc.domain.locations import Location @@ -32,14 +34,22 @@ def parse_instance(self, line: str) -> InstanceInfo | None: ... @runtime_checkable class PresencePublisher(Protocol): async def connect(self) -> None: ... - async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: ... + async def publish( + self, + level_info: LevelInfo | None, + instance_info: InstanceInfo | None, + ) -> None: ... def close(self) -> None: ... @runtime_checkable class EventBus(Protocol): def emit(self, event: DomainEvent) -> None: ... - def subscribe(self, event_type: type[DomainEvent], handler: Callable[[DomainEvent], Awaitable[None]]) -> None: ... + def subscribe( + self, + event_type: type[DomainEvent], + handler: Callable[[DomainEvent], Awaitable[None]], + ) -> None: ... @runtime_checkable diff --git a/src/poe2_rpc/infrastructure/catalog.py b/src/poe2_rpc/infrastructure/catalog.py index 42947a3..fe49794 100644 --- a/src/poe2_rpc/infrastructure/catalog.py +++ b/src/poe2_rpc/infrastructure/catalog.py @@ -1,4 +1,5 @@ """Bundled locations.json catalog adapter implementing LocationCatalogPort.""" + from __future__ import annotations import importlib.resources @@ -17,9 +18,7 @@ def load_bundled_catalog() -> LocationCatalog: bundled JSON is present and parsable without contacting Discord. """ text = ( - importlib.resources.files("poe2_rpc") - .joinpath("locations.json") - .read_text(encoding="utf-8") + importlib.resources.files("poe2_rpc").joinpath("locations.json").read_text(encoding="utf-8") ) data = json.loads(text) return LocationCatalog(areas=dict(data.get("areas", {}))) diff --git a/src/poe2_rpc/infrastructure/detection.py b/src/poe2_rpc/infrastructure/detection.py index c1c8d05..82956dc 100644 --- a/src/poe2_rpc/infrastructure/detection.py +++ b/src/poe2_rpc/infrastructure/detection.py @@ -1,4 +1,5 @@ """Psutil-based game process detector.""" + from __future__ import annotations import time diff --git a/src/poe2_rpc/infrastructure/log_stream.py b/src/poe2_rpc/infrastructure/log_stream.py index 6391e70..bf7d74c 100644 --- a/src/poe2_rpc/infrastructure/log_stream.py +++ b/src/poe2_rpc/infrastructure/log_stream.py @@ -4,20 +4,22 @@ directly. All enqueues are scheduled via loop.call_soon_threadsafe so they execute on the event-loop thread, keeping the Queue single-threaded. """ + from __future__ import annotations import asyncio import re import time from asyncio import AbstractEventLoop, QueueFull +from collections.abc import AsyncIterator from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator +from typing import TYPE_CHECKING import structlog from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.observers import Observer -from poe2_rpc.domain.exceptions import LogStreamStalled +from poe2_rpc.domain.exceptions import LogStreamStalledError if TYPE_CHECKING: from poe2_rpc.infrastructure.settings import AppSettings @@ -40,11 +42,11 @@ def _classify_line(line: str) -> bool: class _LogFileHandler(FileSystemEventHandler): """Watchdog handler — runs on the observer thread.""" - def __init__(self, stream: "WatchdogLogStream") -> None: + def __init__(self, stream: WatchdogLogStream) -> None: super().__init__() self._stream = stream - def on_modified(self, event: FileSystemEvent) -> None: + def on_modified(self, _event: FileSystemEvent) -> None: self._stream._read_new_lines() @@ -54,15 +56,13 @@ class WatchdogLogStream: def __init__( self, log_path: Path, - settings: "AppSettings", + settings: AppSettings, loop: AbstractEventLoop, ) -> None: self._log_path = log_path self._settings = settings self._loop = loop - self._queue: asyncio.Queue[str] = asyncio.Queue( - maxsize=settings.log_stream_queue_maxsize - ) + self._queue: asyncio.Queue[str] = asyncio.Queue(maxsize=settings.log_stream_queue_maxsize) self._cursor: int = 0 self._observer = Observer() self._handler = _LogFileHandler(self) @@ -125,7 +125,7 @@ def _enqueue( started_at = _started_at if _started_at is not None else time.monotonic() deadline = self._settings.log_stream_enqueue_deadline_seconds if time.monotonic() - started_at >= deadline: - raise LogStreamStalled( + raise LogStreamStalledError( f"Domain line could not be enqueued within {deadline}s" ) from None next_delay = min(_delay * 2, _BACKOFF_CAP) diff --git a/src/poe2_rpc/infrastructure/logging.py b/src/poe2_rpc/infrastructure/logging.py index 9cf8dd1..06c5da3 100644 --- a/src/poe2_rpc/infrastructure/logging.py +++ b/src/poe2_rpc/infrastructure/logging.py @@ -3,6 +3,7 @@ Call configure_logging(settings) once at startup. Do NOT call stdlib logging.basicConfig — structlog is the canonical interface. """ + from __future__ import annotations import logging diff --git a/src/poe2_rpc/infrastructure/parsing.py b/src/poe2_rpc/infrastructure/parsing.py index df64ee4..630916d 100644 --- a/src/poe2_rpc/infrastructure/parsing.py +++ b/src/poe2_rpc/infrastructure/parsing.py @@ -1,4 +1,5 @@ """Regex-based log line parsers — byte-patterns preserved verbatim from main.py:273-274.""" + from __future__ import annotations import re diff --git a/src/poe2_rpc/infrastructure/presence.py b/src/poe2_rpc/infrastructure/presence.py index aafd01c..470a935 100644 --- a/src/poe2_rpc/infrastructure/presence.py +++ b/src/poe2_rpc/infrastructure/presence.py @@ -1,9 +1,10 @@ """Discord Rich Presence publisher — infrastructure adapter for PresencePublisher port.""" + from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import pypresence.exceptions as pex @@ -91,7 +92,7 @@ def _build_update_kwargs( instance_info: InstanceInfo | None, ) -> dict[str, Any]: kwargs: dict[str, Any] = { - "start": int(datetime.now(tz=timezone.utc).timestamp()), + "start": int(datetime.now(tz=UTC).timestamp()), } if level_info is not None: details = f"{level_info.username} ({level_info.base_class}" @@ -102,9 +103,7 @@ def _build_update_kwargs( asc = level_info.ascension_class or level_info.base_class kwargs["small_image"] = asc.lower().replace(" ", "_") if instance_info is not None: - kwargs["state"] = ( - f"In: {instance_info.area_display_name} (Lvl {instance_info.level})" - ) + kwargs["state"] = f"In: {instance_info.area_display_name} (Lvl {instance_info.level})" return kwargs def close(self) -> None: diff --git a/src/poe2_rpc/infrastructure/settings.py b/src/poe2_rpc/infrastructure/settings.py index 8166161..c59f17e 100644 --- a/src/poe2_rpc/infrastructure/settings.py +++ b/src/poe2_rpc/infrastructure/settings.py @@ -7,16 +7,22 @@ Windows : %APPDATA%\\poe2-rpc\\config.toml POSIX : ~/.config/poe2-rpc/config.toml """ + from __future__ import annotations import os import sys +import tomllib from pathlib import Path -from typing import Any, ClassVar, Literal +from typing import Any, Literal from pydantic import PrivateAttr -from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict -from pydantic_settings import TomlConfigSettingsSource +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, + TomlConfigSettingsSource, +) def _default_config_path() -> Path: @@ -56,8 +62,6 @@ def __init__(self, _toml_file: str = "", **kwargs: Any) -> None: def _apply_toml(self, path: Path) -> None: if not path.exists(): return - import tomllib - with open(path, "rb") as f: data = tomllib.load(f) for key, value in data.items(): @@ -73,6 +77,8 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: + # framework signature requires these but we don't consume them + del dotenv_settings, file_secret_settings toml_source = TomlConfigSettingsSource(settings_cls, toml_file=_default_config_path()) # init → env → default TOML file → defaults return (init_settings, env_settings, toml_source) diff --git a/tests/conftest.py b/tests/conftest.py index 30b80a4..b3bff9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,7 @@ def sample_log_line_level() -> str: """Sample Client.txt line that matches regex_level (verbatim contract).""" return ( - "2024/01/01 12:00:00 12345 cffb0734 [INFO Client 9876] " - ": Foo (Witchhunter) is now level 42" + "2024/01/01 12:00:00 12345 cffb0734 [INFO Client 9876] : Foo (Witchhunter) is now level 42" ) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index c52cfb2..0f717d1 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -1,4 +1,5 @@ """Integration tests for the Typer CLI / composition root.""" + from __future__ import annotations from unittest.mock import MagicMock, patch diff --git a/tests/integration/test_main_module.py b/tests/integration/test_main_module.py index 2e7b9a8..aa0c7a1 100644 --- a/tests/integration/test_main_module.py +++ b/tests/integration/test_main_module.py @@ -1,6 +1,8 @@ """Test that `python -m poe2_rpc` works as an alternate entry point.""" + from __future__ import annotations +import os import subprocess import sys from pathlib import Path @@ -18,13 +20,12 @@ def test_module_runs_via_python_dash_m() -> None: text=True, env={"PYTHONPATH": str(SRC_DIR), **_env_passthrough()}, timeout=15, + check=False, ) assert result.returncode == 0, result.stderr assert __version__ in result.stdout def _env_passthrough() -> dict[str, str]: - import os - keep = ("PATH", "HOME", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TERM") return {k: os.environ[k] for k in keep if k in os.environ} diff --git a/tests/unit/test_bus.py b/tests/unit/test_bus.py index ee56b69..48c65f3 100644 --- a/tests/unit/test_bus.py +++ b/tests/unit/test_bus.py @@ -1,12 +1,12 @@ """Tests for AsyncioEventBus — dispatch and exception isolation.""" + from __future__ import annotations -import asyncio from pathlib import Path -from typing import Callable import pytest +from poe2_rpc.application.bus import AsyncioEventBus from poe2_rpc.domain.events import DomainEvent, GameStarted @@ -16,8 +16,6 @@ def _make_event() -> GameStarted: @pytest.mark.asyncio async def test_bus_dispatches_to_multiple_handlers() -> None: - from poe2_rpc.application.bus import AsyncioEventBus - received_a: list[DomainEvent] = [] received_b: list[DomainEvent] = [] @@ -40,8 +38,6 @@ async def handler_b(event: DomainEvent) -> None: @pytest.mark.asyncio async def test_bus_isolates_handler_exceptions() -> None: - from poe2_rpc.application.bus import AsyncioEventBus - received_b: list[DomainEvent] = [] async def handler_a(event: DomainEvent) -> None: diff --git a/tests/unit/test_catalog.py b/tests/unit/test_catalog.py index aa20498..2007226 100644 --- a/tests/unit/test_catalog.py +++ b/tests/unit/test_catalog.py @@ -1,8 +1,10 @@ """Unit tests for BundledLocationCatalog.""" + from __future__ import annotations import json +import httpx import pytest from poe2_rpc.infrastructure.catalog import BundledLocationCatalog @@ -37,8 +39,6 @@ class _FakeResponse: def raise_for_status(self) -> None: pass - import httpx - monkeypatch.setattr(httpx, "get", lambda url, **kw: _FakeResponse()) settings = AppSettings(locations_url="http://example.com/locations.json") cat = BundledLocationCatalog(settings=settings) diff --git a/tests/unit/test_classes.py b/tests/unit/test_classes.py index 43f6b16..f5ba922 100644 --- a/tests/unit/test_classes.py +++ b/tests/unit/test_classes.py @@ -1,5 +1,4 @@ """Tests for domain/classes.py — CharacterClass and ClassAscendency enums.""" -import pytest from poe2_rpc.domain.classes import CharacterClass, ClassAscendency @@ -8,8 +7,13 @@ class TestCharacterClass: def test_all_base_classes_present(self) -> None: names = {m.name for m in CharacterClass} assert names == { - "MERCENARY", "MONK", "RANGER", "SORCERESS", - "WARRIOR", "WITCH", "HUNTRESS", + "MERCENARY", + "MONK", + "RANGER", + "SORCERESS", + "WARRIOR", + "WITCH", + "HUNTRESS", } def test_enum_values_match_ingame_strings(self) -> None: @@ -34,11 +38,23 @@ class TestClassAscendency: def test_all_ascendencies_present(self) -> None: names = {m.name for m in ClassAscendency} assert names == { - "WITCHHUNTER", "GEMLING_LEGIONNAIRE", "ACOLYTE_OF_CHAYULA", - "INVOKER", "DEADEYE", "PATHFINDER", "CHRONOMANCER", - "STORMWEAVER", "TITAN", "WARBRINGER", "BLOOD_MAGE", - "INFERNALIST", "RITUALIST", "AMAZON", "SMITH_OF_KITAVA", - "LICH", "TACTICIAN", + "WITCHHUNTER", + "GEMLING_LEGIONNAIRE", + "ACOLYTE_OF_CHAYULA", + "INVOKER", + "DEADEYE", + "PATHFINDER", + "CHRONOMANCER", + "STORMWEAVER", + "TITAN", + "WARBRINGER", + "BLOOD_MAGE", + "INFERNALIST", + "RITUALIST", + "AMAZON", + "SMITH_OF_KITAVA", + "LICH", + "TACTICIAN", } def test_enum_values_match_ingame_strings(self) -> None: diff --git a/tests/unit/test_detection.py b/tests/unit/test_detection.py index 042fa1c..7c1a75f 100644 --- a/tests/unit/test_detection.py +++ b/tests/unit/test_detection.py @@ -1,12 +1,12 @@ """Unit tests for PsutilGameDetector — no real psutil scanning.""" + from __future__ import annotations +from collections.abc import Iterator from pathlib import Path -from typing import Iterator from unittest.mock import MagicMock import psutil -import pytest from poe2_rpc.infrastructure.detection import PsutilGameDetector from poe2_rpc.infrastructure.settings import AppSettings @@ -21,6 +21,7 @@ def _make_process(name: str, exe: str) -> MagicMock: def _fake_iter(*processes: MagicMock): def _factory(attrs: list[str]) -> Iterator[MagicMock]: return iter(processes) + return _factory @@ -39,9 +40,6 @@ def test_detector_returns_none_when_process_absent() -> None: def test_detector_skips_inaccessible_processes() -> None: - bad_proc = MagicMock() - bad_proc.info = {"name": "PathOfExileSteam.exe", "exe": r"C:/Games/PoE2/PathOfExileSteam.exe"} - bad_proc.info # access once to set up # Make the bad proc raise NoSuchProcess when .info is accessed via iteration # We simulate this by having the factory raise on first item good_proc = _make_process("PathOfExileSteam.exe", r"C:/Games/PoE2/PathOfExileSteam.exe") diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py index e4ab40a..f5218fa 100644 --- a/tests/unit/test_events.py +++ b/tests/unit/test_events.py @@ -1,10 +1,17 @@ """Tests for domain event hierarchy.""" + from pathlib import Path import pytest from pydantic import ValidationError -from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged, DomainEvent, GameStarted, GameStopped +from poe2_rpc.domain.events import ( + AreaEntered, + CharacterLevelChanged, + DomainEvent, + GameStarted, + GameStopped, +) from poe2_rpc.domain.models import InstanceInfo, LevelInfo diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 49367db..f2d5e2a 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,16 +1,15 @@ """Unit tests for domain exceptions (C-4b).""" + from __future__ import annotations +from poe2_rpc.domain.exceptions import LogStreamStalledError, PoE2RPCError -def test_log_stream_stalled_is_poe2rpc_error() -> None: - from poe2_rpc.domain.exceptions import LogStreamStalled, PoE2RPCError - assert issubclass(LogStreamStalled, PoE2RPCError) +def test_log_stream_stalled_is_poe2rpc_error() -> None: + assert issubclass(LogStreamStalledError, PoE2RPCError) def test_log_stream_stalled_carries_message() -> None: - from poe2_rpc.domain.exceptions import LogStreamStalled - msg = "Failed to enqueue domain line within 2.0s" - exc = LogStreamStalled(msg) + exc = LogStreamStalledError(msg) assert str(exc) == msg diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index d261678..fb32738 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,4 +1,5 @@ """Tests for application handlers — on_level_changed + on_area_entered (AC#7).""" + from __future__ import annotations from typing import Any @@ -7,6 +8,7 @@ import pytest import structlog.testing +from poe2_rpc.application.handlers import on_area_entered, on_level_changed from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged from poe2_rpc.domain.models import InstanceInfo, LevelInfo @@ -60,8 +62,6 @@ def _make_state( @pytest.mark.asyncio async def test_on_level_changed_formats_details_with_ascendancy() -> None: - from poe2_rpc.application.handlers import on_level_changed - publisher = AsyncMock() throttle = _make_throttle(allow=True) li = _level_info(username="TestUser", base_class="Witch", ascension_class="Lich", level=42) @@ -80,8 +80,6 @@ async def test_on_level_changed_formats_details_with_ascendancy() -> None: @pytest.mark.asyncio async def test_on_level_changed_omits_ascendancy_pipe_when_none() -> None: - from poe2_rpc.application.handlers import on_level_changed - publisher = AsyncMock() throttle = _make_throttle(allow=True) li = _level_info(username="TestUser", base_class="Mercenary", ascension_class=None, level=42) @@ -98,8 +96,6 @@ async def test_on_level_changed_omits_ascendancy_pipe_when_none() -> None: @pytest.mark.asyncio async def test_on_area_entered_formats_in_state() -> None: - from poe2_rpc.application.handlers import on_area_entered - publisher = AsyncMock() throttle = _make_throttle(allow=True) ii = _instance_info(area_display_name="Clearfell", level=5) @@ -116,9 +112,7 @@ async def test_on_area_entered_formats_in_state() -> None: @pytest.mark.asyncio async def test_small_image_lowercases_and_underscores() -> None: - """Infra derives small_image from ascension_class; handler passes LevelInfo with correct value.""" - from poe2_rpc.application.handlers import on_level_changed - + """Infra derives small_image from ascension_class; handler passes LevelInfo correctly.""" publisher = AsyncMock() throttle = _make_throttle(allow=True) li = _level_info(ascension_class="Smith of Kitava", base_class="Warrior", level=10) @@ -134,8 +128,6 @@ async def test_small_image_lowercases_and_underscores() -> None: @pytest.mark.asyncio async def test_handlers_bind_username_class_area_into_logs() -> None: - from poe2_rpc.application.handlers import on_level_changed - publisher = AsyncMock() throttle = _make_throttle(allow=True) li = _level_info(username="LogUser", base_class="Ranger", ascension_class="Deadeye", level=20) @@ -155,8 +147,6 @@ async def test_handlers_bind_username_class_area_into_logs() -> None: @pytest.mark.asyncio async def test_throttled_update_skips_publish() -> None: - from poe2_rpc.application.handlers import on_level_changed - publisher = AsyncMock() throttle = _make_throttle(allow=False) li = _level_info() diff --git a/tests/unit/test_layering.py b/tests/unit/test_layering.py index c977a00..625d95b 100644 --- a/tests/unit/test_layering.py +++ b/tests/unit/test_layering.py @@ -1,4 +1,5 @@ """AST guard: domain modules must not import from application, infrastructure, or cli layers.""" + import ast from pathlib import Path @@ -48,6 +49,6 @@ def test_domain_modules_do_not_import_outer_layers() -> None: for path in sorted(domain_files): all_violations.extend(collect_forbidden_imports(path)) - assert not all_violations, ( - "Domain modules import from outer layers:\n" + "\n".join(all_violations) + assert not all_violations, "Domain modules import from outer layers:\n" + "\n".join( + all_violations ) diff --git a/tests/unit/test_locations.py b/tests/unit/test_locations.py index e34d043..b2f3555 100644 --- a/tests/unit/test_locations.py +++ b/tests/unit/test_locations.py @@ -1,8 +1,9 @@ """Tests for Location VO and LocationCatalog.resolve().""" + import pytest from pydantic import ValidationError -from poe2_rpc.domain.locations import Location, LocationCatalog +from poe2_rpc.domain.locations import LocationCatalog @pytest.fixture diff --git a/tests/unit/test_log_stream.py b/tests/unit/test_log_stream.py index 092c1c0..bd4e8ab 100644 --- a/tests/unit/test_log_stream.py +++ b/tests/unit/test_log_stream.py @@ -1,48 +1,45 @@ """Unit tests for WatchdogLogStream — thread-safety contract (C-4).""" + from __future__ import annotations import re -import threading import time +from asyncio import QueueFull from pathlib import Path -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock import pytest - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- +from poe2_rpc.domain.exceptions import LogStreamStalledError +from poe2_rpc.infrastructure.log_stream import WatchdogLogStream +from poe2_rpc.infrastructure.settings import AppSettings _REGEX_LEVEL = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)") _REGEX_INSTANCE = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)') -DOMAIN_LINE = ': Marauder (Juggernaut) is now level 42' -NON_DOMAIN_LINE = '[INFO] some irrelevant log line' - +DOMAIN_LINE = ": Marauder (Juggernaut) is now level 42" +NON_DOMAIN_LINE = "[INFO] some irrelevant log line" -def _make_stream(tmp_path: Path, maxsize: int = 2) -> "WatchdogLogStream": - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings +def _make_stream(tmp_path: Path, maxsize: int = 2) -> WatchdogLogStream: log_file = tmp_path / "Client.txt" log_file.write_text("", encoding="utf-8") - settings = AppSettings(log_stream_queue_maxsize=maxsize, log_stream_enqueue_deadline_seconds=0.5) + settings = AppSettings( + log_stream_queue_maxsize=maxsize, + log_stream_enqueue_deadline_seconds=0.5, + ) loop = MagicMock() - stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=loop) - return stream + return WatchdogLogStream(log_path=log_file, settings=settings, loop=loop) # --------------------------------------------------------------------------- # Test 1 — observer thread NEVER calls put_nowait directly # --------------------------------------------------------------------------- + def test_watchdog_observer_thread_does_not_touch_queue_directly(tmp_path: Path) -> None: """Handler's on_modified path must use call_soon_threadsafe, not queue.put_nowait.""" - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings - log_file = tmp_path / "Client.txt" log_file.write_text("", encoding="utf-8") @@ -50,26 +47,22 @@ def test_watchdog_observer_thread_does_not_touch_queue_directly(tmp_path: Path) settings = AppSettings(log_stream_queue_maxsize=10) stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) - # Simulate file modification with new content log_file.write_text(DOMAIN_LINE + "\n", encoding="utf-8") stream._handler.on_modified(MagicMock()) # type: ignore[attr-defined] - # call_soon_threadsafe must have been called mock_loop.call_soon_threadsafe.assert_called() - # Queue put_nowait must NOT have been called from this path - # (it's only called inside _enqueue which runs on the loop thread) - assert not stream._queue.put_nowait.called if hasattr(stream._queue, 'put_nowait') and isinstance(stream._queue.put_nowait, MagicMock) else True + put_nowait = getattr(stream._queue, "put_nowait", None) + if isinstance(put_nowait, MagicMock): + assert not put_nowait.called # --------------------------------------------------------------------------- # Test 2 — _enqueue is invoked via call_soon_threadsafe # --------------------------------------------------------------------------- + def test_enqueue_runs_on_loop_thread(tmp_path: Path) -> None: """call_soon_threadsafe must schedule _enqueue (not call put_nowait directly).""" - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings - log_file = tmp_path / "Client.txt" log_file.write_text("", encoding="utf-8") @@ -80,25 +73,18 @@ def test_enqueue_runs_on_loop_thread(tmp_path: Path) -> None: log_file.write_text(DOMAIN_LINE + "\n", encoding="utf-8") stream._handler.on_modified(MagicMock()) # type: ignore[attr-defined] - # Verify call_soon_threadsafe was called with _enqueue as callback calls = mock_loop.call_soon_threadsafe.call_args_list assert len(calls) >= 1 - # The first positional arg should be _enqueue assert calls[0][0][0] == stream._enqueue # type: ignore[attr-defined] # --------------------------------------------------------------------------- -# Test 3 — QueueFull → exponential backoff for domain lines → LogStreamStalled +# Test 3 — QueueFull → exponential backoff for domain lines → LogStreamStalledError # --------------------------------------------------------------------------- -def test_queue_full_exponential_backoff_for_domain_lines(tmp_path: Path) -> None: - """Domain lines on QueueFull get exponential-backoff retry; past deadline raises LogStreamStalled.""" - from asyncio import QueueFull - - from poe2_rpc.domain.exceptions import LogStreamStalled - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings +def test_queue_full_exponential_backoff_for_domain_lines(tmp_path: Path) -> None: + """Domain lines on QueueFull get exponential-backoff retry; raises past deadline.""" log_file = tmp_path / "Client.txt" log_file.write_text("", encoding="utf-8") @@ -109,23 +95,16 @@ def test_queue_full_exponential_backoff_for_domain_lines(tmp_path: Path) -> None ) stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) - # Make put_nowait always raise QueueFull stream._queue = MagicMock() # type: ignore[attr-defined] stream._queue.put_nowait.side_effect = QueueFull() - # Simulate calling _enqueue past deadline — use a started_at in the past started_at = time.monotonic() - 10.0 # well past deadline - with pytest.raises(LogStreamStalled): + with pytest.raises(LogStreamStalledError): stream._enqueue(DOMAIN_LINE, _started_at=started_at) # type: ignore[attr-defined] def test_queue_full_exponential_backoff_schedules_call_later(tmp_path: Path) -> None: """Domain lines on QueueFull within deadline schedule a call_later retry.""" - from asyncio import QueueFull - - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings - log_file = tmp_path / "Client.txt" log_file.write_text("", encoding="utf-8") @@ -139,7 +118,6 @@ def test_queue_full_exponential_backoff_schedules_call_later(tmp_path: Path) -> stream._queue = MagicMock() # type: ignore[attr-defined] stream._queue.put_nowait.side_effect = QueueFull() - # First call — within deadline, delay=0.05 started_at = time.monotonic() stream._enqueue(DOMAIN_LINE, _started_at=started_at, _delay=0.05) # type: ignore[attr-defined] @@ -152,13 +130,9 @@ def test_queue_full_exponential_backoff_schedules_call_later(tmp_path: Path) -> # Test 4 — QueueFull drops non-domain lines, increments counter # --------------------------------------------------------------------------- + def test_queue_full_drops_non_domain_lines_with_metric(tmp_path: Path) -> None: """Non-domain lines on QueueFull are dropped; dropped_non_domain_count incremented.""" - from asyncio import QueueFull - - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings - log_file = tmp_path / "Client.txt" log_file.write_text("", encoding="utf-8") @@ -174,7 +148,6 @@ def test_queue_full_drops_non_domain_lines_with_metric(tmp_path: Path) -> None: stream._enqueue(NON_DOMAIN_LINE) # type: ignore[attr-defined] assert stream.dropped_non_domain_count == 1 # type: ignore[attr-defined] - # No retry scheduled mock_loop.call_later.assert_not_called() @@ -182,11 +155,9 @@ def test_queue_full_drops_non_domain_lines_with_metric(tmp_path: Path) -> None: # Test 5 — file rotation resets cursor # --------------------------------------------------------------------------- + def test_file_rotation_resets_cursor(tmp_path: Path) -> None: """When cursor > file size (rotation), cursor resets to 0 and new lines are yielded.""" - from poe2_rpc.infrastructure.log_stream import WatchdogLogStream - from poe2_rpc.infrastructure.settings import AppSettings - log_file = tmp_path / "Client.txt" initial_content = "old line 1\nold line 2\n" log_file.write_text(initial_content, encoding="utf-8") @@ -195,20 +166,15 @@ def test_file_rotation_resets_cursor(tmp_path: Path) -> None: settings = AppSettings(log_stream_queue_maxsize=100) stream = WatchdogLogStream(log_path=log_file, settings=settings, loop=mock_loop) - # Rotate: truncate file to smaller content (cursor now > new size) new_content = "new line after rotation\n" log_file.write_text(new_content, encoding="utf-8") - # Advance cursor beyond new file size to simulate rotation detection stream._cursor = len(initial_content.encode("utf-8")) + 100 # type: ignore[attr-defined] - # Trigger handler stream._handler.on_modified(MagicMock()) # type: ignore[attr-defined] - # Cursor should now be at end of new content (rotation reset it to 0 first) assert stream._cursor == len(new_content.encode("utf-8")) # type: ignore[attr-defined] - # call_soon_threadsafe should have been called with the new line assert mock_loop.call_soon_threadsafe.call_count >= 1 scheduled_line = mock_loop.call_soon_threadsafe.call_args_list[0][0][1] assert "new line after rotation" in scheduled_line diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 879a619..492157e 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -1,15 +1,17 @@ """Tests for configure_logging — RED phase.""" + from __future__ import annotations import sys -from io import StringIO import pytest import structlog +from poe2_rpc.infrastructure.logging import configure_logging +from poe2_rpc.infrastructure.settings import AppSettings + def _make_settings(log_format: str = "console", log_level: str = "INFO") -> object: - from poe2_rpc.infrastructure.settings import AppSettings return AppSettings(log_format=log_format, log_level=log_level) # type: ignore[arg-type] @@ -17,7 +19,6 @@ def test_configure_logging_console_when_tty(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(sys.stdout, "isatty", lambda: True) settings = _make_settings(log_format="console") - from poe2_rpc.infrastructure.logging import configure_logging configure_logging(settings) # type: ignore[arg-type] cfg = structlog.get_config() @@ -29,7 +30,6 @@ def test_configure_logging_json_when_setting(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) settings = _make_settings(log_format="json") - from poe2_rpc.infrastructure.logging import configure_logging configure_logging(settings) # type: ignore[arg-type] cfg = structlog.get_config() @@ -40,7 +40,6 @@ def test_configure_logging_json_when_setting(monkeypatch: pytest.MonkeyPatch) -> def test_bind_contextvars_propagates(capsys: pytest.CaptureFixture[str]) -> None: settings = _make_settings(log_format="console", log_level="DEBUG") - from poe2_rpc.infrastructure.logging import configure_logging configure_logging(settings) # type: ignore[arg-type] structlog.contextvars.clear_contextvars() @@ -56,7 +55,6 @@ def test_bind_contextvars_propagates(capsys: pytest.CaptureFixture[str]) -> None def test_log_level_respected(capsys: pytest.CaptureFixture[str]) -> None: settings = _make_settings(log_format="console", log_level="WARNING") - from poe2_rpc.infrastructure.logging import configure_logging configure_logging(settings) # type: ignore[arg-type] structlog.contextvars.clear_contextvars() diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index eec0f83..4f29bcd 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,4 +1,5 @@ """Tests for domain value objects: LevelInfo and InstanceInfo.""" + import pytest from pydantic import ValidationError diff --git a/tests/unit/test_no_mutable_state.py b/tests/unit/test_no_mutable_state.py index 0b4f2cb..f0c4f4a 100644 --- a/tests/unit/test_no_mutable_state.py +++ b/tests/unit/test_no_mutable_state.py @@ -1,4 +1,5 @@ """AST guard: domain modules must not have module-level mutable state.""" + import ast from pathlib import Path @@ -15,6 +16,7 @@ ast.If, # allow if TYPE_CHECKING guards ) + def _is_final_annotation(annotation: ast.expr | None) -> bool: if annotation is None: return False @@ -54,9 +56,7 @@ def collect_violations(path: Path) -> list[str]: f"{path.name}:{node.lineno} — annotated assignment without Final[...]" ) elif isinstance(node, ast.Assign): - violations.append( - f"{path.name}:{node.lineno} — bare module-level assignment" - ) + violations.append(f"{path.name}:{node.lineno} — bare module-level assignment") return violations @@ -72,6 +72,6 @@ def test_domain_modules_have_no_mutable_state() -> None: for path in sorted(domain_files): all_violations.extend(collect_violations(path)) - assert not all_violations, ( - "Module-level mutable state found in domain:\n" + "\n".join(all_violations) + assert not all_violations, "Module-level mutable state found in domain:\n" + "\n".join( + all_violations ) diff --git a/tests/unit/test_orchestrator_layering.py b/tests/unit/test_orchestrator_layering.py index 184c4b5..bca3550 100644 --- a/tests/unit/test_orchestrator_layering.py +++ b/tests/unit/test_orchestrator_layering.py @@ -1,15 +1,14 @@ """AST guard: orchestrator must not import from poe2_rpc.infrastructure.*""" + from __future__ import annotations import ast from pathlib import Path +import pytest + ORCHESTRATOR_PATH = ( - Path(__file__).parent.parent.parent - / "src" - / "poe2_rpc" - / "application" - / "orchestrator.py" + Path(__file__).parent.parent.parent / "src" / "poe2_rpc" / "application" / "orchestrator.py" ) FORBIDDEN_PREFIX = "poe2_rpc.infrastructure" @@ -23,23 +22,16 @@ def _collect_forbidden(path: Path) -> list[str]: if isinstance(node, ast.ImportFrom): module = node.module or "" if module == FORBIDDEN_PREFIX or module.startswith(FORBIDDEN_PREFIX + "."): - violations.append( - f"line {node.lineno}: forbidden 'from {module} import ...'" - ) + violations.append(f"line {node.lineno}: forbidden 'from {module} import ...'") elif isinstance(node, ast.Import): for alias in node.names: - if alias.name == FORBIDDEN_PREFIX or alias.name.startswith( - FORBIDDEN_PREFIX + "." - ): - violations.append( - f"line {node.lineno}: forbidden 'import {alias.name}'" - ) + if alias.name == FORBIDDEN_PREFIX or alias.name.startswith(FORBIDDEN_PREFIX + "."): + violations.append(f"line {node.lineno}: forbidden 'import {alias.name}'") return violations def test_orchestrator_has_no_infrastructure_imports() -> None: if not ORCHESTRATOR_PATH.exists(): - import pytest pytest.fail(f"orchestrator.py not found at {ORCHESTRATOR_PATH}") violations = _collect_forbidden(ORCHESTRATOR_PATH) diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py index e3f7ed4..88f88a6 100644 --- a/tests/unit/test_parsing.py +++ b/tests/unit/test_parsing.py @@ -1,7 +1,6 @@ """Unit tests for infrastructure parsing functions.""" -from __future__ import annotations -import pytest +from __future__ import annotations from poe2_rpc.infrastructure.parsing import parse_instance_event, parse_level_event diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index ff94247..14ed870 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -1,6 +1,7 @@ """Tests for domain port Protocols — all must be @runtime_checkable.""" + +from collections.abc import Awaitable, Callable, Iterator from pathlib import Path -from typing import Awaitable, Callable, Iterator from poe2_rpc.domain.events import DomainEvent from poe2_rpc.domain.locations import Location @@ -40,7 +41,11 @@ class _ConcretePresencePublisher: async def connect(self) -> None: pass - async def publish(self, level_info: LevelInfo | None, instance_info: InstanceInfo | None) -> None: + async def publish( + self, + level_info: LevelInfo | None, + instance_info: InstanceInfo | None, + ) -> None: pass def close(self) -> None: diff --git a/tests/unit/test_presence_connect.py b/tests/unit/test_presence_connect.py index 267fcce..697d3b2 100644 --- a/tests/unit/test_presence_connect.py +++ b/tests/unit/test_presence_connect.py @@ -1,9 +1,11 @@ """Unit tests for PypresencePublisher.connect (C-7a).""" + from __future__ import annotations -import pytest import pypresence.exceptions as pex +import pytest +from poe2_rpc.infrastructure.presence import PypresencePublisher from poe2_rpc.infrastructure.settings import AppSettings @@ -39,8 +41,6 @@ def settings() -> AppSettings: @pytest.mark.asyncio async def test_connect_calls_aiopresence_connect_once_on_success(settings: AppSettings) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - fake = FakeAioPresence("test-app-id") publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] await publisher.connect() @@ -50,16 +50,14 @@ async def test_connect_calls_aiopresence_connect_once_on_success(settings: AppSe @pytest.mark.asyncio -async def test_connect_retries_5_times_on_pipeclosed( - settings: AppSettings, mocker: object -) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - +async def test_connect_retries_5_times_on_pipeclosed(settings: AppSettings, mocker: object) -> None: mocker.patch("asyncio.sleep") # type: ignore[union-attr] fake = FakeAioPresence("test-app-id") # Fail 4 times, succeed on 5th - fake.set_side_effects([pex.PipeClosed(), pex.PipeClosed(), pex.PipeClosed(), pex.PipeClosed(), None]) + fake.set_side_effects( + [pex.PipeClosed(), pex.PipeClosed(), pex.PipeClosed(), pex.PipeClosed(), None] + ) publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] await publisher.connect() @@ -71,8 +69,6 @@ async def test_connect_retries_5_times_on_pipeclosed( async def test_connect_reraises_after_all_attempts_exhausted( settings: AppSettings, mocker: object ) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - mocker.patch("asyncio.sleep") # type: ignore[union-attr] fake = FakeAioPresence("test-app-id") @@ -88,8 +84,6 @@ async def test_connect_reraises_after_all_attempts_exhausted( @pytest.mark.asyncio async def test_connect_uses_settings_app_id(mocker: object) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - custom_settings = AppSettings(discord_app_id="custom-id-123", connect_retry_attempts=5) captured: list[str] = [] diff --git a/tests/unit/test_presence_publish.py b/tests/unit/test_presence_publish.py index 14936cd..01836e8 100644 --- a/tests/unit/test_presence_publish.py +++ b/tests/unit/test_presence_publish.py @@ -1,10 +1,12 @@ """Unit tests for PypresencePublisher.publish (C-7b).""" + from __future__ import annotations -import pytest import pypresence.exceptions as pex +import pytest from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.infrastructure.presence import PypresencePublisher from poe2_rpc.infrastructure.settings import AppSettings @@ -70,8 +72,6 @@ async def test_publish_calls_aiopresence_update_once_on_success( level_info: LevelInfo, instance_info: InstanceInfo, ) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - fake = FakeAioPresence("test-app-id") publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] publisher._presence = fake # inject already-connected presence @@ -88,8 +88,6 @@ async def test_publish_retries_3_times_on_discorderror( instance_info: InstanceInfo, mocker: object, ) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - mocker.patch("asyncio.sleep") # type: ignore[union-attr] fake = FakeAioPresence("test-app-id") @@ -110,8 +108,6 @@ async def test_publish_reraises_after_3_attempts( instance_info: InstanceInfo, mocker: object, ) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - mocker.patch("asyncio.sleep") # type: ignore[union-attr] fake = FakeAioPresence("test-app-id") @@ -131,8 +127,6 @@ async def test_publish_does_not_share_retry_state_with_connect( level_info: LevelInfo, instance_info: InstanceInfo, ) -> None: - from poe2_rpc.infrastructure.presence import PypresencePublisher - fake = FakeAioPresence("test-app-id") publisher = PypresencePublisher(settings, presence_factory=lambda cid: fake) # type: ignore[arg-type] publisher._presence = fake diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index e41aa5b..6f9fc42 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,16 +1,17 @@ """Tests for AppSettings — RED phase.""" + from __future__ import annotations -import os -import sys +from importlib import reload from pathlib import Path import pytest +import poe2_rpc.infrastructure.settings as settings_mod +from poe2_rpc.infrastructure.settings import AppSettings -def test_settings_defaults() -> None: - from poe2_rpc.infrastructure.settings import AppSettings +def test_settings_defaults() -> None: s = AppSettings() assert s.discord_app_id == "1315800372207419504" assert s.process_name == "PathOfExileSteam.exe" @@ -28,12 +29,9 @@ def test_settings_env_overrides_defaults(monkeypatch: pytest.MonkeyPatch) -> Non monkeypatch.setenv("POE2RPC_DISCORD_APP_ID", "9999999999999999999") monkeypatch.setenv("POE2RPC_THROTTLE_WINDOW_SECONDS", "30.0") - from importlib import reload - import poe2_rpc.infrastructure.settings as mod - reload(mod) - from poe2_rpc.infrastructure.settings import AppSettings + reload(settings_mod) - s = AppSettings() + s = settings_mod.AppSettings() assert s.discord_app_id == "9999999999999999999" assert s.throttle_window_seconds == 30.0 @@ -45,8 +43,6 @@ def test_settings_toml_overrides_defaults(tmp_path: Path) -> None: encoding="utf-8", ) - from poe2_rpc.infrastructure.settings import AppSettings - s = AppSettings(_toml_file=str(toml_file)) # type: ignore[call-arg] assert s.discord_app_id == "1111111111111111111" assert s.log_level == "DEBUG" @@ -55,7 +51,5 @@ def test_settings_toml_overrides_defaults(tmp_path: Path) -> None: def test_settings_init_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("POE2RPC_CONNECT_RETRY_ATTEMPTS", "10") - from poe2_rpc.infrastructure.settings import AppSettings - s = AppSettings(connect_retry_attempts=2) assert s.connect_retry_attempts == 2 diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py index 7d2e736..29d9c3d 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -3,6 +3,9 @@ from __future__ import annotations import poe2_rpc +import poe2_rpc.application +import poe2_rpc.domain +import poe2_rpc.infrastructure def test_package_importable() -> None: @@ -12,10 +15,6 @@ def test_package_importable() -> None: def test_subpackages_importable() -> None: - import poe2_rpc.application - import poe2_rpc.domain - import poe2_rpc.infrastructure - assert poe2_rpc.domain.__doc__ is not None assert poe2_rpc.application.__doc__ is not None assert poe2_rpc.infrastructure.__doc__ is not None diff --git a/tests/unit/test_throttle.py b/tests/unit/test_throttle.py index 7cfa068..2c1f519 100644 --- a/tests/unit/test_throttle.py +++ b/tests/unit/test_throttle.py @@ -1,7 +1,8 @@ """Tests for PresenceThrottle application service.""" + from __future__ import annotations -from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.application.throttle import PresenceThrottle, random_status def test_first_call_is_not_throttled() -> None: @@ -39,8 +40,6 @@ def clock() -> float: def test_random_status_returns_known_string() -> None: - from poe2_rpc.application.throttle import random_status - result = random_status() known = [ "Exploring ancient ruins", From fcc50b6fe21c2d45cb5b51415873813b7df826f7 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 14:17:06 +0300 Subject: [PATCH 09/17] fix(deps): declare httpx as runtime dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infrastructure/catalog.py imports httpx at module level for the locations_url override path. CI mypy strict failed with import-not-found because httpx was not in [project.dependencies] — it only resolved locally via transient install. Declare it explicitly so CI installs it and the strict typecheck passes. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 17a1b18..2f7ec43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "pydantic-settings>=2.3", "structlog>=24.1", "typer>=0.12", + "httpx>=0.27", ] [project.optional-dependencies] From 6a1b9bc6f1317fc1f601754097e250d9f9aaf5af Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 14:21:09 +0300 Subject: [PATCH 10/17] ci(workflow): hoist FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to workflow env Previous commit set the env only on the dorny/paths-filter step, leaving actions/upload-artifact@v4, actions/download-artifact@v4, and softprops/action-gh-release@v2 still emitting Node 20 deprecation warnings. Hoisting the env to workflow level applies the Node 24 force to every step in every job in one place. --- .github/workflows/build.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47eb1d0..2c3f71f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: changes: runs-on: ubuntu-latest @@ -24,8 +27,6 @@ jobs: steps: - uses: actions/checkout@v5 - uses: dorny/paths-filter@v3 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true id: filter with: filters: | From aeb081a1905e0d73d51c53750db2a25942d081f5 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" Date: Tue, 5 May 2026 14:27:35 +0300 Subject: [PATCH 11/17] ci(actions): bump to latest major versions per context7 docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout@v5 → @v6 (Node 24 native) - dorny/paths-filter@v3 → @v4 (new ref input, list-files csv) - actions/download-artifact@v4 → @v5 (Node 24 native) - softprops/action-gh-release@v2 → @v3 (explicitly Node 24) actions/upload-artifact stays at @v4 — no v5 yet upstream; FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 retained for that one action. --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c3f71f..1f4dc8f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,8 +25,8 @@ jobs: outputs: build_relevant: ${{ steps.filter.outputs.build_relevant }} steps: - - uses: actions/checkout@v5 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 id: filter with: filters: | @@ -42,7 +42,7 @@ jobs: needs: changes steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -81,7 +81,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -138,13 +138,13 @@ jobs: steps: - name: Download executable - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: executable path: dist - name: Create Release and upload asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 142e7458fa245c5abac298f82ff54e3b71e30708 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" <46745805+PrEvIeS@users.noreply.github.com> Date: Tue, 5 May 2026 16:46:49 +0300 Subject: [PATCH 12/17] feat(detector): support official PoE2 client (PathOfExile.exe) alongside Steam `AppSettings.process_name` becomes `list[str]` defaulting to both the Steam build (`PathOfExileSteam.exe`) and the standalone client (`PathOfExile.exe`). A `field_validator` + `NoDecode` annotation keeps legacy `POE2RPC_PROCESS_NAME=Foo.exe` env vars working by promoting the raw string to a single-element list before validation. `PsutilGameDetector` now matches the running process by membership in the candidate list. Refs upstream PR-1 from .omc/plans/ralplan-upstream-prs.md (bd panvex-4gx). --- src/poe2_rpc/infrastructure/detection.py | 2 +- src/poe2_rpc/infrastructure/settings.py | 23 ++++++++++++++--- tests/unit/test_detection.py | 33 +++++++++++++++++++++--- tests/unit/test_settings.py | 22 +++++++++++++++- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/poe2_rpc/infrastructure/detection.py b/src/poe2_rpc/infrastructure/detection.py index 82956dc..f4b934c 100644 --- a/src/poe2_rpc/infrastructure/detection.py +++ b/src/poe2_rpc/infrastructure/detection.py @@ -50,7 +50,7 @@ def _iter_processes_safely(self) -> Iterator[Path | None]: for proc in self._process_iter(["name", "exe"]): try: info = proc.info - if info.get("name") == self._settings.process_name: + if info.get("name") in self._settings.process_name: exe = info.get("exe") if exe: yield Path(exe).parent / "logs" / "Client.txt" diff --git a/src/poe2_rpc/infrastructure/settings.py b/src/poe2_rpc/infrastructure/settings.py index c59f17e..662a605 100644 --- a/src/poe2_rpc/infrastructure/settings.py +++ b/src/poe2_rpc/infrastructure/settings.py @@ -14,11 +14,12 @@ import sys import tomllib from pathlib import Path -from typing import Any, Literal +from typing import Annotated, Any, Literal -from pydantic import PrivateAttr +from pydantic import PrivateAttr, field_validator from pydantic_settings import ( BaseSettings, + NoDecode, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource, @@ -41,7 +42,13 @@ class AppSettings(BaseSettings): ) discord_app_id: str = "1315800372207419504" - process_name: str = "PathOfExileSteam.exe" + # NoDecode: env-source must pass the raw POE2RPC_PROCESS_NAME string through + # to the validator below so a legacy single-string value is coerced to + # a one-element list instead of failing JSON decoding for `list[str]`. + process_name: Annotated[list[str], NoDecode] = [ + "PathOfExileSteam.exe", + "PathOfExile.exe", + ] locations_url: str | None = None log_stream_enqueue_deadline_seconds: float = 2.0 log_stream_queue_maxsize: int = 1000 @@ -53,6 +60,16 @@ class AppSettings(BaseSettings): _toml_file: str = PrivateAttr(default="") + @field_validator("process_name", mode="before") + @classmethod + def _coerce_string_to_list(cls, v: object) -> object: + # Back-compat: legacy POE2RPC_PROCESS_NAME=Foo.exe (single string) is + # accepted and promoted to a single-element list so existing user + # configs keep working after the str -> list[str] type change. + if isinstance(v, str): + return [v] + return v + def __init__(self, _toml_file: str = "", **kwargs: Any) -> None: super().__init__(**kwargs) self._toml_file = _toml_file diff --git a/tests/unit/test_detection.py b/tests/unit/test_detection.py index 7c1a75f..d73ca29 100644 --- a/tests/unit/test_detection.py +++ b/tests/unit/test_detection.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock import psutil +import pytest from poe2_rpc.infrastructure.detection import PsutilGameDetector from poe2_rpc.infrastructure.settings import AppSettings @@ -25,16 +26,40 @@ def _factory(attrs: list[str]) -> Iterator[MagicMock]: return _factory +@pytest.mark.parametrize( + "process_name,exe_path,expected_log", + [ + ( + "PathOfExileSteam.exe", + r"C:/Games/PoE2/PathOfExileSteam.exe", + r"C:/Games/PoE2/logs/Client.txt", + ), + ( + "PathOfExile.exe", + r"C:/Grinding Gear Games/Path of Exile 2/PathOfExile.exe", + r"C:/Grinding Gear Games/Path of Exile 2/logs/Client.txt", + ), + ], +) +def test_detector_resolves_log_path_for_both_clients( + process_name: str, exe_path: str, expected_log: str +) -> None: + proc = _make_process(process_name, exe_path) + settings = AppSettings() # default candidates include both clients + detector = PsutilGameDetector(settings=settings, process_iter_factory=_fake_iter(proc)) + assert detector.find_log_path() == Path(expected_log) + + def test_detector_returns_log_path_when_process_running() -> None: proc = _make_process("PathOfExileSteam.exe", r"C:/Games/PoE2/PathOfExileSteam.exe") - settings = AppSettings(process_name="PathOfExileSteam.exe") + settings = AppSettings(process_name=["PathOfExileSteam.exe"]) detector = PsutilGameDetector(settings=settings, process_iter_factory=_fake_iter(proc)) result = detector.find_log_path() assert result == Path(r"C:/Games/PoE2/logs/Client.txt") def test_detector_returns_none_when_process_absent() -> None: - settings = AppSettings(process_name="PathOfExileSteam.exe") + settings = AppSettings(process_name=["PathOfExileSteam.exe"]) detector = PsutilGameDetector(settings=settings, process_iter_factory=_fake_iter()) assert detector.find_log_path() is None @@ -53,7 +78,7 @@ def _raising_iter(attrs: list[str]) -> Iterator[MagicMock]: yield good_proc detector = PsutilGameDetector( - settings=AppSettings(process_name="PathOfExileSteam.exe"), + settings=AppSettings(process_name=["PathOfExileSteam.exe"]), process_iter_factory=_raising_iter, ) result = detector.find_log_path() @@ -71,7 +96,7 @@ def info(self) -> dict[str, str]: def test_detector_uses_settings_process_name() -> None: proc = _make_process("OtherGame.exe", r"C:/Games/Other/OtherGame.exe") wrong_proc = _make_process("PathOfExileSteam.exe", r"C:/Games/PoE2/PathOfExileSteam.exe") - settings = AppSettings(process_name="OtherGame.exe") + settings = AppSettings(process_name=["OtherGame.exe"]) detector = PsutilGameDetector( settings=settings, process_iter_factory=_fake_iter(wrong_proc, proc) ) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 6f9fc42..0ed0f6f 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -14,7 +14,7 @@ def test_settings_defaults() -> None: s = AppSettings() assert s.discord_app_id == "1315800372207419504" - assert s.process_name == "PathOfExileSteam.exe" + assert s.process_name == ["PathOfExileSteam.exe", "PathOfExile.exe"] assert s.locations_url is None assert s.log_stream_enqueue_deadline_seconds == 2.0 assert s.log_stream_queue_maxsize == 1000 @@ -53,3 +53,23 @@ def test_settings_init_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: s = AppSettings(connect_retry_attempts=2) assert s.connect_retry_attempts == 2 + + +def test_process_name_string_env_coerced_to_list(monkeypatch: pytest.MonkeyPatch) -> None: + # Back-compat: legacy single-string env var must be promoted to a + # single-element list so users with POE2RPC_PROCESS_NAME=Foo.exe in + # an existing config keep working after the type change to list[str]. + monkeypatch.setenv("POE2RPC_PROCESS_NAME", "Foo.exe") + reload(settings_mod) + s = settings_mod.AppSettings() + assert s.process_name == ["Foo.exe"] + + +def test_process_name_init_string_coerced_to_list() -> None: + s = AppSettings(process_name="OnlyOne.exe") # type: ignore[arg-type] + assert s.process_name == ["OnlyOne.exe"] + + +def test_process_name_init_list_passes_through() -> None: + s = AppSettings(process_name=["A.exe", "B.exe"]) + assert s.process_name == ["A.exe", "B.exe"] From e006c537ad7a60d7c05e8af2c2f8eaeaad2d6d61 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" <46745805+PrEvIeS@users.noreply.github.com> Date: Tue, 5 May 2026 17:06:45 +0300 Subject: [PATCH 13/17] feat(owner): auto-pin local player via area-entered + level-event state machine Adds an `OwnerTracker` frozen pydantic VO (UNKNOWN -> AREA_ENTERED -> PINNED, or INVALIDATED if a party member joins inside an unpinned window). The orchestrator emits two new domain events from the parse loop: * `LocalAreaEntered` <- `: You have entered .` * `PartyMemberJoined` <- ` has joined the area.` Their handlers drive the state machine. `on_level_changed` consults `owner_tracker.should_emit(li.username)` and drops level events that don't belong to the local player. The `LogParser` Protocol grows two new `parse_local_area_entered` / `parse_party_joined` methods (regexes verbatim from klayveR/poe-log-monitor `resource/events.json`). An optional `POE2RPC_CHARACTER_NAME` env override short-circuits to PINNED on first area entry. Tests cover the full state-machine table (15 cases incl. parametrized party scenarios + re-entry-resets-INVALIDATED) and add a runtime-checkable Protocol conformance test for `RegexLogParser`. Refs upstream PR-2 from .omc/plans/ralplan-upstream-prs.md (bd panvex-7w5). --- src/poe2_rpc/application/handlers.py | 62 ++++++++++- src/poe2_rpc/application/orchestrator.py | 31 +++++- src/poe2_rpc/cli.py | 5 +- src/poe2_rpc/domain/events.py | 12 +++ src/poe2_rpc/domain/owner.py | 69 +++++++++++++ src/poe2_rpc/domain/ports.py | 2 + src/poe2_rpc/infrastructure/parsing.py | 21 ++++ src/poe2_rpc/infrastructure/settings.py | 1 + tests/integration/test_orchestrator.py | 10 ++ tests/unit/test_log_parser_protocol.py | 31 ++++++ tests/unit/test_owner.py | 126 +++++++++++++++++++++++ tests/unit/test_ports.py | 6 ++ 12 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 src/poe2_rpc/domain/owner.py create mode 100644 tests/unit/test_log_parser_protocol.py create mode 100644 tests/unit/test_owner.py diff --git a/src/poe2_rpc/application/handlers.py b/src/poe2_rpc/application/handlers.py index af46ee5..1d5e4ab 100644 --- a/src/poe2_rpc/application/handlers.py +++ b/src/poe2_rpc/application/handlers.py @@ -6,8 +6,14 @@ import structlog.contextvars from poe2_rpc.application.throttle import PresenceThrottle -from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged +from poe2_rpc.domain.events import ( + AreaEntered, + CharacterLevelChanged, + LocalAreaEntered, + PartyMemberJoined, +) from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.owner import OwnerState, OwnerTracker from poe2_rpc.domain.ports import PresencePublisher _log = structlog.get_logger(__name__) @@ -16,9 +22,12 @@ class MutableState: """Shared mutable state threaded through handlers so each can see the other's last value.""" - def __init__(self) -> None: + def __init__(self, owner_tracker: OwnerTracker | None = None) -> None: self.level_info: LevelInfo | None = None self.instance_info: InstanceInfo | None = None + self.owner_tracker: OwnerTracker = ( + owner_tracker if owner_tracker is not None else OwnerTracker.unknown() + ) def _format_details(level_info: LevelInfo) -> str: @@ -37,6 +46,18 @@ async def on_level_changed( current_state: MutableState, ) -> None: li = event.level_info + + current_state.owner_tracker = current_state.owner_tracker.on_level_event(li.username) + + if not current_state.owner_tracker.should_emit(li.username): + _log.debug( + "level_event_dropped_non_owner", + username=li.username, + owner_state=current_state.owner_tracker.state.value, + pinned_name=current_state.owner_tracker.pinned_name, + ) + return + current_state.level_info = li structlog.contextvars.bind_contextvars( @@ -89,3 +110,40 @@ async def on_area_entered( area_level=ii.level, ) await publisher.publish(current_state.level_info, ii) + + +async def on_local_area_entered( + event: LocalAreaEntered, + *, + current_state: MutableState, +) -> None: + """`: You have entered ` opens a fresh owner-detection window.""" + current_state.owner_tracker = current_state.owner_tracker.on_local_area_entered() + _log.debug( + "local_area_entered", + area_name=event.area_name, + owner_state=current_state.owner_tracker.state.value, + pinned_name=current_state.owner_tracker.pinned_name, + ) + + +async def on_party_joined( + event: PartyMemberJoined, + *, + current_state: MutableState, +) -> None: + """A party member entering the same instance invalidates an unpinned window.""" + prior = current_state.owner_tracker + current_state.owner_tracker = prior.on_party_member_joined() + if prior.state == OwnerState.PINNED: + _log.warning( + "party_member_joined_while_pinned", + party_member=event.name, + pinned_name=prior.pinned_name, + ) + else: + _log.debug( + "party_member_joined", + party_member=event.name, + owner_state=current_state.owner_tracker.state.value, + ) diff --git a/src/poe2_rpc/application/orchestrator.py b/src/poe2_rpc/application/orchestrator.py index 3651605..533c8dc 100644 --- a/src/poe2_rpc/application/orchestrator.py +++ b/src/poe2_rpc/application/orchestrator.py @@ -14,9 +14,20 @@ import structlog from poe2_rpc.application.bus import AsyncioEventBus -from poe2_rpc.application.handlers import MutableState, on_area_entered, on_level_changed +from poe2_rpc.application.handlers import ( + MutableState, + on_area_entered, + on_level_changed, + on_local_area_entered, + on_party_joined, +) from poe2_rpc.application.throttle import PresenceThrottle -from poe2_rpc.domain.events import AreaEntered, CharacterLevelChanged +from poe2_rpc.domain.events import ( + AreaEntered, + CharacterLevelChanged, + LocalAreaEntered, + PartyMemberJoined, +) from poe2_rpc.domain.ports import ( GameDetector, LocationCatalogPort, @@ -75,6 +86,14 @@ def _subscribe_handlers(self) -> None: current_state=self._current_state, ), ) + self._bus.subscribe( + LocalAreaEntered, + functools.partial(on_local_area_entered, current_state=self._current_state), + ) + self._bus.subscribe( + PartyMemberJoined, + functools.partial(on_party_joined, current_state=self._current_state), + ) def run_once(self) -> None: """Process all lines from one log stream pass. Handles CancelledError/KeyboardInterrupt.""" @@ -85,6 +104,14 @@ def run_once(self) -> None: log_path = self._detector.log_path() stream = self._factory(log_path, loop) for line in stream.lines(): + local_area = self._parser.parse_local_area_entered(line) + if local_area is not None: + self._bus.emit(LocalAreaEntered(area_name=local_area)) + continue + party_name = self._parser.parse_party_joined(line) + if party_name is not None: + self._bus.emit(PartyMemberJoined(name=party_name)) + continue level_info = self._parser.parse_level(line) if level_info is not None: self._bus.emit(CharacterLevelChanged(level_info=level_info)) diff --git a/src/poe2_rpc/cli.py b/src/poe2_rpc/cli.py index 9879c56..1bdc8e7 100644 --- a/src/poe2_rpc/cli.py +++ b/src/poe2_rpc/cli.py @@ -25,6 +25,7 @@ from poe2_rpc.application.handlers import MutableState from poe2_rpc.application.orchestrator import Orchestrator from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.domain.owner import OwnerTracker from poe2_rpc.domain.ports import LogStream from poe2_rpc.infrastructure.catalog import load_bundled_catalog from poe2_rpc.infrastructure.detection import PsutilGameDetector @@ -93,7 +94,9 @@ def factory(path: Path, loop: asyncio.AbstractEventLoop) -> LogStream: bus=bus, log_stream_factory=factory, throttle=PresenceThrottle(interval=settings.throttle_window_seconds), - current_state=MutableState(), + current_state=MutableState( + owner_tracker=OwnerTracker.unknown(override_name=settings.character_name), + ), settings=settings, ) diff --git a/src/poe2_rpc/domain/events.py b/src/poe2_rpc/domain/events.py index 84c4db3..2910933 100644 --- a/src/poe2_rpc/domain/events.py +++ b/src/poe2_rpc/domain/events.py @@ -25,3 +25,15 @@ class CharacterLevelChanged(DomainEvent): class AreaEntered(DomainEvent): instance_info: InstanceInfo + + +class LocalAreaEntered(DomainEvent): + """Emitted on `: You have entered .` — signals the local player crossed a boundary.""" + + area_name: str + + +class PartyMemberJoined(DomainEvent): + """Emitted on ` has joined the area.` — signals another player is in the same instance.""" + + name: str diff --git a/src/poe2_rpc/domain/owner.py b/src/poe2_rpc/domain/owner.py new file mode 100644 index 0000000..be2e59c --- /dev/null +++ b/src/poe2_rpc/domain/owner.py @@ -0,0 +1,69 @@ +"""Owner detection state machine — frozen VO + transitions. + +The orchestrator sees three kinds of signal: + * `: You have entered ` — the LOCAL player crossed an area boundary. + * ` has joined the area.` — a party member is in the same instance. + * `: () is now level ` — somebody (anybody) levelled. + +In a fresh local-area-entered window with NO party members present, the very +next level event is by definition the local player — pin them. If a party +member shows up first, we cannot disambiguate the level event and drop it. +An optional `POE2RPC_CHARACTER_NAME` override short-circuits the state +machine straight to PINNED on the first area entry. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class OwnerState(StrEnum): + UNKNOWN = "unknown" + AREA_ENTERED = "area_entered" + PINNED = "pinned" + INVALIDATED = "invalidated" + + +class OwnerTracker(BaseModel): + model_config = ConfigDict(frozen=True) + + state: OwnerState = OwnerState.UNKNOWN + pinned_name: str | None = None + override_name: str | None = None + + @classmethod + def unknown(cls, override_name: str | None = None) -> OwnerTracker: + return cls( + state=OwnerState.UNKNOWN, + pinned_name=None, + override_name=override_name, + ) + + def on_local_area_entered(self) -> OwnerTracker: + if self.override_name is not None: + return self.model_copy( + update={ + "state": OwnerState.PINNED, + "pinned_name": self.override_name, + } + ) + return self.model_copy(update={"state": OwnerState.AREA_ENTERED, "pinned_name": None}) + + def on_party_member_joined(self) -> OwnerTracker: + if self.state == OwnerState.AREA_ENTERED: + return self.model_copy(update={"state": OwnerState.INVALIDATED}) + return self + + def on_level_event(self, username: str) -> OwnerTracker: + if self.state == OwnerState.AREA_ENTERED: + return self.model_copy(update={"state": OwnerState.PINNED, "pinned_name": username}) + return self + + def should_emit(self, username: str) -> bool: + if self.state == OwnerState.PINNED: + return self.pinned_name == username + if self.state == OwnerState.AREA_ENTERED: + return True + return self.state == OwnerState.UNKNOWN diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py index e4b2cfa..c6fe170 100644 --- a/src/poe2_rpc/domain/ports.py +++ b/src/poe2_rpc/domain/ports.py @@ -29,6 +29,8 @@ def lines(self) -> Iterator[str]: ... class LogParser(Protocol): def parse_level(self, line: str) -> LevelInfo | None: ... def parse_instance(self, line: str) -> InstanceInfo | None: ... + def parse_local_area_entered(self, line: str) -> str | None: ... + def parse_party_joined(self, line: str) -> str | None: ... @runtime_checkable diff --git a/src/poe2_rpc/infrastructure/parsing.py b/src/poe2_rpc/infrastructure/parsing.py index 630916d..d72bac5 100644 --- a/src/poe2_rpc/infrastructure/parsing.py +++ b/src/poe2_rpc/infrastructure/parsing.py @@ -8,6 +8,9 @@ regex_level = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)") regex_instance = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)') +# Verbatim from klayveR/poe-log-monitor resource/events.json: +regex_local_area_entered = re.compile(r": You have entered (.*)\.") +regex_party_joined = re.compile(r": (\S+) has joined the area\.") def parse_level_event(line: str) -> LevelInfo | None: @@ -34,6 +37,16 @@ def parse_instance_event(line: str) -> InstanceInfo | None: ) +def parse_local_area_entered_event(line: str) -> str | None: + m = regex_local_area_entered.search(line) + return m.group(1) if m else None + + +def parse_party_joined_event(line: str) -> str | None: + m = regex_party_joined.search(line) + return m.group(1) if m else None + + class RegexLogParser: """LogParser port adapter wrapping the module-level parse_*_event functions.""" @@ -44,3 +57,11 @@ def parse_level(line: str) -> LevelInfo | None: @staticmethod def parse_instance(line: str) -> InstanceInfo | None: return parse_instance_event(line) + + @staticmethod + def parse_local_area_entered(line: str) -> str | None: + return parse_local_area_entered_event(line) + + @staticmethod + def parse_party_joined(line: str) -> str | None: + return parse_party_joined_event(line) diff --git a/src/poe2_rpc/infrastructure/settings.py b/src/poe2_rpc/infrastructure/settings.py index 662a605..409d7b6 100644 --- a/src/poe2_rpc/infrastructure/settings.py +++ b/src/poe2_rpc/infrastructure/settings.py @@ -50,6 +50,7 @@ class AppSettings(BaseSettings): "PathOfExile.exe", ] locations_url: str | None = None + character_name: str | None = None log_stream_enqueue_deadline_seconds: float = 2.0 log_stream_queue_maxsize: int = 1000 throttle_window_seconds: float = 15.0 diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py index 605b1df..dd5a7de 100644 --- a/tests/integration/test_orchestrator.py +++ b/tests/integration/test_orchestrator.py @@ -65,6 +65,16 @@ def parse_instance(self, line: str) -> InstanceInfo | None: ) return None + def parse_local_area_entered(self, line: str) -> str | None: + if line.startswith("LOCAL_AREA:"): + return line[len("LOCAL_AREA:") :] + return None + + def parse_party_joined(self, line: str) -> str | None: + if line.startswith("PARTY:"): + return line[len("PARTY:") :] + return None + class FakePresencePublisher: def __init__(self) -> None: diff --git a/tests/unit/test_log_parser_protocol.py b/tests/unit/test_log_parser_protocol.py new file mode 100644 index 0000000..b34e01c --- /dev/null +++ b/tests/unit/test_log_parser_protocol.py @@ -0,0 +1,31 @@ +"""Conformance tests: RegexLogParser must satisfy the LogParser Protocol.""" + +from __future__ import annotations + +from poe2_rpc.domain.ports import LogParser +from poe2_rpc.infrastructure.parsing import RegexLogParser + + +def test_regex_log_parser_satisfies_protocol() -> None: + assert isinstance(RegexLogParser(), LogParser) + + +def test_incomplete_parser_fails_protocol_check() -> None: + """A parser missing one of the four parse_* methods is rejected at runtime.""" + + class IncompleteParser: + @staticmethod + def parse_level(line: str) -> None: + return None + + @staticmethod + def parse_instance(line: str) -> None: + return None + + @staticmethod + def parse_local_area_entered(line: str) -> None: + return None + + # Intentionally missing parse_party_joined + + assert not isinstance(IncompleteParser(), LogParser) diff --git a/tests/unit/test_owner.py b/tests/unit/test_owner.py new file mode 100644 index 0000000..db6fb6a --- /dev/null +++ b/tests/unit/test_owner.py @@ -0,0 +1,126 @@ +"""State-machine tests for OwnerTracker (PR-2 owner detection).""" + +from __future__ import annotations + +import pytest + +from poe2_rpc.domain.owner import OwnerState, OwnerTracker + + +def test_initial_state_is_unknown() -> None: + t = OwnerTracker.unknown() + assert t.state == OwnerState.UNKNOWN + assert t.pinned_name is None + assert t.override_name is None + + +def test_local_area_entered_no_override_transitions_to_area_entered() -> None: + t = OwnerTracker.unknown().on_local_area_entered() + assert t.state == OwnerState.AREA_ENTERED + assert t.pinned_name is None + + +def test_local_area_entered_with_override_pins_immediately() -> None: + t = OwnerTracker.unknown(override_name="MyChar").on_local_area_entered() + assert t.state == OwnerState.PINNED + assert t.pinned_name == "MyChar" + + +def test_party_join_in_area_window_invalidates() -> None: + t = OwnerTracker.unknown().on_local_area_entered().on_party_member_joined() + assert t.state == OwnerState.INVALIDATED + assert t.pinned_name is None + + +def test_first_level_in_clean_window_pins() -> None: + t = OwnerTracker.unknown().on_local_area_entered().on_level_event("Alice") + assert t.state == OwnerState.PINNED + assert t.pinned_name == "Alice" + + +def test_should_emit_only_for_pinned_name() -> None: + t = OwnerTracker(state=OwnerState.PINNED, pinned_name="Alice") + assert t.should_emit("Alice") is True + assert t.should_emit("Bob") is False + + +def test_invalidated_state_drops_all_emit() -> None: + t = OwnerTracker(state=OwnerState.INVALIDATED) + assert t.should_emit("Alice") is False + assert t.should_emit("Bob") is False + + +def test_party_join_while_pinned_keeps_pin() -> None: + pinned = OwnerTracker.unknown().on_local_area_entered().on_level_event("Alice") + after_join = pinned.on_party_member_joined() + assert after_join.state == OwnerState.PINNED + assert after_join.pinned_name == "Alice" + + +def test_re_entering_area_resets_invalidated_state() -> None: + t = OwnerTracker.unknown().on_local_area_entered().on_party_member_joined() + assert t.state == OwnerState.INVALIDATED + t = t.on_local_area_entered() + assert t.state == OwnerState.AREA_ENTERED + assert t.pinned_name is None + + +def test_unknown_state_emits_freely() -> None: + """Pre-area-entered events shouldn't be filtered (caller has no owner signal yet).""" + t = OwnerTracker.unknown() + assert t.should_emit("Anyone") is True + + +def test_area_entered_state_emits_freely() -> None: + """A pinned-but-not-yet-pinned window emits to let the orchestrator pin on first level.""" + t = OwnerTracker.unknown().on_local_area_entered() + assert t.should_emit("Anyone") is True + + +def test_owner_tracker_is_frozen() -> None: + """Domain VOs are immutable per the AST guard in tests/unit/test_no_mutable_state.py.""" + t = OwnerTracker.unknown() + with pytest.raises((TypeError, ValueError)): + t.state = OwnerState.PINNED # type: ignore[misc] + + +@pytest.mark.parametrize( + ("scenario", "expected_state", "expected_pinned"), + [ + # Solo player enters, levels up — pins to local + ( + [("area",), ("level", "Alice")], + OwnerState.PINNED, + "Alice", + ), + # Solo player enters, party member joins, then someone levels — invalidated, drops level + ( + [("area",), ("party", "Bob"), ("level", "Alice")], + OwnerState.INVALIDATED, + None, + ), + # Override set: first area entry pins immediately regardless of party + ( + [("area",), ("party", "Bob"), ("level", "Bob")], + OwnerState.PINNED, + "MyChar", + ), + ], + ids=["solo-then-level", "party-then-level-drops", "override-pins-first"], +) +def test_party_scenarios( + scenario: list[tuple[str, ...]], + expected_state: OwnerState, + expected_pinned: str | None, +) -> None: + override = "MyChar" if expected_pinned == "MyChar" else None + t = OwnerTracker.unknown(override_name=override) + for step in scenario: + if step[0] == "area": + t = t.on_local_area_entered() + elif step[0] == "party": + t = t.on_party_member_joined() + elif step[0] == "level": + t = t.on_level_event(step[1]) + assert t.state == expected_state + assert t.pinned_name == expected_pinned diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 14ed870..4bfcb95 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -36,6 +36,12 @@ def parse_level(self, line: str) -> LevelInfo | None: def parse_instance(self, line: str) -> InstanceInfo | None: return None + def parse_local_area_entered(self, line: str) -> str | None: + return None + + def parse_party_joined(self, line: str) -> str | None: + return None + class _ConcretePresencePublisher: async def connect(self) -> None: From 8ff71f366f98601a8488e17925a817793ad1b770 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" <46745805+PrEvIeS@users.noreply.github.com> Date: Tue, 5 May 2026 17:33:53 +0300 Subject: [PATCH 14/17] feat(afk): publish AFK/DND status with [AFK] suffix and small-image override On the AFK ON line, the handler snapshots the current small_image (derived from the current LevelInfo) so a level-up that happens during the AFK window cannot leak its new icon when AFK turns OFF. Restore semantics use a single small_image_override path through PypresencePublisher so the snapshot wins over the recomputed default. - domain/models: AFKStatus frozen VO (mode: Literal["AFK","DND"], on, autoreply) - domain/events: AFKStatusChanged - domain/ports: LogParser.parse_afk_event; PresencePublisher.publish gains afk_on + small_image_override kwargs (defaults preserve back-compat) - infrastructure/parsing: regex_afk + parse_afk_event_line + RegexLogParser.parse_afk_event - infrastructure/presence: [AFK] suffix on state, override replaces small_image - application/handlers: MutableState gains afk_on/prior_small_image; new on_afk_changed handler captures snapshot on ON, restores on OFF - application/orchestrator: subscribe AFKStatusChanged + parse-loop branch - tests/unit/test_afk.py: 9 tests (4 parametrized parse + 1 none + 2 kwargs + restore-after-level-during-AFK + AFK-without-prior-level) --- src/poe2_rpc/application/handlers.py | 82 +++++++++++- src/poe2_rpc/application/orchestrator.py | 14 ++ src/poe2_rpc/domain/events.py | 8 +- src/poe2_rpc/domain/models.py | 10 ++ src/poe2_rpc/domain/ports.py | 6 +- src/poe2_rpc/infrastructure/parsing.py | 25 +++- src/poe2_rpc/infrastructure/presence.py | 22 ++- tests/integration/test_orchestrator.py | 14 +- tests/unit/test_afk.py | 163 +++++++++++++++++++++++ tests/unit/test_ports.py | 8 +- 10 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_afk.py diff --git a/src/poe2_rpc/application/handlers.py b/src/poe2_rpc/application/handlers.py index 1d5e4ab..792e8e8 100644 --- a/src/poe2_rpc/application/handlers.py +++ b/src/poe2_rpc/application/handlers.py @@ -7,6 +7,7 @@ from poe2_rpc.application.throttle import PresenceThrottle from poe2_rpc.domain.events import ( + AFKStatusChanged, AreaEntered, CharacterLevelChanged, LocalAreaEntered, @@ -18,6 +19,8 @@ _log = structlog.get_logger(__name__) +_AFK_SMALL_IMAGE = "afk" + class MutableState: """Shared mutable state threaded through handlers so each can see the other's last value.""" @@ -28,6 +31,11 @@ def __init__(self, owner_tracker: OwnerTracker | None = None) -> None: self.owner_tracker: OwnerTracker = ( owner_tracker if owner_tracker is not None else OwnerTracker.unknown() ) + self.afk_on: bool = False + # Snapshot of small_image at the moment AFK turned ON; restored on OFF. + # Decoupled from level_info so a level-up *during* AFK doesn't leak + # the new ascendency into the post-AFK presence. + self.prior_small_image: str | None = None def _format_details(level_info: LevelInfo) -> str: @@ -38,6 +46,21 @@ def _format_details(level_info: LevelInfo) -> str: return details +def _small_image_for(level_info: LevelInfo | None) -> str | None: + """Mirror PypresencePublisher._build_update_kwargs small_image derivation.""" + if level_info is None: + return None + asc = level_info.ascension_class or level_info.base_class + return asc.lower().replace(" ", "_") + + +def _afk_publish_kwargs(state: MutableState) -> dict[str, object]: + """When AFK is on, every publish must keep the [AFK] suffix and afk small_image.""" + if state.afk_on: + return {"afk_on": True, "small_image_override": _AFK_SMALL_IMAGE} + return {"afk_on": False, "small_image_override": None} + + async def on_level_changed( event: CharacterLevelChanged, *, @@ -78,7 +101,11 @@ async def on_level_changed( level=li.level, details=_format_details(li), ) - await publisher.publish(li, current_state.instance_info) + await publisher.publish( + li, + current_state.instance_info, + **_afk_publish_kwargs(current_state), # type: ignore[arg-type] + ) async def on_area_entered( @@ -109,7 +136,11 @@ async def on_area_entered( area=ii.area_display_name, area_level=ii.level, ) - await publisher.publish(current_state.level_info, ii) + await publisher.publish( + current_state.level_info, + ii, + **_afk_publish_kwargs(current_state), # type: ignore[arg-type] + ) async def on_local_area_entered( @@ -147,3 +178,50 @@ async def on_party_joined( party_member=event.name, owner_state=current_state.owner_tracker.state.value, ) + + +async def on_afk_changed( + event: AFKStatusChanged, + *, + publisher: PresencePublisher, + current_state: MutableState, +) -> None: + """`: AFK mode is now ON|OFF` (and DND variants). + + On ON: snapshot the current small_image (derived from level_info) so a + subsequent level-up during the AFK window cannot leak its new icon. + On OFF: restore the snapshot via small_image_override; a None snapshot + (no level seen before AFK) cleanly omits the override. + """ + status = event.status + structlog.contextvars.bind_contextvars(afk=status.on, afk_mode=status.mode) + + if status.on: + current_state.prior_small_image = _small_image_for(current_state.level_info) + current_state.afk_on = True + _log.info( + "afk_on", + mode=status.mode, + snapshot=current_state.prior_small_image, + autoreply=status.autoreply, + ) + await publisher.publish( + current_state.level_info, + current_state.instance_info, + afk_on=True, + small_image_override=_AFK_SMALL_IMAGE, + ) + return + + current_state.afk_on = False + restore = current_state.prior_small_image # None when no level seen pre-AFK + _log.info("afk_off", mode=status.mode, restored=restore) + await publisher.publish( + current_state.level_info, + current_state.instance_info, + afk_on=False, + small_image_override=restore, + ) + # Clear the snapshot AFTER the restore publish so a future AFK-ON that + # arrives before any new level event can capture a fresh snapshot. + current_state.prior_small_image = None diff --git a/src/poe2_rpc/application/orchestrator.py b/src/poe2_rpc/application/orchestrator.py index 533c8dc..2673fd7 100644 --- a/src/poe2_rpc/application/orchestrator.py +++ b/src/poe2_rpc/application/orchestrator.py @@ -16,6 +16,7 @@ from poe2_rpc.application.bus import AsyncioEventBus from poe2_rpc.application.handlers import ( MutableState, + on_afk_changed, on_area_entered, on_level_changed, on_local_area_entered, @@ -23,6 +24,7 @@ ) from poe2_rpc.application.throttle import PresenceThrottle from poe2_rpc.domain.events import ( + AFKStatusChanged, AreaEntered, CharacterLevelChanged, LocalAreaEntered, @@ -94,6 +96,14 @@ def _subscribe_handlers(self) -> None: PartyMemberJoined, functools.partial(on_party_joined, current_state=self._current_state), ) + self._bus.subscribe( + AFKStatusChanged, + functools.partial( + on_afk_changed, + publisher=self._publisher, + current_state=self._current_state, + ), + ) def run_once(self) -> None: """Process all lines from one log stream pass. Handles CancelledError/KeyboardInterrupt.""" @@ -112,6 +122,10 @@ def run_once(self) -> None: if party_name is not None: self._bus.emit(PartyMemberJoined(name=party_name)) continue + afk_status = self._parser.parse_afk_event(line) + if afk_status is not None: + self._bus.emit(AFKStatusChanged(status=afk_status)) + continue level_info = self._parser.parse_level(line) if level_info is not None: self._bus.emit(CharacterLevelChanged(level_info=level_info)) diff --git a/src/poe2_rpc/domain/events.py b/src/poe2_rpc/domain/events.py index 2910933..b9d5a81 100644 --- a/src/poe2_rpc/domain/events.py +++ b/src/poe2_rpc/domain/events.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict -from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo class DomainEvent(BaseModel): @@ -37,3 +37,9 @@ class PartyMemberJoined(DomainEvent): """Emitted on ` has joined the area.` — signals another player is in the same instance.""" name: str + + +class AFKStatusChanged(DomainEvent): + """Emitted on `: AFK mode is now ON. Autoreply "..."` and OFF variants (also DND).""" + + status: AFKStatus diff --git a/src/poe2_rpc/domain/models.py b/src/poe2_rpc/domain/models.py index 409237d..e62750f 100644 --- a/src/poe2_rpc/domain/models.py +++ b/src/poe2_rpc/domain/models.py @@ -1,5 +1,7 @@ """Frozen pydantic v2 value objects for the domain layer.""" +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -19,3 +21,11 @@ class InstanceInfo(BaseModel): area_display_name: str level: int seed: int + + +class AFKStatus(BaseModel): + model_config = ConfigDict(frozen=True) + + mode: Literal["AFK", "DND"] + on: bool + autoreply: str | None = None diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py index c6fe170..2adf204 100644 --- a/src/poe2_rpc/domain/ports.py +++ b/src/poe2_rpc/domain/ports.py @@ -8,7 +8,7 @@ from poe2_rpc.domain.events import DomainEvent from poe2_rpc.domain.locations import Location -from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo if TYPE_CHECKING: Handler = Callable[[DomainEvent], Awaitable[None]] @@ -31,6 +31,7 @@ def parse_level(self, line: str) -> LevelInfo | None: ... def parse_instance(self, line: str) -> InstanceInfo | None: ... def parse_local_area_entered(self, line: str) -> str | None: ... def parse_party_joined(self, line: str) -> str | None: ... + def parse_afk_event(self, line: str) -> AFKStatus | None: ... @runtime_checkable @@ -40,6 +41,9 @@ async def publish( self, level_info: LevelInfo | None, instance_info: InstanceInfo | None, + *, + afk_on: bool = False, + small_image_override: str | None = None, ) -> None: ... def close(self) -> None: ... diff --git a/src/poe2_rpc/infrastructure/parsing.py b/src/poe2_rpc/infrastructure/parsing.py index d72bac5..0e1501b 100644 --- a/src/poe2_rpc/infrastructure/parsing.py +++ b/src/poe2_rpc/infrastructure/parsing.py @@ -3,14 +3,16 @@ from __future__ import annotations import re +from typing import Literal -from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo regex_level = re.compile(r": (\w+) \(([\w\s]+)\) is now level (\d+)") regex_instance = re.compile(r'Generating level (\d+) area "([^"]+)" with seed (\d+)') # Verbatim from klayveR/poe-log-monitor resource/events.json: regex_local_area_entered = re.compile(r": You have entered (.*)\.") regex_party_joined = re.compile(r": (\S+) has joined the area\.") +regex_afk = re.compile(r': (DND|AFK) mode is now (?:(ON)\. Autoreply "(.*)"|(OFF))') def parse_level_event(line: str) -> LevelInfo | None: @@ -47,6 +49,23 @@ def parse_party_joined_event(line: str) -> str | None: return m.group(1) if m else None +def parse_afk_event_line(line: str) -> AFKStatus | None: + m = regex_afk.search(line) + if not m: + return None + raw_mode = m.group(1) + mode: Literal["AFK", "DND"] + if raw_mode == "AFK": + mode = "AFK" + elif raw_mode == "DND": + mode = "DND" + else: + return None + if m.group(2) == "ON": + return AFKStatus(mode=mode, on=True, autoreply=m.group(3)) + return AFKStatus(mode=mode, on=False, autoreply=None) + + class RegexLogParser: """LogParser port adapter wrapping the module-level parse_*_event functions.""" @@ -65,3 +84,7 @@ def parse_local_area_entered(line: str) -> str | None: @staticmethod def parse_party_joined(line: str) -> str | None: return parse_party_joined_event(line) + + @staticmethod + def parse_afk_event(line: str) -> AFKStatus | None: + return parse_afk_event_line(line) diff --git a/src/poe2_rpc/infrastructure/presence.py b/src/poe2_rpc/infrastructure/presence.py index 470a935..2b860f2 100644 --- a/src/poe2_rpc/infrastructure/presence.py +++ b/src/poe2_rpc/infrastructure/presence.py @@ -67,12 +67,20 @@ async def publish( self, level_info: LevelInfo | None, instance_info: InstanceInfo | None, + *, + afk_on: bool = False, + small_image_override: str | None = None, ) -> None: """Publish a Rich Presence update with its own 3× retry (independent of connect).""" if self._presence is None: raise RuntimeError("publish() called before connect()") - kwargs = self._build_update_kwargs(level_info, instance_info) + kwargs = self._build_update_kwargs( + level_info, + instance_info, + afk_on=afk_on, + small_image_override=small_image_override, + ) presence = self._presence @retry( @@ -90,6 +98,9 @@ async def _publish() -> None: def _build_update_kwargs( level_info: LevelInfo | None, instance_info: InstanceInfo | None, + *, + afk_on: bool = False, + small_image_override: str | None = None, ) -> dict[str, Any]: kwargs: dict[str, Any] = { "start": int(datetime.now(tz=UTC).timestamp()), @@ -102,8 +113,15 @@ def _build_update_kwargs( kwargs["details"] = details asc = level_info.ascension_class or level_info.base_class kwargs["small_image"] = asc.lower().replace(" ", "_") + if small_image_override is not None: + kwargs["small_image"] = small_image_override if instance_info is not None: - kwargs["state"] = f"In: {instance_info.area_display_name} (Lvl {instance_info.level})" + state = f"In: {instance_info.area_display_name} (Lvl {instance_info.level})" + if afk_on: + state += " [AFK]" + kwargs["state"] = state + elif afk_on: + kwargs["state"] = "[AFK]" return kwargs def close(self) -> None: diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py index dd5a7de..a94d603 100644 --- a/tests/integration/test_orchestrator.py +++ b/tests/integration/test_orchestrator.py @@ -11,7 +11,7 @@ from poe2_rpc.application.orchestrator import Orchestrator from poe2_rpc.application.throttle import PresenceThrottle from poe2_rpc.domain.locations import Location -from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo from poe2_rpc.domain.ports import LogStream # --- Fakes --- @@ -75,10 +75,18 @@ def parse_party_joined(self, line: str) -> str | None: return line[len("PARTY:") :] return None + def parse_afk_event(self, line: str) -> AFKStatus | None: + if line.startswith("AFK_ON:"): + return AFKStatus(mode="AFK", on=True, autoreply=line[len("AFK_ON:") :]) + if line == "AFK_OFF": + return AFKStatus(mode="AFK", on=False, autoreply=None) + return None + class FakePresencePublisher: def __init__(self) -> None: self.published: list[tuple[LevelInfo | None, InstanceInfo | None]] = [] + self.publish_kwargs: list[dict[str, object]] = [] self.connected: bool = False async def connect(self) -> None: @@ -88,8 +96,12 @@ async def publish( self, level_info: LevelInfo | None, instance_info: InstanceInfo | None, + *, + afk_on: bool = False, + small_image_override: str | None = None, ) -> None: self.published.append((level_info, instance_info)) + self.publish_kwargs.append({"afk_on": afk_on, "small_image_override": small_image_override}) def close(self) -> None: pass diff --git a/tests/unit/test_afk.py b/tests/unit/test_afk.py new file mode 100644 index 0000000..d874bfb --- /dev/null +++ b/tests/unit/test_afk.py @@ -0,0 +1,163 @@ +"""AFK status tests — parser, presence kwargs, and snapshot/restore handler semantics.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from poe2_rpc.application.handlers import MutableState, on_afk_changed +from poe2_rpc.domain.events import AFKStatusChanged +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo +from poe2_rpc.infrastructure.parsing import parse_afk_event_line +from poe2_rpc.infrastructure.presence import PypresencePublisher + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ( + '2026-05-05 12:00:00 12345 [INFO Client 1234] : AFK mode is now ON. Autoreply "brb"', + AFKStatus(mode="AFK", on=True, autoreply="brb"), + ), + ( + "2026-05-05 12:00:00 12345 [INFO Client 1234] : AFK mode is now OFF", + AFKStatus(mode="AFK", on=False, autoreply=None), + ), + ( + '2026-05-05 12:00:00 12345 [INFO Client 1234] : DND mode is now ON. Autoreply "busy"', + AFKStatus(mode="DND", on=True, autoreply="busy"), + ), + ( + "2026-05-05 12:00:00 12345 [INFO Client 1234] : DND mode is now OFF", + AFKStatus(mode="DND", on=False, autoreply=None), + ), + ], +) +def test_parse_afk_event_table(line: str, expected: AFKStatus) -> None: + assert parse_afk_event_line(line) == expected + + +def test_parse_afk_event_returns_none_for_unrelated_line() -> None: + assert parse_afk_event_line("2026-05-05 12:00:00 [INFO Client 1234] : Connected to ...") is None + + +def test_presence_kwargs_afk_on_appends_afk_suffix_and_swaps_small_image() -> None: + li = LevelInfo(username="Alice", base_class="Witch", ascension_class=None, level=10) + ii = InstanceInfo(level=5, area_code="G1_1", area_display_name="Lioneye's Watch", seed=1) + kwargs = PypresencePublisher._build_update_kwargs( + li, + ii, + afk_on=True, + small_image_override="afk", + ) + assert kwargs["small_image"] == "afk" + assert kwargs["state"].endswith("[AFK]") + + +def test_presence_kwargs_afk_off_with_restore_override() -> None: + """OFF with explicit override restores the captured snapshot, not the recomputed default.""" + li = LevelInfo(username="Alice", base_class="Witch", ascension_class=None, level=10) + ii = InstanceInfo(level=5, area_code="G1_1", area_display_name="Lioneye's Watch", seed=1) + kwargs = PypresencePublisher._build_update_kwargs( + li, + ii, + afk_on=False, + small_image_override="witch", + ) + assert kwargs["small_image"] == "witch" + assert "[AFK]" not in kwargs["state"] + + +def test_afk_restore_after_level_during_afk() -> None: + """Snapshot survives a level/ascendency change inside the AFK window. + + 1. Witch lvl 10 → small_image "witch". + 2. AFK ON → snapshot "witch". + 3. Mid-AFK level/ascendency change to Infernalist lvl 11. + 4. AFK OFF → restore must use snapshot "witch", NOT recomputed "infernalist". + """ + state = MutableState() + state.level_info = LevelInfo( + username="Alice", base_class="Witch", ascension_class=None, level=10 + ) + state.instance_info = InstanceInfo( + level=5, area_code="G1_1", area_display_name="Lioneye's Watch", seed=1 + ) + publisher = AsyncMock() + + asyncio.run( + on_afk_changed( + AFKStatusChanged(status=AFKStatus(mode="AFK", on=True)), + publisher=publisher, + current_state=state, + ) + ) + assert state.prior_small_image == "witch" + publisher.publish.assert_awaited_with( + state.level_info, + state.instance_info, + afk_on=True, + small_image_override="afk", + ) + publisher.publish.reset_mock() + + state.level_info = LevelInfo( + username="Alice", + base_class="Witch", + ascension_class="Infernalist", + level=11, + ) + + asyncio.run( + on_afk_changed( + AFKStatusChanged(status=AFKStatus(mode="AFK", on=False)), + publisher=publisher, + current_state=state, + ) + ) + publisher.publish.assert_awaited_with( + state.level_info, + state.instance_info, + afk_on=False, + small_image_override="witch", + ) + assert state.prior_small_image is None + + +def test_afk_on_off_with_no_prior_level_info() -> None: + """AFK ON arrives BEFORE any 'is now level' event — handler must not crash.""" + state = MutableState() + state.instance_info = None + publisher = AsyncMock() + + asyncio.run( + on_afk_changed( + AFKStatusChanged(status=AFKStatus(mode="AFK", on=True)), + publisher=publisher, + current_state=state, + ) + ) + assert state.prior_small_image is None + publisher.publish.assert_awaited_with( + None, + None, + afk_on=True, + small_image_override="afk", + ) + publisher.publish.reset_mock() + + asyncio.run( + on_afk_changed( + AFKStatusChanged(status=AFKStatus(mode="AFK", on=False)), + publisher=publisher, + current_state=state, + ) + ) + publisher.publish.assert_awaited_with( + None, + None, + afk_on=False, + small_image_override=None, + ) diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 4bfcb95..ce1f527 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -5,7 +5,7 @@ from poe2_rpc.domain.events import DomainEvent from poe2_rpc.domain.locations import Location -from poe2_rpc.domain.models import InstanceInfo, LevelInfo +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo from poe2_rpc.domain.ports import ( EventBus, GameDetector, @@ -42,6 +42,9 @@ def parse_local_area_entered(self, line: str) -> str | None: def parse_party_joined(self, line: str) -> str | None: return None + def parse_afk_event(self, line: str) -> AFKStatus | None: + return None + class _ConcretePresencePublisher: async def connect(self) -> None: @@ -51,6 +54,9 @@ async def publish( self, level_info: LevelInfo | None, instance_info: InstanceInfo | None, + *, + afk_on: bool = False, + small_image_override: str | None = None, ) -> None: pass From c44ec7497ef911fd466c9ae615da2821b5adaa08 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" <46745805+PrEvIeS@users.noreply.github.com> Date: Tue, 5 May 2026 18:49:33 +0300 Subject: [PATCH 15/17] feat(tray,autostart): background launcher (pystray + Windows Startup) PR-4 of the upstream campaign. Adds an opt-in tray icon and a Startup- folder shortcut so the orchestrator can run as a background service. Hexagonal changes: - pyproject.toml: new [tray] extras (pystray>=0.19, Pillow>=10, pylnk3>=0.4); matching mypy ignore_missing_imports overrides; project-wide PLC0415 ruff ignore (deferred imports are the architectural extras-gate, not a smell). - domain/ports.py: LogStream Protocol gains close() + is_closed() so the tray Quit thread can unblock the sync for-line loop. - infrastructure/log_stream.py: thread-safe idempotent close() that stops the watchdog observer and posts an empty sentinel via call_soon_threadsafe to wake the queue-bound consumer. - infrastructure/tray.py: TrayController wrapping pystray.Icon with Status/Open log/Restart/Quit menu and a default 64x64 PIL icon when no icon file is supplied. Module-level import gate raises a clear hint to install the [tray] extras. - infrastructure/autostart.py: pylnk3-backed Startup-folder shortcut at %APPDATA%/Microsoft/Windows/Start Menu/Programs/Startup. Idempotent install + boolean uninstall. - application/orchestrator.py: tracks _current_stream, wraps the line loop in try/finally so close() always runs, filters the empty sentinel, and exposes a thread-safe stop() that closes the active stream. - cli.py: tray / install-autostart / uninstall-autostart commands; tray spawns the orchestrator on a background thread and wires Quit to orch.stop() THEN icon.stop() (drain order matters); _SyncLineIterator now forwards close()/is_closed() so cli adapter respects the new Protocol. Tests (+15): - test_orchestrator_stop.py: threaded run + main-thread stop joins within 1s; double-close is idempotent; stop with no active stream is a noop. - test_tray.py: menu wiring, status propagation, quit-callback ordering. - test_autostart.py: install/uninstall idempotency, frozen exe path resolution; uses sys.modules injection so pylnk3 is not required in dev. - test_extras_missing.py: tray + install-autostart commands exit 1 with the pip install hint when extras are absent. Docs: - README.md: new "Run as a background service" section with the explicit pip install poe2-rpc[tray] prereq before poe2-rpc tray, plus the install-autostart / uninstall-autostart pair; flips the matching to-do. Gates: 143 passed (+15), mypy --strict clean, ruff clean, ruff format clean, lint-imports kept (1 contract). Refs: epic panvex-b6p, plan .omc/plans/ralplan-upstream-prs.md --- README.md | 23 +++- pyproject.toml | 18 ++- src/poe2_rpc/application/orchestrator.py | 73 +++++++---- src/poe2_rpc/cli.py | 114 ++++++++++++++++- src/poe2_rpc/domain/ports.py | 2 + src/poe2_rpc/infrastructure/autostart.py | 44 +++++++ src/poe2_rpc/infrastructure/log_stream.py | 17 +++ src/poe2_rpc/infrastructure/tray.py | 84 +++++++++++++ tests/integration/test_orchestrator.py | 12 +- tests/unit/test_autostart.py | 127 +++++++++++++++++++ tests/unit/test_extras_missing.py | 68 ++++++++++ tests/unit/test_orchestrator_stop.py | 146 ++++++++++++++++++++++ tests/unit/test_ports.py | 6 + tests/unit/test_tray.py | 121 ++++++++++++++++++ 14 files changed, 826 insertions(+), 29 deletions(-) create mode 100644 src/poe2_rpc/infrastructure/autostart.py create mode 100644 src/poe2_rpc/infrastructure/tray.py create mode 100644 tests/unit/test_autostart.py create mode 100644 tests/unit/test_extras_missing.py create mode 100644 tests/unit/test_orchestrator_stop.py create mode 100644 tests/unit/test_tray.py diff --git a/README.md b/README.md index ebdc0a3..9850a00 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ CLI commands: `poe2-rpc run` (continuous monitor), `poe2-rpc once` (single log-stream pass), `poe2-rpc validate-config --no-discord` (validate settings + bundled assets without contacting Discord IPC). +### 🖥️ Run as a background service (Windows tray) + +Install the optional tray extras first, then launch the tray icon and +register it with Windows Startup so it boots on login: + +```bash +pip install "poe2-rpc[tray]" +poe2-rpc tray # foreground tray (Status / Open log / Restart / Quit) +poe2-rpc install-autostart # Startup-folder shortcut so it launches on login +poe2-rpc uninstall-autostart +``` + +Notes: + +- The tray runs the orchestrator on a background thread; `Quit` performs an + orderly shutdown of the log-stream watcher. +- The shortcut points at the running interpreter / packaged `.exe` and passes + `tray --quiet` so no console window is spawned at login. +- Use `poe2-rpc tray --quiet` from PowerShell if you launch it manually and + want to suppress the console. + **For convenience, a pre-compiled .exe is available in the releases section. Download the latest release here:** 👉 https://github.com/ezbooz/Path-Of-Exile-2-RPC/releases @@ -31,7 +52,7 @@ Download the latest release here:** ## 🔧 To-Do - [x] Support for custom images (all classes and ascendancies) -- [ ] Launch as background service when game starts +- [x] Launch as background service when game starts (tray + Windows Startup shortcut) - [ ] Add support for the official PoE2 client - [ ] Detect the player who started the script (avoid party conflicts) - [ ] Show AFK status diff --git a/pyproject.toml b/pyproject.toml index 2f7ec43..6cdc924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,11 @@ dev = [ "pyinstaller>=6.14", "types-psutil", ] +tray = [ + "pystray>=0.19", + "Pillow>=10", + "pylnk3>=0.4", +] [project.scripts] poe2-rpc = "poe2_rpc.cli:app" @@ -84,6 +89,10 @@ select = [ ignore = [ "PLR0913", # too-many-arguments — orchestrator legitimately takes many "PLR2004", # magic-value-comparison — readable in tests + "PLC0415", # import-outside-toplevel — required for optional-extras gate + # (cli.py defers `infrastructure.tray`/`autostart` imports inside + # try/except so headless installs don't pay the import cost AND + # extras-missing tests can monkeypatch __import__ per-test) ] [tool.ruff.lint.per-file-ignores] @@ -97,7 +106,14 @@ files = ["src/poe2_rpc", "tests"] plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] -module = ["psutil.*", "pypresence.*", "watchdog.*"] +module = [ + "psutil.*", + "pypresence.*", + "watchdog.*", + "pystray.*", + "PIL.*", + "pylnk3.*", +] ignore_missing_imports = true [tool.pydantic-mypy] diff --git a/src/poe2_rpc/application/orchestrator.py b/src/poe2_rpc/application/orchestrator.py index 2673fd7..80d95a3 100644 --- a/src/poe2_rpc/application/orchestrator.py +++ b/src/poe2_rpc/application/orchestrator.py @@ -67,6 +67,7 @@ def __init__( self._throttle = throttle self._current_state = current_state self._settings = settings + self._current_stream: LogStream | None = None self._subscribe_handlers() def _subscribe_handlers(self) -> None: @@ -113,30 +114,39 @@ def run_once(self) -> None: loop.run_until_complete(self._publisher.connect()) log_path = self._detector.log_path() stream = self._factory(log_path, loop) - for line in stream.lines(): - local_area = self._parser.parse_local_area_entered(line) - if local_area is not None: - self._bus.emit(LocalAreaEntered(area_name=local_area)) - continue - party_name = self._parser.parse_party_joined(line) - if party_name is not None: - self._bus.emit(PartyMemberJoined(name=party_name)) - continue - afk_status = self._parser.parse_afk_event(line) - if afk_status is not None: - self._bus.emit(AFKStatusChanged(status=afk_status)) - continue - level_info = self._parser.parse_level(line) - if level_info is not None: - self._bus.emit(CharacterLevelChanged(level_info=level_info)) - continue - instance_info = self._parser.parse_instance(line) - if instance_info is not None: - location = self._catalog.resolve(instance_info.area_code) - resolved = instance_info.model_copy( - update={"area_display_name": location.display_name} - ) - self._bus.emit(AreaEntered(instance_info=resolved)) + self._current_stream = stream + try: + for line in stream.lines(): + if stream.is_closed(): + break + if not line: + continue + local_area = self._parser.parse_local_area_entered(line) + if local_area is not None: + self._bus.emit(LocalAreaEntered(area_name=local_area)) + continue + party_name = self._parser.parse_party_joined(line) + if party_name is not None: + self._bus.emit(PartyMemberJoined(name=party_name)) + continue + afk_status = self._parser.parse_afk_event(line) + if afk_status is not None: + self._bus.emit(AFKStatusChanged(status=afk_status)) + continue + level_info = self._parser.parse_level(line) + if level_info is not None: + self._bus.emit(CharacterLevelChanged(level_info=level_info)) + continue + instance_info = self._parser.parse_instance(line) + if instance_info is not None: + location = self._catalog.resolve(instance_info.area_code) + resolved = instance_info.model_copy( + update={"area_display_name": location.display_name} + ) + self._bus.emit(AreaEntered(instance_info=resolved)) + finally: + stream.close() + self._current_stream = None except (asyncio.CancelledError, KeyboardInterrupt): _log.info("orchestrator_shutdown") finally: @@ -148,6 +158,21 @@ def run(self) -> None: """Continuous monitor loop — runs until cancelled or interrupted.""" try: while True: + if self._current_stream is not None and self._current_stream.is_closed(): + break self.run_once() + if self._current_stream is not None and self._current_stream.is_closed(): + break except (asyncio.CancelledError, KeyboardInterrupt): _log.info("orchestrator_stopped") + + def stop(self) -> None: + """Thread-safe shutdown signal — called from the tray Quit thread. + + Closes the active stream (if any). The sync ``for line in stream.lines()`` + loop exits cleanly on the next ``is_closed()`` check, then the ``finally`` + block tears down the publisher and event loop. + """ + stream = self._current_stream + if stream is not None: + stream.close() diff --git a/src/poe2_rpc/cli.py b/src/poe2_rpc/cli.py index 1bdc8e7..fe8b41e 100644 --- a/src/poe2_rpc/cli.py +++ b/src/poe2_rpc/cli.py @@ -14,6 +14,10 @@ from __future__ import annotations import asyncio +import os +import subprocess +import sys +import threading from collections.abc import Iterator from pathlib import Path @@ -48,7 +52,7 @@ class _SyncLineIterator: """Adapter: drains WatchdogLogStream's async queue into a sync Iterator[str]. Lives in the composition root because LogStream is a sync Protocol but the - Watchdog adapter speaks asyncio. Owns the start/stop of the observer. + Watchdog adapter speaks asyncio. Owns the start/close of the observer. """ def __init__(self, stream: WatchdogLogStream, loop: asyncio.AbstractEventLoop) -> None: @@ -59,10 +63,18 @@ def __init__(self, stream: WatchdogLogStream, loop: asyncio.AbstractEventLoop) - def lines(self) -> Iterator[str]: try: while True: + if self._stream.is_closed(): + return line = self._loop.run_until_complete(self._stream._queue.get()) yield line finally: - self._stream.stop() + self._stream.close() + + def close(self) -> None: + self._stream.close() + + def is_closed(self) -> bool: + return self._stream.is_closed() def _version_callback(value: bool) -> None: @@ -132,6 +144,104 @@ def once() -> None: orch.run_once() +def _resolve_tray_exe_path() -> Path: + """Pick the executable path the Startup shortcut should target. + + Under PyInstaller ``--onefile``, ``sys.executable`` IS the bundled .exe and + the correct target. In a normal ``pip install`` flow, ``sys.executable`` is + the Python interpreter (Startup would launch ``python.exe`` with no args); + use ``sys.argv[0]`` instead — it points at the installed ``poe2-rpc`` + console script. + """ + if getattr(sys, "frozen", False): + return Path(sys.executable) + return Path(sys.argv[0]) + + +def _open_log_file(settings: AppSettings) -> None: + detector = PsutilGameDetector(settings) + try: + path = detector.log_path() + except Exception: # noqa: BLE001 — tray must not crash on missing game + return + if sys.platform == "win32": + os.startfile(str(path)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", str(path)]) + else: + subprocess.Popen(["xdg-open", str(path)]) + + +def _restart_self() -> None: + """Re-exec the current process with the same argv.""" + os.execv(sys.executable, [sys.executable, *sys.argv]) + + +@app.command() +def tray( + quiet: bool = typer.Option(False, "--quiet", help="Suppress console output."), +) -> None: + """Run orchestrator in background; show tray icon for control.""" + try: + from poe2_rpc.infrastructure.tray import TrayController + except ImportError as e: + typer.secho(str(e), fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from e + + settings = AppSettings() + if not quiet: + configure_logging(settings) + orch = build_orchestrator(settings) + + # orch.run() is sync (closeable-stream design — see ADR). It owns its own + # event loop per run_once() call; no asyncio.run wrapper here. + worker = threading.Thread(target=orch.run, daemon=True) + worker.start() + + tray_controller_holder: list[TrayController] = [] + + def _on_quit() -> None: + orch.stop() + if tray_controller_holder: + tray_controller_holder[0].stop() + + tray_controller = TrayController( + on_open_log=lambda: _open_log_file(settings), + on_restart=_restart_self, + on_quit=_on_quit, + icon_path=None, + ) + tray_controller_holder.append(tray_controller) + tray_controller.run() + + +@app.command(name="install-autostart") +def install_autostart() -> None: + """Create Windows Startup shortcut that boots the tray on login.""" + try: + from poe2_rpc.infrastructure.autostart import install_startup_shortcut + except ImportError as e: + typer.secho(str(e), fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from e + + exe = _resolve_tray_exe_path() + target = install_startup_shortcut(exe, target_args=["tray", "--quiet"]) + typer.echo(f"Installed: {target}") + + +@app.command(name="uninstall-autostart") +def uninstall_autostart() -> None: + """Remove the Windows Startup shortcut if present.""" + try: + from poe2_rpc.infrastructure.autostart import uninstall_startup_shortcut + except ImportError as e: + typer.secho(str(e), fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from e + + removed = uninstall_startup_shortcut() + typer.echo("Removed." if removed else "Nothing to remove.") + + @app.command(name="validate-config") def validate_config( no_discord: bool = typer.Option( diff --git a/src/poe2_rpc/domain/ports.py b/src/poe2_rpc/domain/ports.py index 2adf204..c34acd4 100644 --- a/src/poe2_rpc/domain/ports.py +++ b/src/poe2_rpc/domain/ports.py @@ -23,6 +23,8 @@ def log_path(self) -> Path: ... @runtime_checkable class LogStream(Protocol): def lines(self) -> Iterator[str]: ... + def close(self) -> None: ... + def is_closed(self) -> bool: ... @runtime_checkable diff --git a/src/poe2_rpc/infrastructure/autostart.py b/src/poe2_rpc/infrastructure/autostart.py new file mode 100644 index 0000000..cfbdf92 --- /dev/null +++ b/src/poe2_rpc/infrastructure/autostart.py @@ -0,0 +1,44 @@ +"""Windows Startup-folder shortcut adapter. + +Writes a ``.lnk`` file into ``%APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup`` +so the tray launcher boots on user login. Import-gated on the ``[tray]`` extras. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +try: + import pylnk3 +except ImportError as e: + raise ImportError("Autostart support requires extras: pip install poe2-rpc[tray]") from e + +_SHORTCUT_NAME = "PathOfExile2DiscordRPC.lnk" + + +def _startup_dir() -> Path: + appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + return appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" + + +def install_startup_shortcut(exe_path: Path, target_args: list[str]) -> Path: + """Create or overwrite the Startup-folder shortcut. Idempotent.""" + target = _startup_dir() / _SHORTCUT_NAME + target.parent.mkdir(parents=True, exist_ok=True) + pylnk3.for_file( + target_file=str(exe_path), + lnk_name=str(target), + arguments=" ".join(target_args), + description="Path of Exile 2 Discord RPC (background tray)", + ) + return target + + +def uninstall_startup_shortcut() -> bool: + """Remove the Startup-folder shortcut if present. Returns True iff removed.""" + target = _startup_dir() / _SHORTCUT_NAME + if target.exists(): + target.unlink() + return True + return False diff --git a/src/poe2_rpc/infrastructure/log_stream.py b/src/poe2_rpc/infrastructure/log_stream.py index bf7d74c..1c2d2cf 100644 --- a/src/poe2_rpc/infrastructure/log_stream.py +++ b/src/poe2_rpc/infrastructure/log_stream.py @@ -9,6 +9,7 @@ import asyncio import re +import threading import time from asyncio import AbstractEventLoop, QueueFull from collections.abc import AsyncIterator @@ -68,6 +69,8 @@ def __init__( self._handler = _LogFileHandler(self) self.dropped_non_domain_count: int = 0 self._last_drop_warn_time: float = 0.0 + self._is_closed: bool = False + self._close_lock = threading.Lock() # Seek to EOF on start if log_path.exists(): @@ -82,6 +85,20 @@ def stop(self) -> None: self._observer.stop() self._observer.join() + def close(self) -> None: + """Idempotent, thread-safe close. Safe to call from any thread.""" + with self._close_lock: + if self._is_closed: + return + self._is_closed = True + self._observer.stop() + self._observer.join() + # Wake any pending await on the queue so consumers can observe is_closed(): + self._loop.call_soon_threadsafe(self._queue.put_nowait, "") + + def is_closed(self) -> bool: + return self._is_closed + def _read_new_lines(self) -> None: """Called from the watchdog observer thread. Reads new bytes and schedules enqueues.""" try: diff --git a/src/poe2_rpc/infrastructure/tray.py b/src/poe2_rpc/infrastructure/tray.py new file mode 100644 index 0000000..7463058 --- /dev/null +++ b/src/poe2_rpc/infrastructure/tray.py @@ -0,0 +1,84 @@ +"""System-tray icon adapter wrapping pystray. + +Imported only by the ``tray`` CLI command. Module-level import gate raises a +clear ImportError when the optional ``[tray]`` extras are not installed. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from pathlib import Path +from typing import Any, Literal + +try: + import pystray + from PIL import Image +except ImportError as e: + raise ImportError("Tray support requires extras: pip install poe2-rpc[tray]") from e + +TrayStatus = Literal["waiting", "running", "error"] + + +def _default_icon() -> Any: + """Generate a 64x64 dark-purple PIL Image when no custom icon is provided.""" + return Image.new("RGB", (64, 64), (40, 16, 56)) + + +class TrayController: + """Wraps pystray.Icon with a Status/Open log/Restart/Quit menu. + + Status is updated thread-safely from the orchestrator thread; the icon + itself runs on the main thread (pystray requirement on Windows). + """ + + def __init__( + self, + *, + on_open_log: Callable[[], None], + on_restart: Callable[[], None], + on_quit: Callable[[], None], + icon_path: Path | None = None, + ) -> None: + self._status: TrayStatus = "waiting" + self._on_open_log = on_open_log + self._on_restart = on_restart + self._on_quit = on_quit + self._icon_image = Image.open(icon_path) if icon_path is not None else _default_icon() + self._icon: Any | None = None + self._lock = threading.Lock() + + @property + def status(self) -> TrayStatus: + with self._lock: + return self._status + + def set_status(self, status: TrayStatus) -> None: + with self._lock: + self._status = status + icon = self._icon + if icon is not None: + icon.update_menu() + + def _build_menu(self) -> Any: + return pystray.Menu( + pystray.MenuItem(lambda _i: f"Status: {self.status}", None, enabled=False), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Open log file", lambda _i, _it: self._on_open_log()), + pystray.MenuItem("Restart", lambda _i, _it: self._on_restart()), + pystray.MenuItem("Quit", lambda _i, _it: self._on_quit()), + ) + + def run(self) -> None: + """Blocking — runs the tray icon on the calling (main) thread.""" + self._icon = pystray.Icon( + "poe2-rpc", + self._icon_image, + "PoE2 RPC", + menu=self._build_menu(), + ) + self._icon.run() + + def stop(self) -> None: + if self._icon is not None: + self._icon.stop() diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py index a94d603..a650ae1 100644 --- a/tests/integration/test_orchestrator.py +++ b/tests/integration/test_orchestrator.py @@ -37,9 +37,19 @@ def log_path(self) -> Path: class FakeLogStream: def __init__(self, lines: list[str]) -> None: self._lines = lines + self._closed = False def lines(self) -> Iterator[str]: - yield from self._lines + for line in self._lines: + if self._closed: + return + yield line + + def close(self) -> None: + self._closed = True + + def is_closed(self) -> bool: + return self._closed class FakeLogParser: diff --git a/tests/unit/test_autostart.py b/tests/unit/test_autostart.py new file mode 100644 index 0000000..bec0eb6 --- /dev/null +++ b/tests/unit/test_autostart.py @@ -0,0 +1,127 @@ +"""Tests for Windows Startup-folder shortcut install/uninstall. + +pylnk3 is in the optional ``[tray]`` extras; the tests inject a stub module +into ``sys.modules`` before importing the autostart adapter so the import gate +in ``poe2_rpc.infrastructure.autostart`` succeeds on dev machines without the +extras installed. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def autostart_module(monkeypatch: pytest.MonkeyPatch) -> tuple[object, MagicMock]: + fake_lnk = types.ModuleType("pylnk3") + for_file_mock = MagicMock() + fake_lnk.for_file = for_file_mock # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "pylnk3", fake_lnk) + monkeypatch.delitem(sys.modules, "poe2_rpc.infrastructure.autostart", raising=False) + + import importlib + + module = importlib.import_module("poe2_rpc.infrastructure.autostart") + return module, for_file_mock + + +def test_install_creates_shortcut_in_startup_dir( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + autostart_module: tuple[object, MagicMock], +) -> None: + monkeypatch.setenv("APPDATA", str(tmp_path)) + module, for_file_mock = autostart_module + + exe = Path(r"C:\Tools\PathOfExile2DiscordRPC.exe") + target = module.install_startup_shortcut(exe, target_args=["tray", "--quiet"]) # type: ignore[attr-defined] + + assert target.name == "PathOfExile2DiscordRPC.lnk" + assert target.parent.exists() + for_file_mock.assert_called_once() + _, kwargs = for_file_mock.call_args + assert kwargs["target_file"] == str(exe) + assert kwargs["arguments"] == "tray --quiet" + assert kwargs["lnk_name"] == str(target) + + +def test_uninstall_returns_false_when_shortcut_absent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + autostart_module: tuple[object, MagicMock], +) -> None: + monkeypatch.setenv("APPDATA", str(tmp_path)) + module, _ = autostart_module + assert module.uninstall_startup_shortcut() is False # type: ignore[attr-defined] + + +def test_uninstall_removes_existing_shortcut( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + autostart_module: tuple[object, MagicMock], +) -> None: + monkeypatch.setenv("APPDATA", str(tmp_path)) + module, _ = autostart_module + + startup = tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" + startup.mkdir(parents=True) + shortcut = startup / "PathOfExile2DiscordRPC.lnk" + shortcut.write_text("fake-lnk-bytes") + + assert module.uninstall_startup_shortcut() is True # type: ignore[attr-defined] + assert not shortcut.exists() + + +def test_install_idempotent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + autostart_module: tuple[object, MagicMock], +) -> None: + monkeypatch.setenv("APPDATA", str(tmp_path)) + module, _ = autostart_module + + exe = Path(r"C:\Tools\foo.exe") + target1 = module.install_startup_shortcut(exe, target_args=["tray"]) # type: ignore[attr-defined] + target2 = module.install_startup_shortcut(exe, target_args=["tray"]) # type: ignore[attr-defined] + assert target1 == target2 + + +@pytest.mark.parametrize( + ("frozen", "executable", "argv0", "expected"), + [ + ( + True, + r"C:\Tools\PathOfExile2DiscordRPC.exe", + "irrelevant", + r"C:\Tools\PathOfExile2DiscordRPC.exe", + ), + ( + False, + r"C:\Python311\python.exe", + r"C:\Tools\poe2-rpc.exe", + r"C:\Tools\poe2-rpc.exe", + ), + ], +) +def test_install_autostart_uses_frozen_exe_path( + monkeypatch: pytest.MonkeyPatch, + frozen: bool, + executable: str, + argv0: str, + expected: str, +) -> None: + from poe2_rpc.cli import _resolve_tray_exe_path + + if frozen: + monkeypatch.setattr(sys, "frozen", True, raising=False) + else: + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "executable", executable) + monkeypatch.setattr(sys, "argv", [argv0]) + + assert _resolve_tray_exe_path() == Path(expected) diff --git a/tests/unit/test_extras_missing.py b/tests/unit/test_extras_missing.py new file mode 100644 index 0000000..4661d4e --- /dev/null +++ b/tests/unit/test_extras_missing.py @@ -0,0 +1,68 @@ +"""Tests that tray / install-autostart fail gracefully when extras are absent.""" + +from __future__ import annotations + +import builtins +from typing import Any + +import pytest +from typer.testing import CliRunner + +from poe2_rpc.cli import app + + +def _patch_import_failure( + monkeypatch: pytest.MonkeyPatch, blocked_module: str, message: str +) -> None: + real_import = builtins.__import__ + + def fake_import( + name: str, globals_: Any = None, locals_: Any = None, fromlist: Any = (), level: int = 0 + ) -> Any: + if name == blocked_module or name.startswith(blocked_module + "."): + raise ImportError(message) + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + +def test_tray_command_exits_when_pystray_missing(monkeypatch: pytest.MonkeyPatch) -> None: + import sys + + sys.modules.pop("poe2_rpc.infrastructure.tray", None) + _patch_import_failure( + monkeypatch, + "pystray", + "Tray support requires extras: pip install poe2-rpc[tray]", + ) + + runner = CliRunner() + result = runner.invoke(app, ["tray"]) + + assert result.exit_code == 1 + combined = (result.output or "") + ( + result.stderr if hasattr(result, "stderr") and result.stderr_bytes else "" + ) + assert "pip install poe2-rpc[tray]" in combined + + +def test_install_autostart_exits_when_pylnk3_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import sys + + sys.modules.pop("poe2_rpc.infrastructure.autostart", None) + _patch_import_failure( + monkeypatch, + "pylnk3", + "Autostart support requires extras: pip install poe2-rpc[tray]", + ) + + runner = CliRunner() + result = runner.invoke(app, ["install-autostart"]) + + assert result.exit_code == 1 + combined = (result.output or "") + ( + result.stderr if hasattr(result, "stderr") and result.stderr_bytes else "" + ) + assert "pip install poe2-rpc[tray]" in combined diff --git a/tests/unit/test_orchestrator_stop.py b/tests/unit/test_orchestrator_stop.py new file mode 100644 index 0000000..be2e288 --- /dev/null +++ b/tests/unit/test_orchestrator_stop.py @@ -0,0 +1,146 @@ +"""Tests for sync close-stream design: Orchestrator.stop() unblocks run_once().""" + +from __future__ import annotations + +import asyncio +import threading +import time +from collections.abc import Iterator +from pathlib import Path + +from poe2_rpc.application.bus import AsyncioEventBus +from poe2_rpc.application.handlers import MutableState +from poe2_rpc.application.orchestrator import Orchestrator +from poe2_rpc.application.throttle import PresenceThrottle +from poe2_rpc.domain.locations import Location +from poe2_rpc.domain.models import AFKStatus, InstanceInfo, LevelInfo +from poe2_rpc.domain.ports import LogStream + + +class _BlockingLogStream: + """Yields one line, then blocks on a threading.Event until close() fires.""" + + def __init__(self) -> None: + self._closed = threading.Event() + + def lines(self) -> Iterator[str]: + yield "first-line" + self._closed.wait(timeout=5.0) + + def close(self) -> None: + self._closed.set() + + def is_closed(self) -> bool: + return self._closed.is_set() + + +class _StubSettings: + throttle_window_seconds: float = 0.0 + connect_retry_attempts: int = 1 + publish_retry_attempts: int = 1 + + +class _StubDetector: + def is_running(self) -> bool: + return True + + def log_path(self) -> Path: + return Path("/fake/Client.txt") + + +class _StubParser: + def parse_level(self, line: str) -> LevelInfo | None: + return None + + def parse_instance(self, line: str) -> InstanceInfo | None: + return None + + def parse_local_area_entered(self, line: str) -> str | None: + return None + + def parse_party_joined(self, line: str) -> str | None: + return None + + def parse_afk_event(self, line: str) -> AFKStatus | None: + return None + + +class _StubPublisher: + async def connect(self) -> None: + pass + + async def publish( + self, + level_info: LevelInfo | None, + instance_info: InstanceInfo | None, + *, + afk_on: bool = False, + small_image_override: str | None = None, + ) -> None: + pass + + def close(self) -> None: + pass + + +class _StubCatalog: + def resolve(self, area_code: str) -> Location: + return Location(area_code=area_code, display_name=area_code) + + +def _build_orchestrator(stream: LogStream) -> Orchestrator: + def factory(_path: Path, _loop: asyncio.AbstractEventLoop) -> LogStream: + return stream + + return Orchestrator( + detector=_StubDetector(), + parser=_StubParser(), + publisher=_StubPublisher(), + catalog=_StubCatalog(), + bus=AsyncioEventBus(), + log_stream_factory=factory, + throttle=PresenceThrottle(interval=0.0), + current_state=MutableState(), + settings=_StubSettings(), + ) + + +def test_orchestrator_stop_closes_stream_within_1s() -> None: + """Tray Quit thread → orch.stop() → stream.close() → for-loop exits → join.""" + stream = _BlockingLogStream() + orch = _build_orchestrator(stream) + + worker = threading.Thread(target=orch.run_once, daemon=True) + worker.start() + time.sleep(0.1) + orch.stop() + worker.join(timeout=1.0) + + assert not worker.is_alive(), "stop() did not terminate run_once within 1s" + assert stream.is_closed() + + +def test_log_stream_close_is_idempotent(tmp_path: Path) -> None: + """Double-close (rapid tray quit) must not raise.""" + from poe2_rpc.infrastructure.log_stream import WatchdogLogStream + from poe2_rpc.infrastructure.settings import AppSettings + + log_file = tmp_path / "Client.txt" + log_file.write_text("") + loop = asyncio.new_event_loop() + try: + stream = WatchdogLogStream(log_file, AppSettings(), loop) + stream.start() + stream.close() + stream.close() + assert stream.is_closed() is True + finally: + loop.close() + + +def test_orchestrator_stop_when_no_stream_active_is_noop() -> None: + """stop() before any run_once() must not raise — _current_stream is None.""" + stream = _BlockingLogStream() + orch = _build_orchestrator(stream) + orch.stop() + assert orch._current_stream is None diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index ce1f527..fc40c9b 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -28,6 +28,12 @@ class _ConcreteLogStream: def lines(self) -> Iterator[str]: yield "line" + def close(self) -> None: + pass + + def is_closed(self) -> bool: + return False + class _ConcreteLogParser: def parse_level(self, line: str) -> LevelInfo | None: diff --git a/tests/unit/test_tray.py b/tests/unit/test_tray.py new file mode 100644 index 0000000..93ee771 --- /dev/null +++ b/tests/unit/test_tray.py @@ -0,0 +1,121 @@ +"""Tests for TrayController menu wiring and quit-callback ordering. + +These tests run on POSIX dev machines via mocked pystray + Pillow imports, +so the tray module's import gate is satisfied before TrayController is touched. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def fake_pystray(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Install a fake pystray module before TrayController import.""" + fake = types.ModuleType("pystray") + + class _Menu: + SEPARATOR = object() + + def __init__(self, *items: Any) -> None: + self.items = items + + class _MenuItem: + def __init__(self, text: Any, action: Any = None, enabled: bool = True) -> None: + self.text = text + self.action = action + self.enabled = enabled + + icon_instance = MagicMock() + + def _icon_factory(name: str, image: Any, title: str, menu: Any) -> MagicMock: + icon_instance.name = name + icon_instance.image = image + icon_instance.title = title + icon_instance.menu = menu + return icon_instance + + fake.Menu = _Menu # type: ignore[attr-defined] + fake.MenuItem = _MenuItem # type: ignore[attr-defined] + fake.Icon = _icon_factory # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "pystray", fake) + + fake_pil = types.ModuleType("PIL") + fake_pil_image = types.ModuleType("PIL.Image") + + def _open(_path: Any) -> Any: + return MagicMock(name="opened-image") + + def _new(_mode: str, _size: tuple[int, int], _color: tuple[int, int, int]) -> Any: + return MagicMock(name="generated-image") + + fake_pil_image.open = _open # type: ignore[attr-defined] + fake_pil_image.new = _new # type: ignore[attr-defined] + fake_pil.Image = fake_pil_image # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "PIL", fake_pil) + monkeypatch.setitem(sys.modules, "PIL.Image", fake_pil_image) + + monkeypatch.delitem(sys.modules, "poe2_rpc.infrastructure.tray", raising=False) + return icon_instance + + +def _make_controller(fake_pystray: MagicMock, on_quit: Any = None) -> Any: + from poe2_rpc.infrastructure.tray import TrayController + + return TrayController( + on_open_log=lambda: None, + on_restart=lambda: None, + on_quit=on_quit or (lambda: None), + icon_path=None, + ) + + +def test_menu_items_built_correctly(fake_pystray: MagicMock) -> None: + controller = _make_controller(fake_pystray) + menu = controller._build_menu() + items_with_text = [item for item in menu.items if hasattr(item, "text")] + texts = [item.text(None) if callable(item.text) else item.text for item in items_with_text] + assert texts[0] == "Status: waiting" + assert "Open log file" in texts + assert "Restart" in texts + assert "Quit" in texts + + +def test_status_update_propagates_to_icon(fake_pystray: MagicMock) -> None: + controller = _make_controller(fake_pystray) + controller._icon = fake_pystray # simulate run() + controller.set_status("running") + assert controller.status == "running" + fake_pystray.update_menu.assert_called_once() + + +def test_quit_callback_fires(fake_pystray: MagicMock) -> None: + fired: list[bool] = [] + controller = _make_controller(fake_pystray, on_quit=lambda: fired.append(True)) + menu = controller._build_menu() + quit_item = next( + i for i in menu.items if hasattr(i, "text") and getattr(i, "text", None) == "Quit" + ) + quit_item.action(None, None) + assert fired == [True] + + +def test_quit_callback_invokes_orchestrator_stop_then_tray_stop() -> None: + """Order matters: orchestrator must stop FIRST so the worker thread can drain.""" + orch = MagicMock() + icon = MagicMock() + call_order: list[str] = [] + orch.stop.side_effect = lambda: call_order.append("orch_stop") + icon.stop.side_effect = lambda: call_order.append("icon_stop") + + def on_quit() -> None: + orch.stop() + icon.stop() + + on_quit() + assert call_order == ["orch_stop", "icon_stop"] From 4dd9b551e03f7c45188034e8ab5ccb090a2df310 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" <46745805+PrEvIeS@users.noreply.github.com> Date: Tue, 5 May 2026 19:11:18 +0300 Subject: [PATCH 16/17] docs: mark all 4 README to-do items done; document campaign features in EN/RU/UA The 4-PR upstream campaign (panvex-b6p) is feature-complete on this stacked branch. Updates the user-facing READMEs and CLAUDE.md to reflect the four shipped capabilities: - Official PoE2 client support (Steam + PathOfExile.exe) - Owner auto-pin (party-mate disambiguation) - AFK / DND status with small-image override + restore - Background tray service + Windows Startup shortcut README.md / README.ru.md / README.ua.md - Add explicit Features bullets for all four capabilities. - Flip the To-Do checklist (now 5/5 complete). - RU/UA translations get the new "Run as a background service" section to match the English README that was added during PR-4. CLAUDE.md - Replace the four open-work items with checked-off equivalents and a pointer to the upstream draft PRs (#6, #7, #8, #9). Notes that the remaining work is the end-of-campaign Windows live-smoke pass. --- CLAUDE.md | 15 +++++++++++---- README.md | 14 +++++++++++--- README.ru.md | 37 +++++++++++++++++++++++++++++++++---- README.ua.md | 37 +++++++++++++++++++++++++++++++++---- 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0841e8e..4013ee0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,10 +102,17 @@ Both target the Steam-build log format. Defined in `src/poe2_rpc/infrastructure/ ## Open work (from README) -- [ ] Launch as a background service when the game starts. -- [ ] Support the official PoE2 client (currently Steam-only via the hardcoded `PathOfExileSteam.exe` process name). -- [ ] Detect which player started the script (avoid party-conflict mis-detection). -- [ ] Show AFK status. +All four originally-listed README items have been implemented locally on +stacked feature branches and submitted as draft PRs upstream +(`ezbooz/Path-Of-Exile-2-RPC` #6, #7, #8, #9): + +- [x] Support the official PoE2 client (`PathOfExile.exe` alongside Steam). +- [x] Detect which player started the script (owner auto-pin). +- [x] Show AFK status (with `[AFK]` suffix + small-image override + restore). +- [x] Launch as a background service (pystray tray + Windows Startup shortcut). + +Remaining work: end-of-campaign Windows live-smoke screenshots to flip the +4 upstream draft PRs to ready-for-review. ## See also diff --git a/README.md b/README.md index 9850a00..b485b6a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ Download the latest release here:** - Rich Discord presence for **Path of Exile 2** - Automatically detects character class, ascendancy, zone, and level - Displays an image for each class +- Detects both the **Steam** (`PathOfExileSteam.exe`) and the **official** + (`PathOfExile.exe`) clients +- **Owner auto-pin** so the RPC tracks the player who launched the script, + not whichever party member appears in the log first +- **AFK / DND status** with `[AFK]` suffix and a temporary small-image override + that restores the prior icon when AFK clears +- Optional **system-tray background service** with a Windows Startup-folder + shortcut for autostart on login --- @@ -53,9 +61,9 @@ Download the latest release here:** - [x] Support for custom images (all classes and ascendancies) - [x] Launch as background service when game starts (tray + Windows Startup shortcut) -- [ ] Add support for the official PoE2 client -- [ ] Detect the player who started the script (avoid party conflicts) -- [ ] Show AFK status +- [x] Add support for the official PoE2 client +- [x] Detect the player who started the script (avoid party conflicts) +- [x] Show AFK status --- diff --git a/README.ru.md b/README.ru.md index 2c1e4ba..3f0be56 100644 --- a/README.ru.md +++ b/README.ru.md @@ -18,6 +18,27 @@ CLI-команды: `poe2-rpc run` (непрерывный мониторинг) `poe2-rpc validate-config --no-discord` (проверить настройки и встроенные ассеты без подключения к Discord IPC). +### 🖥️ Запуск как фоновая служба (Windows tray) + +Сначала установите опциональные зависимости для трея, затем запустите +иконку и зарегистрируйте её в Автозагрузке Windows: + +```bash +pip install "poe2-rpc[tray]" +poe2-rpc tray # tray на переднем плане (Status / Open log / Restart / Quit) +poe2-rpc install-autostart # ярлык в Startup, чтобы запускалось при входе +poe2-rpc uninstall-autostart +``` + +Замечания: + +- Tray запускает оркестратор в отдельном потоке; `Quit` корректно + останавливает наблюдатель за лог-файлом. +- Ярлык указывает на текущий интерпретатор / собранный `.exe` и передаёт + `tray --quiet`, чтобы при автозапуске не появлялось окно консоли. +- Используйте `poe2-rpc tray --quiet` из PowerShell, если запускаете + вручную и хотите подавить вывод. + **Для удобства собран `.exe` под Windows — он лежит в разделе Releases. Скачайте последнюю версию здесь:** 👉 https://github.com/ezbooz/Path-Of-Exile-2-RPC/releases @@ -27,16 +48,24 @@ CLI-команды: `poe2-rpc run` (непрерывный мониторинг) - Discord Rich Presence для **Path of Exile 2** - Автоматически распознаёт класс персонажа, аскенденцию, зону и уровень - Показывает иконку для каждого класса +- Поддерживает оба клиента — **Steam** (`PathOfExileSteam.exe`) и + **официальный** (`PathOfExile.exe`) +- **Auto-pin владельца**: RPC отслеживает игрока, запустившего скрипт, + а не первого попавшегося пати-мейта из лога +- **AFK / DND-статус** с суффиксом `[AFK]` и временной подменой + small-image, которая возвращает прежнюю иконку при выходе из AFK +- Опциональная **фоновая служба в трее** + ярлык в Автозагрузке Windows + для автозапуска при входе в систему --- ## 🔧 To-Do - [x] Поддержка пользовательских изображений (все классы и аскенденции) -- [ ] Запуск как фоновая служба при старте игры -- [ ] Поддержка официального клиента PoE2 -- [ ] Определять игрока, который запустил скрипт (избежать конфликтов в пати) -- [ ] Показ AFK-статуса +- [x] Запуск как фоновая служба при старте игры (трей + ярлык в Автозагрузке) +- [x] Поддержка официального клиента PoE2 +- [x] Определять игрока, который запустил скрипт (избежать конфликтов в пати) +- [x] Показ AFK-статуса --- diff --git a/README.ua.md b/README.ua.md index ab33db3..ba9656d 100644 --- a/README.ua.md +++ b/README.ua.md @@ -18,6 +18,27 @@ CLI-команди: `poe2-rpc run` (безперервний моніторин `poe2-rpc validate-config --no-discord` (перевірити налаштування та вбудовані ассети без підключення до Discord IPC). +### 🖥️ Запуск як фонова служба (Windows tray) + +Спочатку встановіть опціональні залежності для трея, потім запустіть +іконку та зареєструйте її в Автозавантаженні Windows: + +```bash +pip install "poe2-rpc[tray]" +poe2-rpc tray # tray на передньому плані (Status / Open log / Restart / Quit) +poe2-rpc install-autostart # ярлик у Startup, щоб запускалося при вході +poe2-rpc uninstall-autostart +``` + +Зауваження: + +- Tray запускає оркестратор в окремому потоці; `Quit` коректно + зупиняє спостерігач за лог-файлом. +- Ярлик вказує на поточний інтерпретатор / зібраний `.exe` і передає + `tray --quiet`, щоб під час автозапуску не з'являлося вікно консолі. +- Використовуйте `poe2-rpc tray --quiet` з PowerShell, якщо запускаєте + вручну та хочете придушити вивід. + **Для зручності зібрано `.exe` під Windows — він лежить у розділі Releases. Завантажте останню версію тут:** 👉 https://github.com/ezbooz/Path-Of-Exile-2-RPC/releases @@ -27,16 +48,24 @@ CLI-команди: `poe2-rpc run` (безперервний моніторин - Discord Rich Presence для **Path of Exile 2** - Автоматично розпізнає клас персонажа, асцендансі, зону та рівень - Показує іконку для кожного класу +- Підтримує обидва клієнти — **Steam** (`PathOfExileSteam.exe`) і + **офіційний** (`PathOfExile.exe`) +- **Auto-pin власника**: RPC відстежує гравця, який запустив скрипт, + а не першого-ліпшого паті-мейта з лога +- **AFK / DND-статус** із суфіксом `[AFK]` і тимчасовою підміною + small-image, що повертає попередню іконку при виході з AFK +- Опціональна **фонова служба у треї** + ярлик в Автозавантаженні Windows + для автозапуску при вході в систему --- ## 🔧 To-Do - [x] Підтримка користувацьких зображень (усі класи та асцендансі) -- [ ] Запуск як фонова служба під час старту гри -- [ ] Підтримка офіційного клієнта PoE2 -- [ ] Визначення гравця, що запустив скрипт (уникати конфліктів у паті) -- [ ] Показ AFK-статусу +- [x] Запуск як фонова служба під час старту гри (трей + ярлик в Автозавантаженні) +- [x] Підтримка офіційного клієнта PoE2 +- [x] Визначення гравця, що запустив скрипт (уникати конфліктів у паті) +- [x] Показ AFK-статусу --- From 44e446e2c05c574f0d608ca77fe5b5251fb1ae49 Mon Sep 17 00:00:00 2001 From: "d.shuvalov" <46745805+PrEvIeS@users.noreply.github.com> Date: Tue, 5 May 2026 19:24:33 +0300 Subject: [PATCH 17/17] docs(agents): refresh hierarchical AGENTS.md after hexagonal migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root AGENTS.md was stamped 2026-05-04 and still described the single-file main.py runtime — completely stale after the G-phase migration to src/poe2_rpc/. Refreshed via the deepinit skill: - Root: dual-form (hexagonal + upstream-form main.py for backports), full layering rules, build/test gate, upstream PR campaign pointer; preserves the BEGIN BEADS INTEGRATION block verbatim. - src/poe2_rpc/AGENTS.md (new): hexagonal package overview + composition root, _SyncLineIterator bridge, optional-extras gate. - src/poe2_rpc/domain/AGENTS.md (new): frozen pydantic VOs, Protocol ports, ascendancy-add workflow. - src/poe2_rpc/application/AGENTS.md (new): orchestrator + bus + throttle + handlers; sync close-stream design. - src/poe2_rpc/infrastructure/AGENTS.md (new): all 11 adapter modules, optional-deps idiom (module-top vs lazy-inside), tenacity split-retry. - tests/AGENTS.md, tests/unit/AGENTS.md, tests/integration/AGENTS.md (new): test layout + AST-guard inventory + Typer CLI surface. All non-root files carry (../../AGENTS.md for src/poe2_rpc/) and a preservation marker. --- AGENTS.md | 115 ++++++++++++++++++++------ src/poe2_rpc/AGENTS.md | 53 ++++++++++++ src/poe2_rpc/application/AGENTS.md | 46 +++++++++++ src/poe2_rpc/domain/AGENTS.md | 48 +++++++++++ src/poe2_rpc/infrastructure/AGENTS.md | 59 +++++++++++++ tests/AGENTS.md | 47 +++++++++++ tests/integration/AGENTS.md | 43 ++++++++++ tests/unit/AGENTS.md | 57 +++++++++++++ 8 files changed, 441 insertions(+), 27 deletions(-) create mode 100644 src/poe2_rpc/AGENTS.md create mode 100644 src/poe2_rpc/application/AGENTS.md create mode 100644 src/poe2_rpc/domain/AGENTS.md create mode 100644 src/poe2_rpc/infrastructure/AGENTS.md create mode 100644 tests/AGENTS.md create mode 100644 tests/integration/AGENTS.md create mode 100644 tests/unit/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index cec4fb0..1346738 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,58 +1,119 @@ - + # Path-Of-Exile-2-RPC ## Purpose -A small, single-script Python utility that provides Discord Rich Presence integration for Path of Exile 2. The tool locates the running `PathOfExileSteam.exe` process, tails its `Client.txt` log, parses level-up and zone-generation events, and pushes a live presence update (character/class/ascendancy/zone) to Discord via `pypresence`. +A Discord Rich Presence integration for Path of Exile 2. Detects the running game process (Steam **and** official client), tails its `Client.txt` log, parses level-up + zone-generation + AFK events, and pushes a live presence update (character / class / ascendancy / zone / AFK status) to Discord via `pypresence`. -The whole runtime is intentionally one file (`main.py`) so it can be packaged into a single Windows `.exe` via PyInstaller in CI. +The runtime is a hexagonal Python package at `src/poe2_rpc/` with `domain/`, `application/`, `infrastructure/`, and `cli.py` (composition root). The Typer app in `cli.py` is the entrypoint, exposed as the `poe2-rpc` console script and `python -m poe2_rpc`. The legacy single-file `main.py` at the repo root is the upstream-compatible form: it carries the same features re-encoded as module globals + inline branches and is the surface used by the `ezbooz/Path-Of-Exile-2-RPC` upstream PRs. + +End users grab a prebuilt Windows `.exe` from GitHub Releases (built by `.github/workflows/build.yml` via PyInstaller `--onefile` against `PathOfExile2DiscordRPC.spec`). ## Key Files | File | Description | |------|-------------| -| `main.py` | Entire application: log discovery, regex parsing, RPC connect, monitor loop. Discord app ID `1315800372207419504`. | -| `locations.json` | Mapping of internal area codes (e.g. `G1_1`) to player-facing zone names (e.g. `The Riverbank`). Loaded from disk if present, otherwise fetched from the GitHub `main` branch on first run. | -| `requirements.txt` | Runtime dependencies: `psutil` (process discovery), `pypresence` (Discord IPC). | -| `README.md` | User-facing install/run instructions. | -| `LICENSE` | MIT license. | -| `.gitignore` | Ignores `.idea/` and `__pycache__/`. | +| `main.py` | Upstream-form single-file runtime (re-encoded hexagonal features as module globals + inline branches). Backport target for `ezbooz/Path-Of-Exile-2-RPC` PRs #6–#9. | +| `pyproject.toml` | Package metadata, dev/tray extras, Typer console script, ruff/mypy/import-linter config. | +| `PathOfExile2DiscordRPC.spec` | PyInstaller `--onefile` spec; bundles `src/poe2_rpc/locations.json`. | +| `locations.json` | Human-edit source-of-truth for area-code → display-name mapping; mirrored into `src/poe2_rpc/locations.json` for packaging. | +| `requirements.txt` | Upstream-form runtime deps; mirrors the runtime subset of `pyproject.toml`. | +| `README.md` / `README.ru.md` / `README.ua.md` | End-user instructions in EN / RU / UA. | +| `CLAUDE.md` | Project instructions for Claude Code. | +| `LICENSE` | MIT. | +| `.gitignore` | Ignores caches, build artifacts, `.omc/state/`, `.idea/`. | ## Subdirectories | Directory | Purpose | |-----------|---------| -| `.github/` | CI workflow + issue templates (see `.github/AGENTS.md`) | +| `src/poe2_rpc/` | Hexagonal package (see `src/poe2_rpc/AGENTS.md`). | +| `tests/` | Unit + integration test suites (see `tests/AGENTS.md`). | +| `.github/` | CI workflows + issue templates (see `.github/AGENTS.md`). | +| `.omc/` | OMC planning artifacts: `.omc/specs/` (deep-interview specs) and `.omc/plans/` (ralplan consensus plans) are tracked; `.omc/state/` is gitignored. | +| `.beads/` | Beads issue-tracker state (`issues.jsonl` is the file-based backup). | ## For AI Agents ### Working In This Directory -- Keep the runtime to `main.py`. The CI build (`.github/workflows/build.yml`) only triggers on changes to `main.py`, and PyInstaller is invoked as `pyinstaller --onefile --name PathOfExile2DiscordRPC main.py`. Splitting code into modules requires updating both the path filter and the PyInstaller call in lockstep. -- The `regex_level` pattern is `: (\w+) \(([\w\s]+)\) is now level (\d+)` and `regex_instance` is `Generating level (\d+) area "([^"]+)" with seed (\d+)`. Both target the literal log format produced by the Steam build of PoE2; verify against a real `Client.txt` sample before changing them. -- When adding a new ascendancy: extend `ClassAscendency` enum (value must match the in-game string exactly), add the entry to `ClassAscendency.get_class()`, and add it to the parent `CharacterClass.get_ascendencies()` list. Reference commit: `fe9c494` ("Add new character classes: Smith of Kitava, Lich, and Tactician"). -- The `small_image` field is derived as `ascension_class.lower().replace(" ", "_")`. Discord asset keys must therefore be lowercase + underscores (commit `5ae14e6` enforced this). Asset names that don't match this convention silently fall back to no image. -- `locations.json` is fetched from `https://raw.githubusercontent.com/ezbooz/Path-Of-Exile-2-RPC/refs/heads/main/locations.json` only when the local file is missing. If you change the schema, ship the updated `locations.json` in the same commit so existing installs upgrade on next launch. -- `monitor_log()` calls `log_file.readlines()` after `seek(0, 2)` and sleeps 5s — a deliberate append-only poll. The cadence matches how PoE2 buffers its log; preserve this approach. -- Process discovery hardcodes `PathOfExileSteam.exe`. Adding support for the official client (see README) means another explicit process-name check, not a regex. +- **Hexagonal layering is non-negotiable.** `lint-imports` enforces `domain ← application ← infrastructure ← cli`. Only `cli.py` may import from `infrastructure`. AST guards in `tests/unit/test_layering.py` and `test_orchestrator_layering.py` back this up. +- **Frozen pydantic v2 VOs in `domain/`** — no `dataclass`, no mutable state. `tests/unit/test_no_mutable_state.py` AST guard fails CI on violations. +- **`mypy --strict` is mandatory.** 4-space indent, type hints on every signature. +- **`structlog` not `logging`.** Use `bind_contextvars(username=, character_class=, area=)` so events carry context through the call graph. +- **`pathlib.Path` + explicit `encoding="utf-8"`** for file I/O. Bundled assets via `importlib.resources.files("poe2_rpc")` — never cwd-relative. +- **Tenacity for retries**, with split policies for connect vs publish (see `src/poe2_rpc/infrastructure/presence.py`). +- **The dual-form (hexagonal + main.py) is intentional.** When changing both, treat the cross-form contract as the state-transition table, not the class hierarchy. See memory `feedback_backport_divergence_pattern.md`. +- **Optional extras pattern:** hexagonal modules import third-party deps at module top behind `try import / except ImportError as e: raise ... from e`. Upstream-form lazy-imports inside helpers. See memory `feedback_optional_deps_backport_idiom.md`. ### Testing Requirements -- No automated test suite. Manual verification: launch the game, run `python main.py`, confirm Discord shows the expected presence; kill/relaunch Discord to exercise `rpc_connect` (5 retries with `time.sleep(2 ** retries)` backoff). -- No linter/formatter is enforced. Match existing style: 4-space indent, type hints on signatures, `logging` over `print`. +The full gate (mirrors CI): + +```bash +pytest tests -ra +mypy --strict src/poe2_rpc +lint-imports +ruff check src tests +ruff format --check src tests +``` + +143 tests pass on `feature/background-launcher`; new code should not lower this baseline. ### Common Patterns -- Log parsers return `Optional[Dict[str, str]]`; callers check truthiness. -- Module-level `logging` (configured at import) is used everywhere instead of `print`. -- File I/O uses `pathlib.Path` and explicit `encoding="utf-8"`. -- Retry loops use exponential backoff `time.sleep(2 ** retries)` (see `rpc_connect`). +- Composition root in `cli.py::build_orchestrator(settings)` wires Protocols → adapters. +- All Typer command callbacks return `None`; errors raise `typer.Exit(code=...)`. +- Optional-deps imports are deferred to inside the function body so headless installs never hit the import. +- Domain events are nouns-in-past-tense (`CharacterLevelChanged`, `AreaEntered`, `AFKStatusChanged`). ## Dependencies +### Internal +- `domain/` — pure VOs, events, Protocols (see `src/poe2_rpc/domain/AGENTS.md`). +- `application/` — orchestration, bus, throttle, handlers (see `src/poe2_rpc/application/AGENTS.md`). +- `infrastructure/` — psutil, watchdog, pypresence, pydantic-settings, structlog, pystray, pylnk3 adapters (see `src/poe2_rpc/infrastructure/AGENTS.md`). + ### External -- `psutil` — iterating processes to find `PathOfExileSteam.exe` and resolve its install directory. -- `pypresence` — Discord IPC client for the Rich Presence API. -- Stdlib: `datetime`, `json`, `logging`, `os`, `re`, `time`, `random`, `pathlib`, `enum`, `urllib.request`. +- `typer` — CLI framework. +- `psutil` — process discovery. +- `watchdog` — `ReadDirectoryChangesW` log tailing. +- `pypresence` — Discord IPC. +- `pydantic` v2 + `pydantic-settings` — VOs, env-var coercion. +- `structlog` — structured logging. +- `tenacity` — split-policy retries. +- `pystray` + `Pillow` + `pylnk3` — optional `[tray]` extra (Windows tray + Startup shortcut). ### Runtime - Discord desktop client must be running and authorized for app ID `1315800372207419504`. -- Path of Exile 2 (Steam build) must be installed and running. +- Path of Exile 2 (Steam **or** official client) must be installed and running. + +## Build & Test + +```bash +pip install -e ".[dev]" +poe2-rpc run # continuous monitor loop +poe2-rpc once # single log-stream pass +poe2-rpc validate-config --no-discord # smoke check (no Discord IPC) + +# Optional tray service (Windows) +pip install "poe2-rpc[tray]" +poe2-rpc tray +poe2-rpc install-autostart +poe2-rpc uninstall-autostart +``` + +Optional config: `%APPDATA%\poe2-rpc\config.toml` on Windows, `~/.config/poe2-rpc/config.toml` on macOS/Linux for cross-platform dev. Defaults work without one — see `src/poe2_rpc/infrastructure/settings.py::AppSettings`. + +## CI / Release flow + +Push to `main` touching `src/**`, `pyproject.toml`, `locations.json`, `PathOfExile2DiscordRPC.spec`, or the workflow → `.github/workflows/build.yml` runs lint+test on `ubuntu-latest`, then build on `windows-latest`. The build job runs `pyinstaller PathOfExile2DiscordRPC.spec`, then deep-smokes the `.exe` with `validate-config --no-discord`, then a cold-start benchmark (continue-on-error: budget breach files a follow-up bd issue, doesn't block release). A timestamp tag (`vYYYYMMDD-HHMMSS`) is created and pushed; the release job uploads `PathOfExile2DiscordRPC.exe` as a GitHub Release asset. + +## Upstream PR campaign + +Four small, sequential PRs against `ezbooz/Path-Of-Exile-2-RPC` carry the same feature work to upstream's single-file form: + +- **#6** Official PoE2 client support (`process_name: list[str]`). +- **#7** Owner detection via auto-pin (`OwnerTracker` re-encoded as module globals). +- **#8** AFK status with `small_image_override`. +- **#9** Background launcher (pystray tray + Windows Startup shortcut). + +All four are open as drafts pending end-of-campaign Windows live-smoke. See memory `project_upstream_pr_campaign.md`. ## Non-Interactive Shell Commands diff --git a/src/poe2_rpc/AGENTS.md b/src/poe2_rpc/AGENTS.md new file mode 100644 index 0000000..d13216a --- /dev/null +++ b/src/poe2_rpc/AGENTS.md @@ -0,0 +1,53 @@ + + + +# poe2_rpc + +## Purpose +Hexagonal package for the Path of Exile 2 Discord Rich Presence integration. The runtime entrypoint is `cli.py` (Typer app, exposed as the `poe2-rpc` console script and `python -m poe2_rpc`). Layering — `domain` ← `application` ← `infrastructure` ← `cli` — is enforced by `import-linter` (`[tool.importlinter]` in `pyproject.toml`); `cli.py` is the only module allowed to import from `infrastructure`. + +## Key Files +| File | Description | +|------|-------------| +| `cli.py` | Composition root. Typer commands (`run`, `once`, `validate-config`, `tray`, `install-autostart`, `uninstall-autostart`); `build_orchestrator(settings)` factory; `_SyncLineIterator` adapter that bridges the async `WatchdogLogStream` to the sync `LogStream` Protocol. | +| `__main__.py` | `python -m poe2_rpc` entrypoint; dispatches to the Typer app. | +| `__init__.py` | Package marker; re-exports `__version__`. | +| `__version__.py` | Single source of truth for the package version (read by `cli.py --version` and CI release tagging). | +| `locations.json` | Bundled mapping of internal area codes → display names. Loaded via `importlib.resources.files("poe2_rpc")` so PyInstaller `--onefile` builds keep working without a runtime URL fetch. | +| `py.typed` | PEP 561 marker — downstream consumers get type info. | + +## Subdirectories +| Directory | Purpose | +|-----------|---------| +| `domain/` | Pure value objects, events, ports — no I/O, no third-party (see `domain/AGENTS.md`). | +| `application/` | Orchestration, event bus, throttle, handlers — Protocols only (see `application/AGENTS.md`). | +| `infrastructure/` | psutil/watchdog/pypresence/pydantic-settings/structlog/pystray/pylnk3 adapters (see `infrastructure/AGENTS.md`). | + +## For AI Agents + +### Working In This Directory +- `cli.py` is the **only** layer permitted to import from `infrastructure`. Don't add infrastructure imports anywhere else — `lint-imports` will fail. +- `_SyncLineIterator` exists because the orchestrator runs synchronously over `for line in stream.lines()` while `WatchdogLogStream` is event-driven via an asyncio queue. Don't async-ify the orchestrator; the sync close-stream design (PR-4) is intentional and required for the tray Quit-shutdown path. +- `__version__` must stay a plain string in `__version__.py`; the CI release job (`build.yml`) reads it with `grep`/`sed`, not `import`. +- New CLI commands go in `cli.py` and must wire optional-extras imports in a `try/except ImportError + typer.Exit(code=1)` block (see `tray` / `install-autostart` for the pattern). + +### Testing Requirements +- `tests/integration/test_cli.py` covers Typer app surface (help, version, validate-config flows). Add an integration test alongside any new command. +- `tests/unit/test_extras_missing.py` covers the optional-extras gate; extend it whenever a new `[tray]`-style extra is introduced. + +### Common Patterns +- Composition root assembles ports → application → handlers via `build_orchestrator(settings)`. +- All Typer command callbacks return `None`; errors raise `typer.Exit(code=...)`. +- Optional-deps imports are deferred to inside the function body so headless installs never hit the import. + +## Dependencies + +### Internal +- Imports `domain.ports` and concrete `infrastructure.*` adapters at composition time. + +### External +- `typer` — CLI framework. +- `structlog` — structured logging (configured via `infrastructure.logging`). +- `importlib.resources` — bundled-asset access. + + diff --git a/src/poe2_rpc/application/AGENTS.md b/src/poe2_rpc/application/AGENTS.md new file mode 100644 index 0000000..9373985 --- /dev/null +++ b/src/poe2_rpc/application/AGENTS.md @@ -0,0 +1,46 @@ + + + +# application + +## Purpose +Orchestration layer: composes domain ports into the runtime pipeline. Imports `domain.*` only — never `infrastructure`. Houses the event bus, throttle, handlers, and the main `Orchestrator` that ties `LogStream → LogParser → EventBus → handlers → PresencePublisher` together. `import-linter` enforces this layering. + +## Key Files +| File | Description | +|------|-------------| +| `orchestrator.py` | `Orchestrator.run_once()` / `run_forever()`; resolves `area_code → display name` via `LocationCatalogPort` between parse and emit; `stop()` signals sync close so the watchdog stream releases. | +| `bus.py` | `InMemoryEventBus` — synchronous pub/sub over typed event classes; handlers register via `subscribe(event_type, handler)`. | +| `throttle.py` | `PresenceThrottle` — Discord-IPC rate-limit guard (default 15s window per `pypresence` recommendation). | +| `handlers.py` | `on_level_changed` / `on_area_entered` / `on_afk_changed` / `on_local_area_entered`; structlog `bind_contextvars` carries `username` / `character_class` / `area` through the call graph. | + +## For AI Agents + +### Working In This Directory +- **Never import from `infrastructure`.** This layer sees Protocols only. The `cli.py` composition root is the only place adapters get instantiated and wired. +- Handler signatures take the event VO + the `PresencePublisher` Protocol — keep them that shape so tests can swap in a `FakePresencePublisher`. +- New events: define the frozen pydantic VO in `domain/events.py`, subscribe a handler here, and let the orchestrator publish via `bus.publish(event)` after parsing. +- The `Orchestrator.stop()` sync close-stream design (PR-4) is intentional: it sets a sentinel on the line iterator so `for line in stream.lines()` returns naturally, allowing the tray-thread shutdown path to exit cleanly. Do not async-ify the orchestrator. + +### Testing Requirements +- `tests/unit/test_orchestrator_layering.py` — AST guard preventing `infrastructure` imports. +- `tests/unit/test_orchestrator_stop.py` — covers the sync close-stream signal. +- `tests/unit/test_bus.py`, `test_throttle.py`, `test_handlers.py` — per-module unit coverage. +- `tests/integration/test_orchestrator.py` — wires real `RegexLogParser` + `InMemoryEventBus` + fakes for I/O ports. + +### Common Patterns +- `bind_contextvars(username=..., character_class=..., area=...)` so structured logs carry pipeline context (AC#7 of the migration). +- Throttle is consulted before every `presence.publish()` call. +- Orchestrator owns the catalog resolution step — handlers receive already-resolved display names. + +## Dependencies + +### Internal +- `domain.ports` — `GameDetector`, `LogStream`, `LogParser`, `PresencePublisher`, `EventBus`, `LocationCatalogPort`, `Settings`. +- `domain.events`, `domain.models` — typed payloads. + +### External +- `structlog` — `bind_contextvars` for pipeline context. +- Stdlib: `time`, `threading`, `typing`, `collections.abc`. + + diff --git a/src/poe2_rpc/domain/AGENTS.md b/src/poe2_rpc/domain/AGENTS.md new file mode 100644 index 0000000..0728717 --- /dev/null +++ b/src/poe2_rpc/domain/AGENTS.md @@ -0,0 +1,48 @@ + + + +# domain + +## Purpose +Pure domain layer: frozen pydantic v2 value objects, runtime-checkable Protocol ports, domain events, and game-domain enums. Zero I/O, zero third-party dependencies beyond pydantic. The `tests/unit/test_no_mutable_state.py` AST guard fails CI if any mutable dataclass or `BaseModel` without `frozen=True` lands here. + +## Key Files +| File | Description | +|------|-------------| +| `models.py` | Frozen pydantic VOs: `LevelInfo` (username, base_class, ascendancy_class, level), `InstanceInfo` (level, area_code, seed). | +| `events.py` | Domain events: `CharacterLevelChanged`, `AreaEntered`, `LocalAreaEntered`, `PartyJoined`, `AFKStatusChanged`. Frozen pydantic models published over the application bus. | +| `ports.py` | Runtime-checkable `Protocol` ports: `GameDetector`, `LogStream`, `LogParser`, `PresencePublisher`, `EventBus`, `LocationCatalogPort`, `Settings`. | +| `locations.py` | `Location` VO + `LocationCatalog.resolve(area_code)` — strips `Map` prefix and splits on `_` for map-tier areas so map-name lookups don't require dictionary entries. | +| `classes.py` | `CharacterClass` / `ClassAscendency` enums; values match in-game strings verbatim (e.g. `"Smith of Kitava"`). | +| `owner.py` | `OwnerTracker` frozen VO + state-machine for auto-pinning the player who launched the script (PR-2 hexagonal form; upstream backport uses module globals). | +| `exceptions.py` | Domain-specific exception hierarchy. | + +## For AI Agents + +### Working In This Directory +- **All VOs must be `frozen=True` pydantic v2 models** — never `dataclass`, never mutable. The AST guard at `tests/unit/test_no_mutable_state.py` enforces this on every test run. +- **No third-party imports beyond pydantic.** No `psutil`, no `pypresence`, no `structlog`, no `pathlib` for file I/O (`Path` as a typed value is fine). +- Adding a new ascendancy: extend `ClassAscendency` enum (value = exact in-game string), update `ClassAscendency.get_class()` mapping, append to the right `CharacterClass.get_ascendencies()` list. Reference commit: `fe9c494`. Upload the matching Discord asset using lowercase + underscore key — `small_image` is derived as `ascension_class.lower().replace(" ", "_")` (commit `5ae14e6`). +- New ports go here as `runtime_checkable` Protocols; concrete adapters in `infrastructure/` implement them. Never import an adapter from this layer. +- New events: frozen pydantic models with explicit field types; published by handlers in `application/`. + +### Testing Requirements +- `tests/unit/test_no_mutable_state.py` — AST guard, runs every CI build. +- `tests/unit/test_models.py`, `test_events.py`, `test_classes.py`, `test_locations.py`, `test_owner.py`, `test_ports.py` — per-module unit coverage. +- `tests/unit/test_log_parser_protocol.py` — Protocol structural-typing checks for `LogParser`. + +### Common Patterns +- Frozen pydantic v2 VOs with explicit field types and no defaults for required fields. +- `runtime_checkable` Protocols so adapters can be duck-typed in tests. +- Domain events are nouns-in-past-tense (`CharacterLevelChanged`, not `ChangeCharacterLevel`). + +## Dependencies + +### Internal +- Imported by `application/` (Protocols + VOs + events) and `infrastructure/` (Protocols only — adapters implement them). + +### External +- `pydantic` v2 — frozen models. +- Stdlib: `enum`, `typing`, `collections.abc`. + + diff --git a/src/poe2_rpc/infrastructure/AGENTS.md b/src/poe2_rpc/infrastructure/AGENTS.md new file mode 100644 index 0000000..2890169 --- /dev/null +++ b/src/poe2_rpc/infrastructure/AGENTS.md @@ -0,0 +1,59 @@ + + + +# infrastructure + +## Purpose +Adapter layer: concrete implementations of `domain.ports` Protocols backed by third-party libraries (psutil, watchdog, pypresence, pydantic-settings, structlog, pystray, pylnk3). The composition root in `cli.py` instantiates these and wires them into the application layer. Adapters import from `domain` for Protocols/VOs but never from `application`. + +## Key Files +| File | Description | +|------|-------------| +| `detection.py` | `PsutilGameDetector` — `is_running()` / blocking `log_path()`; iterates over both `PathOfExileSteam.exe` and `PathOfExile.exe` per the `process_name: list[str]` setting (PR-1). | +| `log_stream.py` | `WatchdogLogStream` — event-driven via `ReadDirectoryChangesW` on Windows, falls back to polling elsewhere; thread-safe enqueue using `loop.call_soon_threadsafe`. Async-native; bridged to sync `LogStream` Protocol via `_SyncLineIterator` in `cli.py`. | +| `parsing.py` | `regex_level` / `regex_instance` (verbatim from `main.py:273-274`) + `RegexLogParser` adapter implementing `LogParser`. | +| `presence.py` | `PypresencePublisher` (`AioPresence` + tenacity split-retry: connect 5×wait_exponential(2,32), publish 3×wait_exponential(1,8)). Holds `small_image_override` for AFK/DND state (PR-3) with restore-on-clear. | +| `settings.py` | `AppSettings` (pydantic-settings `BaseSettings`); `Annotated[list[str], NoDecode]` + `field_validator(mode="before")` to coerce legacy single-string `process_name` envs into a list. | +| `catalog.py` | `load_bundled_catalog()` reads bundled `locations.json` via `importlib.resources.files("poe2_rpc")` so PyInstaller `--onefile` keeps working. | +| `logging.py` | structlog config — `ConsoleRenderer` for dev TTY, `JSONRenderer` for prod. | +| `tray.py` | `pystray` system-tray service (PR-4); runs orchestrator on a background thread; `Quit` triggers `Orchestrator.stop()` for orderly shutdown. Lazy-imports `pystray` / `Pillow` so headless installs don't pay the cost. | +| `autostart.py` | `pylnk3` Windows Startup-folder shortcut writer (PR-4); points at the running interpreter or packaged `.exe` and passes `tray --quiet`. Lazy-imports `pylnk3`. | + +## For AI Agents + +### Working In This Directory +- **Never import from `application`.** Adapters know about Protocols and VOs in `domain`, nothing higher. +- All concrete adapters MUST satisfy a `runtime_checkable` Protocol in `domain/ports.py`. Add the Protocol there first; the test in `tests/unit/test_.py` should `assertIsInstance(adapter, ProtocolType)`. +- Optional-extras pattern: hexagonal modules import third-party deps at module top behind `try import / except ImportError as e: raise ... from e` to fail loudly. The upstream-PR backports re-encode this as lazy-import-inside-helper (see memory `feedback_optional_deps_backport_idiom.md`). +- Tenacity split-retry policies live here, not in application code (presence connect vs publish have different retry budgets — see commit history for context). + +### Testing Requirements +- `tests/unit/test_detection.py` — covers list-of-process-names + legacy-string coercion. +- `tests/unit/test_log_stream.py` — covers the watchdog → asyncio-queue bridge. +- `tests/unit/test_parsing.py` — regex contract tests; **don't break the contracts in `parsing.py:regex_level` / `regex_instance` without verifying against a real `Client.txt` sample** (`tests/integration/test_regex_real_sample.py`). +- `tests/unit/test_presence_connect.py`, `test_presence_publish.py`, `test_afk.py` — split coverage for the two retry policies + AFK override. +- `tests/unit/test_settings.py` — env-var coercion + defaults. +- `tests/unit/test_tray.py`, `test_autostart.py` — lazy-import + extras-missing gate. + +### Common Patterns +- `pathlib.Path` everywhere, with explicit `encoding="utf-8"` on file reads. +- `importlib.resources.files("poe2_rpc")` for bundled assets — never cwd-relative `Path("locations.json")`. +- Tenacity decorators (split policies) over hand-rolled `time.sleep(2 ** retries)`. +- structlog event names are nouns-or-noun-phrases (`presence.publish.success`, `log_stream.line_received`). + +## Dependencies + +### Internal +- `domain.ports`, `domain.models`, `domain.events`, `domain.locations`, `domain.classes`, `domain.exceptions`. + +### External +- `psutil` — process discovery. +- `watchdog` — `ReadDirectoryChangesW` log tailing. +- `pypresence` — Discord IPC (`AioPresence`). +- `pydantic-settings` — `BaseSettings` + env-var coercion. +- `structlog` — structured logging. +- `tenacity` — split-policy retries. +- `pystray`, `Pillow` — optional `[tray]` extra. +- `pylnk3` — optional `[tray]` extra (Windows Startup shortcut). + + diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 0000000..bec9e40 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,47 @@ + + + +# tests + +## Purpose +Test root for the project. Pytest discovery picks up `unit/` (fast, hexagonal-isolated) and `integration/` (Typer CLI surface, real regexes, bundled catalog). The full gate suite — `pytest tests -ra && mypy --strict src/poe2_rpc && lint-imports && ruff check src tests && ruff format --check src tests` — runs in CI and on every release build. + +## Key Files +| File | Description | +|------|-------------| +| `__init__.py` | Marks tests as a package so shared fixtures import cleanly. | +| `conftest.py` | Project-wide fixtures: tmp_path-based fake log file, `FakePresencePublisher`, `FakeEventBus`, settings overrides. | + +## Subdirectories +| Directory | Purpose | +|-----------|---------| +| `unit/` | Per-module unit tests + AST guards (no mutable state, no infrastructure imports from application) — see `unit/AGENTS.md`. | +| `integration/` | Typer CLI surface, bundled-catalog roundtrip, real regex against captured `Client.txt` samples — see `integration/AGENTS.md`. | + +## For AI Agents + +### Working In This Directory +- **Tests are the architecture's enforcement layer.** AST guards in `unit/test_no_mutable_state.py`, `unit/test_layering.py`, `unit/test_orchestrator_layering.py` fail CI if hexagonal contracts drift. Don't disable them — fix the source instead. +- New adapter? Add a Protocol-instance check (`assertIsInstance(adapter, ProtocolType)`) so duck-typing is wired through. +- New feature? Add unit tests for the pure code (parsers, handlers, throttle) and an integration test for the CLI surface. +- Optional-extras gate: any new `[tray]`-style extra needs a row in `unit/test_extras_missing.py` so headless installs surface a friendly `typer.Exit(code=1)` instead of an `ImportError`. + +### Testing Requirements +- Run the full gate locally before pushing: `pytest tests -ra && mypy --strict src/poe2_rpc && lint-imports && ruff check src tests && ruff format --check src tests`. +- 143 tests pass on the current branch; new code should not lower this baseline without an accompanying issue documenting why. + +### Common Patterns +- Frozen pydantic VOs constructed in tests use named kwargs only (positional args are ambiguous and break refactors). +- `tmp_path` fixture for any test touching the filesystem; never write into the repo. +- `monkeypatch.setenv("POE2_RPC_*", ...)` for settings overrides — keep env-var names spelled out, since they're part of the public CLI contract. + +## Dependencies + +### Internal +- Imports `poe2_rpc.*` (via the editable install). + +### External +- `pytest` — runner. +- `pytest-asyncio` — async test support for the watchdog stream tests. + + diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md new file mode 100644 index 0000000..7693596 --- /dev/null +++ b/tests/integration/AGENTS.md @@ -0,0 +1,43 @@ + + + +# integration + +## Purpose +Cross-layer tests that wire real adapters together: real `RegexLogParser` against captured `Client.txt` samples, real `LocationCatalog` against the bundled `locations.json`, and the Typer CLI driven via `typer.testing.CliRunner`. The cold-start benchmark sets the runtime budget for `validate-config --no-discord` (used by the CI deep-smoke step). + +## Key Files +| File | Description | +|------|-------------| +| `test_cli.py` | Typer surface — `--help`, `--version`, `validate-config --no-discord`, optional-extras gating. Source-of-truth for the CLI contract. | +| `test_main_module.py` | `python -m poe2_rpc` dispatches to the same Typer app as the `poe2-rpc` console script. | +| `test_orchestrator.py` | End-to-end pipeline: real `RegexLogParser` + `InMemoryEventBus` + `FakePresencePublisher`; verifies that level-up + area-entered events publish presence in the expected order with throttle applied. | +| `test_bundled_catalog.py` | Bundled `locations.json` loads via `importlib.resources` — guards against PyInstaller `--onefile` packaging regressions. | +| `test_regex_real_sample.py` | Runs `regex_level` / `regex_instance` against a captured `Client.txt` slice; G-1 enforces this once the fixture is available. | +| `test_cold_start.py` | Cold-start benchmark for `validate-config --no-discord`; CI's deep-smoke step compares against this budget. | + +## For AI Agents + +### Working In This Directory +- Adding a new Typer command in `cli.py`? Add an integration test here. The `--help` output and the optional-extras gate are part of the public contract. +- The cold-start benchmark is `continue-on-error` in CI — a budget breach files a follow-up bd issue rather than blocking the release. Don't suppress the comparison; the trend matters. +- Captured `Client.txt` samples should be sanitized (no real Steam IDs, no real character names) and committed under `tests/fixtures/` if added. + +### Testing Requirements +- These tests are slower than unit tests; run them last in your local loop. +- The CLI tests use `typer.testing.CliRunner(mix_stderr=False)` so stderr/stdout assertions stay sharp. + +### Common Patterns +- `CliRunner` invocation pattern: `runner.invoke(app, ["validate-config", "--no-discord"])` then assert on `result.exit_code` and `result.stdout`. +- Fakes for I/O ports come from `tests/conftest.py`, not redefined per file. +- `tmp_path` for any test writing to disk; never the repo. + +## Dependencies + +### Internal +- `poe2_rpc.cli` (Typer app), `poe2_rpc.application.orchestrator`, `poe2_rpc.infrastructure.parsing`, `poe2_rpc.infrastructure.catalog`. + +### External +- `pytest`, `typer.testing.CliRunner`. + + diff --git a/tests/unit/AGENTS.md b/tests/unit/AGENTS.md new file mode 100644 index 0000000..9b1934f --- /dev/null +++ b/tests/unit/AGENTS.md @@ -0,0 +1,57 @@ + + + +# unit + +## Purpose +Fast, isolated tests with no real I/O. Covers domain VOs, application orchestration with fakes, and infrastructure adapters mocked at the third-party seam (psutil, watchdog, pypresence). Includes the AST guards that fail CI if hexagonal contracts drift. + +## Key Files +| File | Description | +|------|-------------| +| `test_no_mutable_state.py` | AST guard — fails if any `dataclass` or non-`frozen=True` `BaseModel` lands in `domain/`. | +| `test_layering.py` | AST guard — fails if `application/` or `domain/` imports from `infrastructure`. | +| `test_orchestrator_layering.py` | AST guard — narrower check on `application/orchestrator.py` specifically. | +| `test_models.py`, `test_events.py`, `test_classes.py`, `test_locations.py`, `test_exceptions.py` | Pure-domain VO/enum coverage. | +| `test_owner.py` | `OwnerTracker` state-machine + auto-pin transitions (PR-2). | +| `test_ports.py`, `test_log_parser_protocol.py` | `runtime_checkable` Protocol structural-typing checks. | +| `test_bus.py`, `test_throttle.py`, `test_handlers.py` | Application-layer unit coverage with fakes. | +| `test_orchestrator_stop.py` | Sync close-stream signal end-to-end (sentinel queue value, idempotent close). | +| `test_parsing.py` | Regex-contract checks against synthesized log lines. | +| `test_detection.py` | `PsutilGameDetector` with mocked `psutil.process_iter`; covers `process_name: list[str]` (PR-1). | +| `test_log_stream.py` | Watchdog → asyncio-queue bridge with a fake observer. | +| `test_presence_connect.py`, `test_presence_publish.py` | Split-retry policy coverage (5×wait_exponential(2,32) connect, 3×wait_exponential(1,8) publish). | +| `test_afk.py` | `small_image_override` set/clear/restore (PR-3). | +| `test_settings.py` | Env-var coercion (`Annotated[list[str], NoDecode]` + `field_validator(mode="before")` for legacy single-string `process_name`). | +| `test_catalog.py` | `load_bundled_catalog()` + `LocationCatalog.resolve()` with map-prefix stripping. | +| `test_logging.py` | structlog config (Console renderer for dev, JSON for prod). | +| `test_tray.py`, `test_autostart.py` | pystray + pylnk3 with the third-party imports patched (PR-4). | +| `test_extras_missing.py` | Optional-extras gate — `pip install poe2-rpc` (no `[tray]`) must `typer.Exit(code=1)` instead of `ImportError`. | +| `test_smoke.py` | One-liner smoke import of `poe2_rpc` to detect packaging breakage. | + +## For AI Agents + +### Working In This Directory +- AST guards (`test_no_mutable_state.py`, `test_layering.py`) are non-negotiable architectural enforcement. Don't add `# noqa` or per-file ignores — fix the source. +- Mock at the **third-party seam**, not the Protocol seam, so we still exercise our adapter glue (e.g. patch `psutil.process_iter`, not `PsutilGameDetector`). +- New domain VO → new test file matching the module name. New port → add a `runtime_checkable` instance check. +- The split-retry tests for `presence` use `tenacity.retry.wait_none()` overrides via `monkeypatch` to keep tests fast. + +### Testing Requirements +- Each test file maps 1:1 to a module under `src/poe2_rpc/`. Adding a new module without a test is a review-blocker. +- Run a focused subset locally with `pytest tests/unit/test_.py -ra`. + +### Common Patterns +- `pytest.fixture` over class-based test setup. +- `monkeypatch.setattr` for third-party patches; keep `unittest.mock` for cases that genuinely need spec-checking. +- `freezegun` (already a dev dep) is used in throttle tests to deterministically advance the clock. + +## Dependencies + +### Internal +- `poe2_rpc.*` modules under test. + +### External +- `pytest`, `pytest-asyncio`, `freezegun`. + +