From 472ade61b95bcc8d32dae995928ab83734fc7ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Wed, 12 Aug 2026 12:16:25 +0200 Subject: [PATCH 1/3] A map is read for the modifiers you decided you cannot take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: Ctrl+Shift+D over a map opens a popup listing every modifier it rolled, with the verdict the profile in use gives each; a click walks a row through safe, dangerous, deadly and back, and a right-click puts it straight back to unrated. ADDED: A one-line outlook leads the popup, reporting the strongest thing true of the map. - One deadly modifier decides it whatever it is outnumbered by. Averaging that away would be exactly the confident wrong answer this codebase exists to avoid. ADDED: Rating tables are profiles, a JSON file each under `/map-profiles/`, made and unmade from Settings, with the directory as the authority over the config's list. ADDED: A Map Check settings tab lists every modifier the bundle's pool publishes, searchable, four verdict buttons to an affix, for the one session where somebody pre-fills the table. ADDED: The search box takes Path of Exile's own item-search syntax — quoted terms, `!` to negate, real regular expressions, `^` and `$` anchored to a printed line — and a `?` beside it says so in six examples. ADDED: A pasted map search string can be turned into verdicts across the whole pool, shown as the rows it would write and saved only on Accept. ADDED: `en-mod-pools.ndjson` and its index load as optional bundle assets behind `has_mod_pools()`, so a bundle published before they existed keeps working. ADDED: `mod_domain` on base types and item classes, and `GameData::mod_domain_for`, which is what says a chart rolls from a different pool than the map it is sailed from. - Ask the base first and its class second: trade files all 491 maps under one proxy row sitting with the stackable currency, so a map's own record states no domain at all. ADDED: `mapcheck_test` and `hotkey_test`. CHANGED: A verdict is keyed on an affix — its whole sorted set of stat `ref`s — rather than on any one wording. - 21 of the pool's wordings sit on more than one affix and 50 affixes grant more than one, so a key per wording cannot tell two decisions apart: rating `Impaling` also rated the more-currency line it shares with fourteen others. - Never on a printed line, which is language-dependent the moment a localised bundle exists. CHANGED: Implicits are rated like anything else instead of being printed above the list. - They were left out on the argument that an implicit is what the base came with rather than what it rolled — true of a Nightmare map saying it is one, false of the Vaal corruption implicits, which roll and which the pool carries. CHANGED: An affix granted by more than one pool is one row, not one per pool, since the verdict key holds no domain and the rows could never disagree. 270 pool entries, 227 rows. CHANGED: A verdict lent by a shorter affix is drawn faintly rather than not at all, so the propagation rule is visible before a map opens; pressing a button still writes only this affix. CHANGED: The profile in use is written the moment it changes, from the popup as well as from Settings. - The selection lived in the live config object that only the Settings Save button commits, so a switch made in the popup was lost on the next launch. Saving that object here would have pushed out a league or an account name still being typed, so the file is re-read and the two map-check fields laid over it. CHANGED: The test bundle slice carries a mod pool, mod domains and the stats behind a map's modifiers. CHANGED: PRIVACY.md lists the profile files and what config.json now holds of them. --- .claude/skills/run-overlay/SKILL.md | 2 + CLAUDE.md | 16 +- CMakeLists.txt | 7 + PRIVACY.md | 3 +- docs/architecture.md | 18 +- docs/data-layer.md | 34 + docs/map-check.md | 477 ++++++++++--- docs/roadmap.md | 18 +- docs/testing.md | 15 + scripts/fetch-glyphs.sh | 13 +- scripts/slice-test-bundle.py | 37 +- src/app.cpp | 348 +++++++++- src/app.hpp | 130 +++- src/config.cpp | 25 + src/config.hpp | 22 + src/data/game_data.cpp | 92 +++ src/data/game_data.hpp | 42 +- src/data/install.cpp | 1 + src/data/manifest.cpp | 1 + src/data/manifest.hpp | 4 + src/data/types.hpp | 52 ++ src/glyph_data.inc | 386 ++++++----- src/mapcheck/filter.cpp | 190 ++++++ src/mapcheck/filter.hpp | 177 +++++ src/mapcheck/rate.cpp | 155 +++++ src/mapcheck/rate.hpp | 117 ++++ src/mapcheck/store.cpp | 168 +++++ src/mapcheck/store.hpp | 95 +++ src/mapcheck/verdict.cpp | 247 +++++++ src/mapcheck/verdict.hpp | 177 +++++ src/platform/input.hpp | 2 +- src/screens/mapcheck_screen.cpp | 428 ++++++++++++ src/screens/mapcheck_screen.hpp | 31 + src/screens/settings_screen.cpp | 348 ++++++++++ src/ui/glyphs.hpp | 12 +- src/ui/strings.cpp | 57 ++ src/ui/strings.hpp | 52 +- tests/data/bundle/en-items-base.index.bin | Bin 104 -> 104 bytes tests/data/bundle/en-items-name.index.bin | Bin 400 -> 400 bytes tests/data/bundle/en-items-ref.index.bin | Bin 400 -> 400 bytes tests/data/bundle/en-items.ndjson | 62 +- tests/data/bundle/en-mod-pools-ref.index.bin | Bin 0 -> 48 bytes tests/data/bundle/en-mod-pools.ndjson | 5 + tests/data/bundle/en-stats-matcher.index.bin | Bin 808 -> 816 bytes tests/data/bundle/en-stats-ref.index.bin | Bin 632 -> 640 bytes tests/data/bundle/en-stats.ndjson | 1 + tests/data/bundle/item-classes.ndjson | 34 +- tests/data/bundle/manifest.json | 3 +- tests/game_data_test.cpp | 100 +++ tests/hotkey_test.cpp | 52 ++ tests/mapcheck_test.cpp | 681 +++++++++++++++++++ 51 files changed, 4566 insertions(+), 371 deletions(-) create mode 100644 src/mapcheck/filter.cpp create mode 100644 src/mapcheck/filter.hpp create mode 100644 src/mapcheck/rate.cpp create mode 100644 src/mapcheck/rate.hpp create mode 100644 src/mapcheck/store.cpp create mode 100644 src/mapcheck/store.hpp create mode 100644 src/mapcheck/verdict.cpp create mode 100644 src/mapcheck/verdict.hpp create mode 100644 src/screens/mapcheck_screen.cpp create mode 100644 src/screens/mapcheck_screen.hpp create mode 100644 tests/data/bundle/en-mod-pools-ref.index.bin create mode 100644 tests/data/bundle/en-mod-pools.ndjson create mode 100644 tests/hotkey_test.cpp create mode 100644 tests/mapcheck_test.cpp diff --git a/.claude/skills/run-overlay/SKILL.md b/.claude/skills/run-overlay/SKILL.md index 9ea8968..dbb8f81 100644 --- a/.claude/skills/run-overlay/SKILL.md +++ b/.claude/skills/run-overlay/SKILL.md @@ -15,6 +15,8 @@ launch it, photograph it, and look. `PPC_DEV_ITEM` is what makes that possible w | `PPC_DEV_OVERLAY=1` | Opens Settings and disables dismiss-on-blur. Required for every dev run. | | `PPC_DEV_ITEM=` | Opens the price-check panel on a captured clipboard instead. | | `PPC_DEV_PASTE=1` | Opens the QuickPaste popup at wherever the pointer is, which is the only way to see it without the game. | +| `PPC_DEV_MAP=` | Opens the map check popup on a captured map instead, at the pointer. Takes precedence over `PPC_DEV_ITEM`. | +| `XDG_CONFIG_HOME=` | Sends `config.json` and the map-check profile tables somewhere scratch. Set it for anything touching map check — otherwise a dev run writes a `Default.json` into the real configuration directory, and a profile with verdicts in it is the only way to photograph a rated popup. | | `PPC_DEV_IDLE=1` | Keeps the idle status marker up (it otherwise shows only while the game is in front). | | `PPC_MANAGED=1` | Lets the window manager manage the window — needed if you want it stackable/movable on a real session. | | `PPC_DEBUG_COPY=1` | Traces the copy timeline to stderr. | diff --git a/CLAUDE.md b/CLAUDE.md index 603912f..473d5bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,10 +21,12 @@ Pipeline: **hotkey → auto-copy → clipboard → parse → identify → price The overlay, Settings, the league list, the static game-data layer, the item layer (parse → resolve → price-relevant numbers → search plan, plus the game-styled tooltip), the trade search, poe.ninja reference pricing, the in-game currency exchange feed, QuickPaste, the bug reporter (with -the relay it posts to) and the binary updater (with the Windows installer it depends on) are all -**built and tested**. +the relay it posts to), the binary updater (with the Windows installer it depends on) and 0.7's +**map check** — the modifier pool, the per-profile verdict tables, the popup and the search-string +import — are all **built and tested**. What is not built is [docs/roadmap.md](docs/roadmap.md) — including the fact that a language other -than English cannot yet be selected, because the data build emits only English. +than English cannot yet be selected, because the data build emits only English, and map check's +"switch profile by watching `Client.txt`", whose Settings checkbox is drawn disabled and says so. Sections of any doc describing an unbuilt layer say so explicitly. Keep them honest. @@ -47,7 +49,7 @@ read whole; each is one layer. | [docs/trade-layer.md](docs/trade-layer.md) | `src/trade/` — query building, the two-step client, the rate limiter, and how results and the filter list are drawn. | | [docs/ninja.md](docs/ninja.md) | `src/ninja/` — the poe.ninja reference price. | | [docs/exchange.md](docs/exchange.md) | `src/exchange/` — GGG's hourly in-game currency exchange digests. | -| [docs/map-check.md](docs/map-check.md) | **Not built.** The design for 0.7 — mod domains, the map modifier pool, and the bundle and data-layer changes it needs first. Read before touching either for map check. | +| [docs/map-check.md](docs/map-check.md) | `src/mapcheck/` and the two screens over it — mod domains, the modifier pool, the per-profile verdict tables, and PoE's item-search syntax as this reads it. | | [docs/localisation.md](docs/localisation.md) | Reading a translated client vs. translating our own text — two unrelated problems, two settings. | | [docs/external-apis.md](docs/external-apis.md) | The endpoints themselves: trade, poe.ninja, currency exchange, and GGG's rate-limit policy. | | [docs/conventions.md](docs/conventions.md) | Comment style, commit and PR shape, the maintainer alias, which docs are public. | @@ -99,6 +101,12 @@ violate one of these on the strength of not having read it. - **A bug report is sent only by a press, and the dialog shows the whole of it first.** Nothing may reach the relay that the preview does not draw, and nothing about a report is gathered in the background. → reporting, PRIVACY.md +- **The verdict store keys on the stat record's `ref`, never on a printed line.** A wording is + language-dependent the moment a localised bundle exists; resolution happens first and the verdict + attaches to what it resolved to. → map-check +- **The mod pool describes; it never gates.** A modifier an item prints and the pool does not + contain is normal — the pool may offer and pre-fill, never reject, hide, or decide that a line + failed to parse. → map-check - **The clipboard is ours to read *and* to write.** `clipboard_set_text` owns the X selection from a thread of its own, because a write on X11 is a promise to answer for the text later. → quickpaste, platform diff --git a/CMakeLists.txt b/CMakeLists.txt index 2da2e7a..36bdc4a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -120,6 +120,10 @@ add_library(ppc_core STATIC src/paths.cpp src/config.cpp src/quickpaste.cpp + src/mapcheck/verdict.cpp + src/mapcheck/filter.cpp + src/mapcheck/store.cpp + src/mapcheck/rate.cpp src/leagues.cpp src/util/sha256.cpp src/util/base64.cpp @@ -187,6 +191,7 @@ set(APP_SOURCES src/ui/range_slider.cpp src/screens/settings_screen.cpp src/screens/quickpaste_screen.cpp + src/screens/mapcheck_screen.cpp src/screens/pricecheck_screen.cpp src/screens/report_screen.cpp src/screens/item_view.cpp) @@ -292,4 +297,6 @@ ppc_add_test(exchange_test) ppc_add_test(ratelimit_test) ppc_add_test(track_test) ppc_add_test(quickpaste_test) +ppc_add_test(mapcheck_test) +ppc_add_test(hotkey_test) ppc_add_test(report_test) diff --git a/PRIVACY.md b/PRIVACY.md index d4d012e..14a607e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -95,7 +95,8 @@ The whole tool works by reading the clipboard, so this is worth being precise ab | path | what | |---|---| -| `/config.json` | your settings: league, hotkeys, panel geometry, listing status, result count, filter ranges, client and interface language, panel opacity, whether to update automatically - **and your QuickPaste entries, in full**, since they are text you typed for this tool to hold | +| `/config.json` | your settings: league, hotkeys, panel geometry, listing status, result count, filter ranges, client and interface language, panel opacity, whether to update automatically, the names of your map-check profiles and which one is in use - **and your QuickPaste entries, in full**, since they are text you typed for this tool to hold | +| `/map-profiles/.json` | one file per map-check profile: which modifiers you marked safe, dangerous or deadly. Written as you rate them, and one empty `Default.json` is created on first run so the feature has somewhere to put a verdict. Nothing here leaves your machine | | `/cookies.txt` | the cookie jar above | | `/data//` | the downloaded game-data bundle, plus a `current` pointer | | `/update/` | a downloaded release of the application, waiting for the restart that applies it. One file, consumed as it is applied; absent whenever no update is pending | diff --git a/docs/architecture.md b/docs/architecture.md index b7e1184..1c0f104 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,9 +9,11 @@ it drives have docs of their own and are read separately: [data-layer.md](data-l [platform.md](platform.md). Pipeline: **hotkey → auto-copy → clipboard → parse → identify → price → render**. `App` (`src/app.cpp`) -owns the SDL event loop and a `Screen` state machine `{ Hidden, PriceCheck, Settings, QuickPaste }` -— the last of which is the paste list and is [quickpaste.md](quickpaste.md), the only screen that -does not involve the copy path at all. Price-check +owns the SDL event loop and a `Screen` state machine +`{ Hidden, PriceCheck, Settings, QuickPaste, MapCheck }` — QuickPaste is the paste list and is +[quickpaste.md](quickpaste.md), the only screen that does not involve the copy path at all, and +MapCheck is [map-check.md](map-check.md), which shares the copy path *whole* and differs only in +what it opens on the far side of it (`App::copy_target_`). Price-check hotkey → `simulate_copy()` → wait for the clipboard to be written → parse → show if it's an item. **Four steps, and they are meant to stay four.** An earlier version grew a pre-copy snapshot, a byte comparison against it, a latching write detector, `SDL_EVENT_CLIPBOARD_UPDATE` as a third @@ -195,7 +197,10 @@ releases even when it finds no game window — an unmatched pair leaks the helpe `App::place_overlay()` gives each screen its own geometry: Settings is a 640×720 dialog centered over the game, the paste popup is sized to its own list and placed at the cursor sampled when its hotkey -fired (see [quickpaste.md](quickpaste.md)), price-check is a **full-height panel docked beside the item's own frame** — right of the +fired (see [quickpaste.md](quickpaste.md)), the map check popup is placed the same way but sized to +an item nothing has laid out yet — so it draws at a generous estimate, reports the height it came +to, and the window follows on the next frame, which is one frame either way for a window that has +just appeared — price-check is a **full-height panel docked beside the item's own frame** — right of the stash if the cursor was in the left half of the game window at hotkey time, left of the inventory if in the right half (`App::cursor_side()`, sampled before the copy; the user has moved on by the time the clipboard lands). Panels straddling the middle — vendor, quest rewards — have no correct answer, @@ -239,7 +244,7 @@ globe's lower half, so that the third line — the one an available update adds glass instead of on the frame. `place_overlay` sizes the window to the text for that screen, so the idle overlay is a 200×48 rectangle rather than a dialog-sized one nothing is drawn into. -**Settings is four tabs** — General, Price check, QuickPaste, Application — between a fixed header (the title +**Settings is five tabs** — General, Price check, QuickPaste, Map check, Application — between a fixed header (the title and the close disc) and a fixed footer (Save). `kTabs` in `settings_screen.cpp` pairs each name with the function that draws it; `App::settings_tab()` holds which one is open, because the screen is a free function rebuilt every frame. The strip is buttons, not `ImGui::BeginTabBar`: the game marks @@ -348,7 +353,8 @@ and on Linux that is an `org.freedesktop.ScreenSaver` inhibit — reason "Playin the life of the process, so an application that sits in the tray all day stopped the machine from sleeping. The game does its own inhibiting; we are a desktop app. `PPC_DEV_OVERLAY=1` opens Settings and disables dismiss-on-blur for local dev; add `PPC_DEV_ITEM=` to open the price-check panel on a captured -clipboard instead, `PPC_DEV_PASTE=1` to open the paste popup at the pointer, or `PPC_DEV_IDLE=1` to keep the idle status marker up (it otherwise only ever +clipboard instead, `PPC_DEV_MAP=` to open the map check popup on a captured map at the +pointer, `PPC_DEV_PASTE=1` to open the paste popup at the pointer, or `PPC_DEV_IDLE=1` to keep the idle status marker up (it otherwise only ever appears while the game is the window in front). `PPC_DEV_UPDATE_URL=` points the update check at a `latest.json` of your own, which is the only way to see its three notice surfaces before a release publishes one — see [updater.md](updater.md). `PPC_REPORT_URL=` points the bug diff --git a/docs/data-layer.md b/docs/data-layer.md index 626ff37..f6178fa 100644 --- a/docs/data-layer.md +++ b/docs/data-layer.md @@ -31,6 +31,40 @@ downloaded at runtime from **[JIRPOS/PathOfPriceCheck-Data](https://github.com/J bundle-level signal saying whether `BaseType::exchange` means anything, because unlike a whole missing file an absent boolean cannot tell "no data" from "no". `install` writes it only when non-zero, since a 0 would claim the opposite of what it means. See the currency-exchange section. + `en-mod-pools.ndjson` and its index are optional in the same way `en-unique-mods.ndjson` is — + see the mod-pool section. `source.mod_pools` is written through beside the other two so the + installed bundle records what the build produced, but it is **not** what gates anything: a + whole missing file says that already, and `has_mod_pools()` reads the index. +- **The mod pools** are the one thing here that does not start from an item in hand. + `en-mod-pools.ndjson` is, per **mod domain**, every modifier that domain can spawn — the whole + set, whether or not anything is holding one. It exists so a modifier can be rated in Settings + before it has ever been rolled; see [map-check.md](map-check.md), which owns the feature. + **The pool describes and never gates.** What it lists is what spawns *naturally*, which is + strictly less than what an item can print: an essence, a craft, a veiled mod or Harvest all put + modifiers on an item whose weights would never have produced them, and the published list is + trimmed by naming conventions besides. A printed modifier no entry covers is normal, renders as + it always did, and is rateable on the spot. Nothing may use a pool to reject a line, hide one, + or decide it failed to parse. + One record — `PoolMod` — is one **wording-set**, not one roll: the tiers of an affix print the + same wordings and a verdict attaches to a wording, so they collapse, and `min`/`max` span the + lowest tier's floor to the highest tier's ceiling in displayed units. `tiers` and `mods` are + provenance for the debug log. A `PoolStat` with no `trade_id` is the ordinary case and not a + gap — the pool is rated, not searched, and a wording trade indexes under two hashes is one the + build refuses to pick between, here as everywhere else. + Two lookups, because the feature needs both directions: `mod_pool(domain)` is the whole pool, + for the settings list, filled by one pass over the file the first time it is asked for (a few + hundred records, and a pool is only ever wanted entire); `find_pool_mods(domain, wording)` goes + the other way, from a wording resolved off an item, through an index keyed on + `"{domain}::{wording}"`. **The domain is part of the key**, because a map and a chart are + separate pools that word 42 modifiers identically, and an answer mixing them would offer a + chart's affix for a map. + **Which domain an item rolls from is `mod_domain_for(base, item_class)`, not + `BaseType::mod_domain`.** The base is asked first and its class answers where it cannot, and + that fallback is not a nicety: trade lists all 491 maps under one entry whose game row is a + *stand-in* sitting with the stackable currency in domain 43, so a map's own record deliberately + states no domain at all and the `Maps` class is what knows the answer is 5. The other way round + would be wrong — a class holding genuinely different things (Jewels covers two domains) + publishes none, and only a base can answer for those. - **`data/lexicon`** is every word the *client* prints, for one language: the section labels (`Item Class`, `Rarity`, `Requirements`, `Sockets`, `Note`), the flag lines, the rarity and influence names, the mod-type suffixes and Advanced Mod Descriptions generation words, the diff --git a/docs/map-check.md b/docs/map-check.md index 05db3e2..958125f 100644 --- a/docs/map-check.md +++ b/docs/map-check.md @@ -1,11 +1,10 @@ -# Map check (not built) +# Map check -**Nothing in this document is built.** It is the design for [ROADMAP.md](../ROADMAP.md)'s 0.7 and -for the two layers underneath it that have to move first — the data bundle and the app's data -layer. Sections become the layer's own documentation as they ship; until then read every sentence -as intent. +**Built**, all five phases of the order of work at the foot — [ROADMAP.md](../ROADMAP.md)'s 0.7. +This document is both the design and the record of what the building changed about it; where a +section describes something that was measured rather than reasoned, it says so. **[roadmap.md](roadmap.md)'s constraints for this version come first**, and this document is written under them rather than beside them. Where the two disagree, that one wins. @@ -14,12 +13,103 @@ The feature: a hotkey reads a map's rolled modifiers and says which ones you dec take. The verdicts live in a table meant to fill in by being used — rate what the popup shows, on the spot. Nothing about the feature requires knowing what a map *could* have rolled. -**The store keys on the stat record, never on the printed line's `placeholder_form`.** A wording is +## The shape of it, from the outside + +**Ctrl+Shift+D** over a map. It shares the whole copy path with the price check and parts company +only once there is an item — one flag, `App::copy_target_`, consumed in `poll_pending_copy`. The +popup opens at the cursor, exactly as the paste list does and through the same placement code. + +The **one gate** is `is_map_device_item`: nothing else opens the popup, and an item that fails it +is dropped in silence like any other check that finds nothing. It is not a data question but an +item one — a ring's modifiers resolve to stats as a map's do, and without the gate they would be +rated into a map profile with nothing to stop them. + +The popup, top to bottom: the **outlook** (below), the name plate and the map's own numbers laid +out across the panel rather than one to a line, the profile in use, and then one row per modifier +with its verdict. A click walks a row through the four states; a right-click puts it straight back +to unrated, which is otherwise three clicks away from deadly and is the one a misclick needs. + +**Nothing is split and nothing is merged.** A hybrid modifier keeps both its lines in one row, and +two modifiers wording the same thing stay two rows. The item printed them that way and the popup is +a reading of the item. + +### The outlook + +One line at the top, which is what the popup is read at a glance for. `mapcheck::assess` is the +whole of it and is tested headless; the order it checks in is the order of what is strongest: + +| | when | colour | +| --- | --- | --- | +| a deadly modifier | any at all | red | +| more than half rated safe | | green, and a second sentence when some are unrated | +| half or more unrated, nothing worse than safe under it | | neutral, question mark | +| more safe than dangerous | | yellow | +| as many dangerous as safe, or more | | orange | + +A deadly modifier decides the map on its own, whatever it is outnumbered by: averaging that away +would be exactly the confident wrong answer this codebase is built to avoid. A row that resolved to +no stat counts as **unrated** rather than being left out — it is a modifier on the map the reader +has not decided about, and dropping it would make a map of unreadable lines look fully rated. + +**Implicits are rated like anything else**, and this is a reversal. They were printed above the +list and left alone on the argument that an implicit is what the base came with rather than what it +rolled — true of a Nightmare map saying it is one, and false of the Vaal corruption implicits, +which roll, and which the pool carries as generation 5. The rule bought simplicity and paid for it +with the inconsistency it admitted to in the same breath: a wording rateable in Settings and inert +on the map in front of you. So an implicit is a row, it counts in the tally, and it carries a +verdict. It keeps the game's duller blue so the reader can still see which is which — pulled back +towards the modifier colour rather than left where it was, because a row that resolved to nothing +is grey and the old tint was two shades off it. + +## The verdict store — **built** + +**The store keys on the stat records, never on the printed lines' `placeholder_form`.** A wording is language-dependent the moment a localised bundle exists, and `find_stat` already refuses to guess between two records that share one. So resolution happens first and the verdict attaches to what it -resolved to — which also means a modifier printing several stats carries a verdict per stat, since -that is what the store can key. The pool below is grouped by modifier for reading; the store -underneath it is not. +resolved to — as a **set**, because the thing being rated is an affix and an affix can grant several +stats. See "A verdict is keyed on the affix" below for why a verdict per wording was tried and does +not work. + +**A profile is a file**, `/map-profiles/.json`, and the name *is* the file name — +`sanitize_profile_name` substitutes what a filesystem refuses rather than dropping it, so `a/b` and +`ab` stay two profiles. `config.json` records the names and their order, but **the directory is the +authority**: `Store::open` reconciles the two, so a table dropped in by hand appears and a name +whose file has gone is dropped. That is also what makes creating a profile safe without saving +Settings first — the file is written the moment the dialog closes. + +**Which profile is in use is remembered the moment it changes**, from the popup as readily as from +Settings, and this had to be built rather than assumed: the selection lived in `config_` and +`config_.save()` is the Settings **Save** button's alone, so a switch made in the popup — which has +no such button — was lost on the next launch. It cannot simply call `save()` either, because the +Settings screen edits that same live object and would push out a league or an account name still +being typed. So `persist_map_profile` re-reads the file, lays the two map-check fields over what is +already there, and writes that. Creating and deleting go through it too, which is what makes the +config's ordering keep up with the directory rather than waiting for a Save. + +**Under an auto-load rule, a switch by hand is temporary instead.** When the profile is decided by +the character being played, picking another is a look at a second table rather than a new +preference: it stands until the next time a screen that rates opens, and `apply_auto_profile` — +called on the way into the popup and into Settings, both through `set_screen` — puts the +character's own back. `auto_profile()` is what decides, and it returns nothing today, so every +selection is the user's own to keep. Reading `Client.txt` is 0.7's "might" and is not built, which +is what the checkbox is disabled for; the rest of the rule is written now so that landing it is a +function body rather than a design. + +There is **always at least one profile**. An empty directory is given `Default` at startup, and +deleting the last one puts it back: a verdict is only ever put into a table, so a popup opening +with no table is one where every click silently does nothing. Deleting one at all is behind a +confirmation that says how many ratings go with it — it is a few hundred decisions and a +thirty-pixel button. + +The file format **parses an optional roll bound from day one and shows no UI for it**, per +[roadmap.md](roadmap.md): reading accepts both the bare word and the object form, writing uses the +bare word since that is every row this version can produce. Accepting both shapes costs one branch +now and a format migration costs every user's file later. + +Ratings are **buffered and written 1.5 seconds after the last one**, and flushed outright whenever +a screen that can rate closes, when a profile is switched, when a bulk import is accepted, and on +the way out. The throttle is a ceiling on batching, never on durability — walking one modifier +through all four states is one write, and nothing outlives the process unwritten. What a **pool** buys is the one thing that cannot fill in by use: pre-filling. A searchable list of every modifier that exists, so a user can rate ones they have not met yet, or paste a regex and @@ -88,11 +178,12 @@ Domain 5's generation types say the same thing from the other side: 1 and 2 are Vaal corruption implicits, 23 `EXPEDITION_LOGBOOK`, 36 `MEMORY_ALTAR`, 8 `TEMPEST` (legacy), 3 the fixed ones including `IsUberMap` and `MapZanaInfluenced`. -One thing to fix while here: the bundle resolves an invitation to its **`Quest Items`** row -(`Metadata/Items/MapFragments/…/Quest…`, domain 43) while the clipboard prints +One thing to fix while here — **fixed**: the bundle resolved an invitation to its **`Quest +Items`** row (`Metadata/Items/MapFragments/…/Quest…`, domain 43) while the clipboard prints `Item Class: Misc Map Items`, which is the *other* row of the same name — domain 5, and the one -that carries the tags. Two bases share each invitation's name and the emit picks the wrong one. -Harmless today; not harmless once the base decides which pool is shown. +that carries the tags. Thirteen names are shaped that way (the nine Maven ones and the four +Eldritch), the emit picked the wrong one, and the map device now outranks the quest item in the +liveness rule that decides. **The three map variants, precisely.** @@ -138,7 +229,7 @@ knowingly imperfect. So: - The pool may be used to *offer* and to *pre-fill*. It may never be used to reject, to hide a printed modifier, or to decide that a line failed to parse. -## What the bundle gains +## What the bundle gained — **built** One change in [the data repo](https://github.com/JIRPOS/PathOfPriceCheck-Data) — a separate repository and therefore a separate change set — and one decision that keeps it to an emit. @@ -157,40 +248,90 @@ answer "can this base roll that", which nothing in 0.7 does. The one thing borrowed from that idea is **list hygiene, by mod id**: entries whose every mod row matches `CorruptedSideArea` (a Vaal side area's own modifiers, which never print on anything the -user can copy) or `Map2Tier` (legacy map series) are left out of the emit. Roughly 52 of 207 -wording-sets. This is a naming convention rather than data, it is allowed to be imperfect, and the -cost of a mistake either way is one row in a searchable list. +user can copy) or `Map2Tier` (legacy map series) are left out of the emit. 55 wording-sets, and +five more go with them under a second rule the build found rather than designed: an entry whose +every wording carries GGG's own **`[DNT]`** marker (a Sirus modifier and the expedition chest +counters) is developer content the client does not show — the one case where the data says +outright that nothing prints. This is a naming convention rather than data, it is allowed to be +imperfect, and the cost of a mistake either way is one row in a searchable list. **2. Emit what is already fetched.** `Mods.Domain`, `Mods.GenerationType` and `BaseItemTypes.ModDomain` are all downloaded today and dropped at emit time. -- **`domain` on base-type records**, straight from `BaseItemTypes.ModDomain`. One integer, exact, - no ambiguity — this is how the app knows a Map is `AREA` and a chart is 39 without a compiled-in - name list. +- **`domain` on base-type records**, straight from `BaseItemTypes.ModDomain`. One integer, exact + for a base that is one — and **the "Map" record is not one**, which is the correction this + section needed most. Trade lists all 491 maps under a single entry whose game row is + `Metadata/Items/TradeProxy/MapKey`, a stand-in sitting with the stackable currency in domain 43. + Emitting that would tell the app every map rolls from the currency pool. So a trade proxy (21 + rows) states **no** domain, and **`domain` on item-class records** answers instead, emitted only + where every base of that class agrees on one: 75 of 86 classes, including all 511 rows of + `Maps`. Ask the base first, its class second, and nothing is ever guessed — a class holding + genuinely different things (Jewels covers two domains) publishes none, and only a base can + answer for those. - **A new optional asset, `en-mod-pools.ndjson`**, plus an fnv1a32 index over each entry's normalized wordings, built with the same machinery as `en-stats-matcher.index.bin`. Named for the general case and carrying its `domain` per record, because the settings page below is - pool-agnostic and flasks, abyss jewels and idols are the same shape. Seed it with domain 5 and - domain 39; nothing else, until something asks. -- **`source.mod_pools`** in the manifest, written only when non-zero, so "no data" is - distinguishable from "no pool for this domain" — the same reasoning as `source.exchange_items` - in [data-layer.md](data-layer.md). + pool-agnostic and flasks, abyss jewels and idols are the same shape. Seeded with domain 5 and + domain 39; nothing else, until something asks. **The index key is `{domain}::{wording}`**, not + the wording alone: a map and a chart word 42 modifiers identically and are separate pools, so an + answer mixing them would offer a chart's affix for a map. +- **`source.mod_pools`** in the manifest, written only when non-zero. Unlike + `source.exchange_items`, which it was modelled on, it is **not** what gates anything: this is a + whole file, so its absence already says "no data" the way `en-unique-mods.ndjson`'s does, and + `has_mod_pools()` reads the index. It is written through so the installed bundle records what + the build produced. + +**Which generation types.** The 207 wording-sets counted above are domain 5's prefixes and +suffixes, which is the pool this design is sized around — but the scope decision at the top puts +logbooks, invitations and charts in it too, and those roll from generation types of their own. The +emit takes every type a player **rolls**: prefixes and suffixes, the Vaal corruption implicits, +the legacy Tempest set, and what an expedition logbook, a memory altar and a chart's voyage grant. +It leaves out domain 5's generation 3 — the fixed implicit a base simply has, 545 wordings nobody +rolls and nobody would rate. Domain 39's single generation-3 row is kept, because "Voyage Modifier +will be revealed once Charted" is what an unsailed chart prints *instead of* the modifier, so it +is the only rateable thing on one. **270 entries** over 897 mod rows: + +| domain 5 | | domain 39 | | +| --- | --- | --- | --- | +| 1 prefix | 83 | 1 prefix | 17 | +| 2 suffix | 73 | 2 suffix | 17 | +| 5 Vaal corruption implicit | 13 | 3 the promise of a voyage modifier | 1 | +| 8 Tempest / Eclipse (legacy) | 8 | 37 voyage | 10 | +| 23 expedition logbook | 15 | | | +| 36 memory altar | 33 | | | One record is one **wording-set**, not one mod row, because tiers collapse: ```json -{"domain": 5, "gen": 1, "name": "Hungering", "tiers": 1, - "mods": ["MapUberModDrowningOrbs"], - "stats": [{"ref": "Area contains Drowning Orbs", - "trade": "explicit.stat_25225034", "min": null, "max": null}]} +{"domain": 5, "gen": 2, "name": "of Impedance", "tiers": 3, + "mods": ["MapMonstersHinderOnHitMapWorlds", "MapMonstersHinderOnHitMapWorldsMaven", + "MapMonstersHinderOnHitMapWorldsExpedition"], + "stats": [{"ref": "Monsters have #% chance to Hinder on Hit with Spells", + "trade": "explicit.stat_962720646", "min": 100, "max": 100}]} ``` `name` is `Mods.Name`, the affix name the client prints with Advanced Mod Descriptions on, and it -is free. `mods` is provenance, for a debug log that has to explain itself. `min`/`max` span the +is free — absent where the rows disagree is not a case that arises, but where they *do* disagree +(7 sets, "Twinned" and "of Twinning" wording the same thing as a prefix and a suffix) the most +common one wins and the build reports the count, because the name is decoration and the wording +under it is not. `mods` is provenance, for a debug log that has to explain itself, and it lists +every row behind the wording including a side-area twin that shares it. `min`/`max` span the lowest and highest tier **in displayed units with `dp` applied**, exactly as `unique_mods` already emits ranges — `Mods.dat` stores hundredths and milliseconds raw and leaving that to the client is -a silent factor of 100. The ranges are not decoration: they are what lets a pasted regex be tested -against a rendered line rather than against a placeholder. +a silent factor of 100. They are absent together for a wording that prints no number, which a +reader must not confuse with bounds it failed to parse. The ranges are not decoration: they are +what lets a pasted regex be tested against a rendered line rather than against a placeholder. + +**A stat entry with no `trade` id is the ordinary case here**, not a gap — 85 of 371 wordings. +Trade indexes many map affixes under no hash at all, and where it indexes one under two the build +refuses to pick, exactly as everywhere else. The pool is rated, not searched, so it costs the +entry nothing. + +One thing fixed on the way through, because it was in the path: a description's `[id|Label]` +markup is now rendered to the label **before** the wording is looked up. Trade indexes the printed +form, so the markup was what stood between such a wording and its stat record — `Rare Monsters +have [PhysicalThorns|Physical Thorns] reflecting # Physical Damage` reaches a real trade id now, +as do 12 pool wordings and 10 that had been carrying the markup into `en-unique-mods.ndjson`. **Deliberately not done: a domain set on stat records.** It was measured. 643 wordings are rendered by more than one description block; domain sets separate only 47 of them, 335 overlap and @@ -211,72 +352,226 @@ rediscovered as a symptom of this work. ## What the app gains -**`src/data`.** `en-mod-pools.ndjson` and its index load exactly as the optional datasets already -do, with a `has_mod_pools()` gate mirroring `has_unique_mods()` / `has_unique_bases()` — a bundle -published before this asset existed must keep working, which is most of why the gate exists rather -than a null check. `BaseType` gains `mod_domain`. Neither addition may pull SDL, ImGui, X11 or -libcurl into `ppc_core`; the pool is data and is testable headless. - -**The verdict store.** A table per character profile of stat → verdict, keyed as above and -persisted beside the existing settings. The verdicts are the roadmap's and the roadmap's wording -sticks: **safe**, **dangerous**, **deadly**, and **unrated** as the zero state — not a fourth choice -a user picks but the absence of one, which is why a row can be drawn as unrated and why the table -grows by being used. The file format **parses an optional roll bound from day one and shows no UI -for it**, per [roadmap.md](roadmap.md): accepting both shapes costs one branch now and a format -migration costs every user's file later. It is a **new file on disk**, so it is a change to -[PRIVACY.md](../PRIVACY.md) as much as to the code — that document enumerates every file written, -and it is the one that goes stale silently. Write both in the same change. - -**A pool browser in Settings, built pool-agnostic from the start.** One flat searchable list of -every entry in the pool, rated and unrated together, with the rating control on the row. Two things -make a long list usable and both are cheap: a **search box**, and **rated entries sorted to the -top** so the part the user has an opinion about is the part they see first. - -The page is expected to be **rarely opened**. The table is meant to fill in by playing — rate what -the popup shows you, on the spot — and the settings list exists for the one session where someone -sits down to pre-fill it, usually by pasting a regex. Design accordingly: it is a bulk-editing -tool, not the primary way anything gets rated. - -Parameterise it by domain now rather than hard-coding the map list. The same page serves 246 flask -mods, 511 abyss jewel mods or 552 idol mods later at no extra cost, and retrofitting a hard-coded -list into that is the expensive order. This is the largest independent piece of UI in 0.7 and -depends on no map logic at all. - -**Seeding from a pasted map regex.** [roadmap.md](roadmap.md) settles the shape of this and it is -worth restating only because it is the part most easily got wrong: the pasted string is **PoE's -search syntax, not one regex** — quoted terms, a leading `!` for negation, space-separated terms -ANDed, bare trailing terms (`"!a|b|c" pte`). Tokenize first, hand each *term* to the engine. -Feeding the whole string to one is how the `!` ends up matched literally. And **match against a -rendered wording, never `placeholder_form`**: a term like `\d+ e` was written against printed item -text and can never match a `#`. - -This is where the pool's `min`/`max` earn their place. With a map in hand the printed lines are -right there, but seeding from the whole list needs each entry rendered with something in the -placeholder — and a term that can only match a number is one this cannot honestly resolve, so it -says so rather than guessing. **The import proposes and the user confirms; nothing writes a verdict -the user has not seen.** - -Two smaller semantics are left, and they are the doc's to settle rather than the roadmap's: whether -an entry counts as hit when **any** of its wordings matches (a modifier can print two to four -lines), and whether the affix-name line is in scope, since the in-game search sees it when Advanced -Mod Descriptions is on. +**`src/data` — built.** `en-mod-pools.ndjson` and its index load exactly as the optional datasets +already do, with a `has_mod_pools()` gate mirroring `has_unique_mods()` / `has_unique_bases()` — a +bundle published before this asset existed keeps working, which is most of why the gate exists +rather than a null check. `BaseType` gained `mod_domain` and `ItemClass` gained one too. Nothing +in it pulls SDL, ImGui, X11 or libcurl into `ppc_core`; the pool is data and is tested headless. + +Three entry points, and [data-layer.md](data-layer.md) owns them now: + +- `mod_pool(domain)` — the whole pool, for the settings list. One pass over the file the first + time it is asked, memoised; a few hundred records, and a pool is only ever wanted entire. +- `find_pool_mods(domain, wording)` — the other direction, from a wording resolved off an item, + through the `{domain}::{wording}` index. Empty is normal and never a gate. +- `mod_domain_for(base, item_class)` — which pool the item in hand rolls from, base first and its + class second. **Use this, not `BaseType::mod_domain`**, for the trade-proxy reason above: a + map's own record states no domain at all. + +**The pool browser in Settings — built, and pool-agnostic.** `map_check_tab` reads +`mapcheck::kDomains` and nothing else knows the number 5, so the same page serves 246 flask mods, +511 abyss jewel mods or 552 idol mods the day one is published. + +The page is **rarely opened** by design. The table fills in by playing; this exists for the one +session where somebody sits down to pre-fill it. So the row carries **four buttons rather than a +click that cycles** — the popup does the cycling, because there the target is the modifier and the +rows are few; here the job is putting a particular entry into a particular state and one click to +any of them is worth the chrome. + +**A verdict is keyed on the affix — its whole set of wordings — not on any one wording.** 21 of +the pool's wordings sit on more than one affix (`Monsters cannot be Stunned` is granted by +`Unwavering` and by `of the Juggernaut`) and 50 affixes grant more than one, so a verdict per +wording cannot tell two decisions apart: rating `Impaling` also rated the `#% more Currency found +in Area` it shares with fourteen other affixes, and they all changed on screen untouched. The key +is the sorted set of stat `ref`s, and a shorter key speaks for the affixes that contain it only +until they are rated in their own right, when the longer key wins for being the more particular +statement. A one-wording affix keys as that wording alone, so a table written before this reads +correctly rather than needing a migration; the file now stores each row as a `mods` array, because +a set is not something a JSON object can be keyed by and joining them would make a hand-editable +file unreadable. + +**One affix is one row, however many pools grant it.** A map and a chart word 42 modifiers +identically and roll them from pools of their own, so `Resistant` arrives as two entries differing +only in range — `10-25` chaos on a map, `0-40` on a chart. The verdict key is the sorted ref set +with no domain in it, so those two can never hold different verdicts: one click lit both, and the +list was showing 39 decisions as 82 rows. `pool_groups` collapses them, **270 entries to 227 rows**, +and the row draws the entry from the first domain in `kDomains` — a map's, which is what the reader +is nearly always deciding about. A search is still asked about every entry in the group, so a term +naming a number hits if either pool's range would print it. + +Putting the domain into the key instead was the alternative and was rejected: it is a format change +to every profile file, and it would make rating a map's `Resistant` stop speaking for a chart's, +which is not a distinction anybody asked for. + +**And the propagation is drawn, not just obeyed.** A row whose verdict is lent by a shorter key +lights that verdict faintly rather than showing nothing — three strengths of lit on the row: solid +for a verdict set here, brighter for one a pending proposal would write, faint for one inherited, +with a tooltip on the faint one saying where it came from. The rule was invisible before, and a +rule the user cannot see is one they discover by being surprised on a map. Pressing a button still +writes this affix's own verdict and nothing else, so no control ever moves except by being pressed +— which was the whole argument for showing `exact()` alone, and it survives intact. + +Grouping a map's printed lines back into affixes is what **Advanced Mod Descriptions** supplies — +the parser marks the second and later stats of one affix `continuation`. Every map capture we hold +has it on. Without it each line stands alone, which still rates every single-wording affix and is +why this degrades rather than fails. + +**Known gap.** An affix can grant stats the item never prints — `#% more Currency / Maps / Scarabs +found in Area` is on every Nightmare-map modifier and appears in none of 62 captures. `rate` asks +`pool_refs_for` to turn what a map printed into the pool entry's full set, and it takes the +smallest entry covering those lines; where an ordinary and a Nightmare affix share a name and the +Nightmare one prints no more than the ordinary one does, that resolves to the ordinary entry. So a +verdict set on the Nightmare `Oppressive` is not read back off a Nightmare map. Ordinary affixes +are unaffected. The fix is a discriminator the item does carry — a Nightmare modifier prints no +`(Tier: N)` — and it is not built. + +Rated entries are **not** sorted to the top, which the design asked for. The search box turned out +to be the whole of what makes the list usable and a list that reorders itself under a click being +used to rate things is worse than one that does not. + +## Seeding from a pasted map search string — **built** + +**GGG publishes no grammar, but the community has written one down.** The reference is the wiki's +[Guide:Regex](https://www.poewiki.net/wiki/Guide:Regex), and it agrees with the convention +[roadmap.md](roadmap.md) fixed before any of this was written: an unquoted space is a logical AND, +double quotes group a term containing spaces, and a leading `!` negates — inside the quotes or +outside them, since generators write `"!a|b"` and players type `!"a b"`. Two things the wiki settles +that the roadmap had only assumed: **quotes group, they do not escape**, so a `|` inside them is +still an alternation and not a literal; and **every search field in the game takes the same syntax**, +which is why a string kept for the stash tab works here. + +**The terms really are regular expressions** — `\d+ e` is a digit run before a space and an `e`, and +`ll damage$` anchors to the end of a printed line. What is not a regex is the *string*: the quotes, +the spaces and the `!` are the syntax holding the patterns apart, and handing the whole of it to one +engine is how those end up matched as literal text. So the string is tokenized first and each term +goes to `std::regex` as it stands, in its ECMAScript dialect. The game's is a custom engine, so the +two can disagree on a corner — lookbehind is the known one, ECMAScript having none — and where they +do it costs a *proposed* verdict the user is about to accept or reject anyway. + +**A term that will not compile hits nothing**, and this was the other way round first. Falling back +to the literal text a broken pattern is made of let one box serve a pasted regex and somebody typing +two plain words — at the price of the box being two search languages at once, with nothing on screen +saying which one a given term had got: `Damage (` found a substring and `Damage (Fire|Cold)` found a +pattern. One language. An unfinished pattern showing an empty list is what the game's own box does, +and `\(` is what a player who meant the bracket writes. + +The `?` beside the search box says all of this in six examples, because the syntax is the game's own +and somebody who does not already know that will not go looking for a paragraph to tell them. + +**Two semantics the design had left open, and the wiki decides one of them:** + +- **Any wording hits, and each is tested on its own.** Not the lines joined: the wiki documents `^` + and `$` as anchoring to a *printed line*, so a join would put that anchor somewhere no term's + author has ever seen. This was built on that reasoning before the reference was read; it is no + longer a judgement call. +- **The affix name is in scope.** With Advanced Mod Descriptions on it is a line of the tooltip the + game's own search reads, so a term naming one was written knowing it is matchable. + +**A term that asks about the item is set aside.** The syntax has keywords — `ilvl:84`, +`"rarity: rare"`, `ts:`, `"item level: 78"` — and they are questions no modifier wording can answer. +Left in the AND, one of them empties the list and never says why: `ilvl:84 monster` matched **0 of +270** entries before this and matches 136 after it. They are recognised **by shape, not by a list of +the keywords**: a term is item-scope when it opens with `:`, and no wording in the published +pool contains a colon at all. The bare keywords are deliberately left as literal text, because the +wiki's own list is partial and the words are real modifier wordings — `currency` alone appears in 17 +of them and `corrupted` in two, so honouring them as keywords would silently swallow the searches +most worth typing. The row under the search box says how many terms were set aside. + +**And one the design did not see coming: filtering and proposing cannot read the terms the same +way.** The game ANDs them because it is deciding about a whole *map*, where each term can be +answered by a different modifier on it. The subject here is one modifier, and a modifier cannot +satisfy two unrelated wanted terms at once — so `classify` asks each term separately (which is also +what ROADMAP.md promises: "every modifier an excluding term hits is proposed dangerous, every one a +wanted term hits proposed safe") while `matches`, which narrows the list, keeps the game's rule so +that typing two plain words means both of them. Two methods, two jobs, and the header says which +is which. Both read the same lines: every wording the affix prints, at both ends of its range, and +its name. + +The wiki's own worked example is the clearest case of why. It advises writing a search in CNF — +`"oj|r at|m el|r damage$|poss|ra c|haz" "fier$"` is *any of these seven affixes* **and** *an +additional modifier* — because that is the only form the game's AND can express. Run over the pool +that string filters to **0 of 270**, correctly: no single modifier is both. Asked term by term it +proposes **21 safe**, which is what its author meant by it. + +This is where the pool's `min`/`max` earn their place: the wording is rendered into printed text +before a term meets it, because a term like `\d+ e` was written against a tooltip and can never +match a placeholder. A wording with no bounds prints no number at all and is left alone. + +**Both ends of the range, and the wording the game would actually print.** The rule started as "the +top of the range", on the grounds that these strings are written to catch the roll that ends a map. +It was wrong twice over. + +A stat record carries alternative wordings, and one may be flagged `negate`: the same stat said the +other way round, for a roll below zero. `Players have #% more Defences` rolls `[-30, -25]`, so the +game *always* prints `Players have 30% less Defences` — while the pool rendered `-25% more +Defences`, a line no player has ever seen and no search term was ever written against. **16 of the +pool's wordings are in that position**, and they are disproportionately the ones a hardcore string +is about: `of Miring`, `of Smothering`, `of Rust`, `of Impotence`, `of Fatigue`, `of Imprecision`, +`of Congealment`, `Hexwarded`, `of Revolt`. It was found by a search string whose `s def` term +silently caught nothing; with `printed_wording` consulting the record's matchers, the same string +now names 24 modifiers instead of 23, and the settings list shows the wording the reader is rating. + +And once a wording can be negated, "the top of the range" stops meaning anything — the top of +`[-30, -25]` prints as the *smallest* number that wording can say. So both ends are rendered, and a +term naming a number hits if any roll would say it. + +**This never touched a verdict.** The popup keys on the stat record's `ref`, and the matcher +resolves the printed `less Defences` line back to that same `ref`, so a rating made in Settings has +always been read correctly off a real map. What was broken was finding the modifier and being shown +what it says — which for a screen whose whole job is *rate this wording* is bad enough. + +**A proposal is per affix, and getting there took two wrong answers.** A pool entry is an *affix*, +and an affix can grant several stats at once: `Protected` is elemental resistance, physical damage +reduction, chaos resistance **and** `#% more Maps found in Area`. + +The first answer rated the whole entry on one term's hit and wrote the verdict **per wording**, so +`ter e` matching the resistance line marked more-maps-found deadly on its own — deadly thereafter on +every map that rolls it, from any affix at all. Over the roadmap's example string that is 38 +wordings rated where 23 were named. + +The second answer kept the per-wording key and asked `classify` once per stat, which fixed that +example and left the real fault standing: a verdict keyed on one wording is a **one-element key**, +and the propagation rule below says a key that short speaks for every affix whose wordings contain +it. Accepting a proposal therefore reached across the pool exactly as far as before — the same bug, +now arriving through the rule that was supposed to be the feature. + +The key is the affix, so the proposal is too. `classify` is asked once, about every line the entry +prints and its name, and what it decides is written under the entry's whole sorted set. `Protected` +becomes one four-wording row; `#% more Maps found in Area` on its own is untouched, and stays +untouched on the fourteen other affixes granting it. This is also what makes the preview honest: +the rows the list lights are the keys Accept writes, one for one. + +**The import proposes and the user confirms.** Pressing the button writes nothing: the list becomes +the proposal's own rows, each lit with the verdict it would get, under a bar saying how many of each +and an Accept that is the only thing which writes. It lights the individual wordings it would rate, +so the preview is literally what Accept writes. Measured against +the roadmap's own example string over the published pool: **23 deadly** out of 270 entries, 23 +wordings rated. + +**And the limit worth stating.** A term can be aimed at item text that is not a modifier at all — +the rarity line, `Corrupted`, the map's name — and against a pool of modifier wordings it will +either miss or hit something by accident. In that same example string the trailing `pte` proposes +two modifiers safe, both because `Corrupted` contains those letters; the term was plainly written to +find corrupted *maps*. Nothing can tell that apart from a term that meant it, which is the whole +argument for the import proposing rather than writing. ## Order of work Each phase is shippable and reversible on its own, and the first two are the "pre-fire" the feature sits on. -1. **Data repo.** Emit `domain` on base records, fix the invitation base-row pick, emit - `en-mod-pools.ndjson` + index + manifest count. No new columns are fetched, so this is an emit +1. **Data repo — done.** Emit `domain` on base records, fix the invitation base-row pick, emit + `en-mod-pools.ndjson` + index + manifest count. No new columns are fetched, so this was an emit change and a data release. The app ignores assets it does not know, so it ships safely ahead of - any app change — and that property should be confirmed against the current release rather than - assumed. -2. **App plumbing, no visible feature.** Load and gate the pool, add `BaseType::mod_domain`, extend - the test bundle slice per [testing.md](testing.md). Nothing on screen changes. -3. **Settings: the pool browser and the verdict store**, with `PRIVACY.md`. Visible, usable, and - inert without the hotkey — a user can rate modifiers before anything reads the ratings. -4. **The feature.** Hotkey, popup, worst-verdict-leads, rate-on-the-spot, profiles. -5. **Regex import**, last, because it is the part the roadmap marks *might*. + any app change. Two things the plan did not have: a trade proxy states no domain and its item + class answers instead, and the `[DNT]`/markup handling above. +2. **App plumbing, no visible feature — done.** Load and gate the pool, add + `BaseType::mod_domain`, extend the test bundle slice per [testing.md](testing.md). Nothing on + screen changes. +3. **Settings: the pool browser and the verdict store — done**, with `PRIVACY.md`. +4. **The feature — done.** Hotkey, popup, worst-verdict-leads, rate-on-the-spot, profiles. +5. **Search-string import — done**, rather than deferred: the same matching the list's own filter + box needs, so the button on top of it was the cheap half. ## Open, and needing an answer rather than a guess diff --git a/docs/roadmap.md b/docs/roadmap.md index b9d82ab..6e1e2fa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -49,7 +49,10 @@ One rule the plan did not have and the code now does: nine slots is a limit on t so storage is unbounded, `enabled` is what competes for a number, and the ceiling is enforced on load as well as in the UI because `config.json` is hand-editable. -### 0.7, map check +### 0.7, map check — **built** + +Every constraint below was honoured; what building it changed about the design, and the two +semantics it had left open, are in [map-check.md](map-check.md). - Reuse the price-check copy path whole, and the map strategy's existing parse and resolve. → [strategy-map.md](strategy-map.md) @@ -60,10 +63,15 @@ load as well as in the UI because `config.json` is hand-editable. threshold and neither should the UI. The store still *parses* an optional roll bound from day one, because a reader that accepts both shapes costs one branch and a format migration costs everyone's file. Do not build UI for it. -- **Importing a map regex is PoE's search syntax, not a regex**: quoted terms, a leading `!` for - negation, space-separated terms ANDed, and bare trailing terms - (`"!a|b|c" pte`). Tokenize that first, then hand each term to the engine — feeding the whole - string to one is how the `!` ends up matched literally. +- **A map search string is PoE's search syntax *around* regexes, not one regex**: quoted terms, a + leading `!` for negation, space-separated terms ANDed, and bare trailing terms (`"!a|b|c" pte`). + Each term genuinely is a regular expression — `\d+ e` and `ll damage$` mean what they look like, + anchor included — so tokenize the string first and hand every term to the engine as it stands. + Feeding the whole string to one engine is how the `!`, the quotes and the term boundaries end up + matched as literal text. The wiki's + [Guide:Regex](https://www.poewiki.net/wiki/Guide:Regex) is the written reference and confirms + all of it; it also documents keywords (`ilvl:`, `"rarity: rare"`, `ts:`) that ask about the item + and therefore cannot be asked of a modifier. - **Match against a rendered wording, never `placeholder_form`.** A term like `\d+ e` was written against printed item text and can never match a `#`. With a map in hand the printed lines are right there; seeding from the full known-mod list needs the wording rendered with something in diff --git a/docs/testing.md b/docs/testing.md index 2284793..d78bf87 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,6 +32,12 @@ changing `assets/popc_icon.png`, run `./scripts/gen-icon-data.sh` (rewrites `src All three write plain byte arrays through `scripts/bin2c.py`, and [architecture.md](architecture.md) says why they are not compressed. +`XDG_CONFIG_HOME` is worth setting for any dev run that touches map check: the rating tables are +files under `/map-profiles/`, and a run with `PPC_DEV_MAP` writes a `Default.json` into the +real configuration directory otherwise. Pointing it at a scratch directory is also how a profile +with verdicts already in it gets in front of the popup, since there is no way to click one from a +script. + `-fsanitize=address,undefined` for debug builds is not wired into CMake yet; pass it by hand: ```sh @@ -105,6 +111,15 @@ which is the whole of what covers an unidentified unique: the gloves are the cas can settle and the Riveted Boots above are the one the app takes for itself. The pair is also what covers `en-items-base.index.bin` at all, since nothing else in the fixture reads it. +The **five mod-pool entries** are chosen for the reader rather than for any item: a wording that +prints no number (so no bounds at all, which must not read as bounds that failed to parse), one +wording shared by the map pool and the chart pool under a trade id they agree on (which is the +whole case for a domain-qualified index key), a modifier printing two wordings of which only one +carries a range, an entry whose wording trade indexes under two hashes and which therefore carries +no id at all, and a corruption implicit, whose hash is in the implicit namespace. Two of their +wordings are in `STATS` as well, so the pool-to-stat join is covered; `Area contains many Totems` +is there for that and for nothing else. + `tests/data/exchange/digest.json` is a slice of one real hourly digest, and every market in it is there to be dropped or kept for a stated reason: the chaos/divine pair (the rate, read from both sides), an Allflame ember whose ratio counts move on *both* sides (which is what proves the band diff --git a/scripts/fetch-glyphs.sh b/scripts/fetch-glyphs.sh index acc0ce9..bf97510 100755 --- a/scripts/fetch-glyphs.sh +++ b/scripts/fetch-glyphs.sh @@ -10,11 +10,14 @@ set -euo pipefail ver="6.7.2" url="https://github.com/FortAwesome/Font-Awesome/releases/download/$ver/fontawesome-free-$ver-web.zip" -# f00c check (confirm), f0e2 arrow-rotate-left (reset), f0fe square-plus (add), -# f304 pen (edit), f2ed trash-can (delete), f7a4 grip-lines (drag to reorder), -# f002 magnifying-glass (search), f08e arrow-up-right-from-square (open in browser), -# f188 bug (report a bug). -codepoints="U+F00C,U+F0E2,U+F0FE,U+F304,U+F2ED,U+F7A4,U+F002,U+F08E,U+F188" +# f00c check (confirm, and a safe modifier), f0e2 arrow-rotate-left (reset), +# f0fe square-plus (add), f304 pen (edit), f2ed trash-can (delete), +# f7a4 grip-lines (drag to reorder), f002 magnifying-glass (search), +# f08e arrow-up-right-from-square (open in browser), f188 bug (report a bug), +# f071 triangle-exclamation (a dangerous modifier), f714 skull-crossbones (a deadly +# one), f128 question (an unrated one), f0d0 wand-magic (propose verdicts from a +# search string). +codepoints="U+F00C,U+F0E2,U+F0FE,U+F304,U+F2ED,U+F7A4,U+F002,U+F08E,U+F188,U+F071,U+F714,U+F128,U+F0D0" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" dest="$root/assets/fonts" diff --git a/scripts/slice-test-bundle.py b/scripts/slice-test-bundle.py index 84285f1..3f1463e 100755 --- a/scripts/slice-test-bundle.py +++ b/scripts/slice-test-bundle.py @@ -72,8 +72,13 @@ # wording — which is what the clipboard prints and what the fixture must key on. "Map contains Baran's Citadel\n" "Item Quantity increases amount of Rewards Baran drops by 20% of its value", - # And a map affix, which that plan must leave out without calling it unrecognised. + # And a map affix, which that plan must leave out without calling it unrecognised. It is + # also half of what covers the mod pool: the same wording is in the map's pool and in the + # chart's, under one trade id, which is what a domain-qualified index key is for. "Monsters have #% chance to Hinder on Hit with Spells", + # A pooled modifier that prints no number at all, so its entry carries no bounds and the + # reader has to tell that apart from bounds it failed to read. + "Area contains many Totems", # A blighted map's implicit, both halves of it — the plan searches every implicit a map # has, so leaving one out of the fixture would show up as an unrecognised modifier. "Area is infested with Fungal Growths\n" @@ -288,6 +293,19 @@ "Bound Fate", ] +# Keyed on the first of each entry's mod ids, which is stable and is what the debug log names. +# Between them these cover every shape the reader has to get right: a wording with no number, +# one shared by two domains, a modifier printing two wordings of which only one carries a +# range, an entry whose wording trade indexes twice and so carries no id at all, and a +# corruption implicit, whose hash is in the implicit namespace rather than the explicit one. +MOD_POOLS = [ + "MapTotems", + "MapMonstersHinderOnHitMapWorlds", + "MapDeepwaterChartMonstersHinderOnHit", + "MapDeepwaterChartMonsterCannotBeStunned", + "MapCorruptionItemQuantity", +] + ITEM_CLASSES = ["Rings", "Boots", "Gloves", "Body Armours", "Stackable Currency", "Divination Cards", "Jewels", "Utility Flasks", "Maps", "Skill Gems", "Support Gems", "Chart", "Misc Map Items", "Contracts", "Blueprints", @@ -368,6 +386,15 @@ def main() -> int: write_index(out / f"{LANG}-unique-mods-name.index.bin", [(f"UNIQUE::{r['name']}", off) for (_, r), off in zip(uniques, offsets)]) + pools = pick(read_ndjson(src / f"{LANG}-mod-pools.ndjson"), MOD_POOLS, + lambda r: r["mods"][0]) + offsets = write_ndjson(out / f"{LANG}-mod-pools.ndjson", pools) + # One key per wording, qualified by domain: a map and a chart share wordings and are + # separate pools, so the domain is part of what is being asked for. + write_index(out / f"{LANG}-mod-pools-ref.index.bin", + [(f"{r['domain']}::{s['ref']}", off) for (_, r), off in zip(pools, offsets) + for s in r["stats"]]) + classes = pick(read_ndjson(src / "item-classes.ndjson"), ITEM_CLASSES, lambda r: r["itemClass"]) write_ndjson(out / "item-classes.ndjson", classes) @@ -382,6 +409,10 @@ def main() -> int: # its item records would read as "unknown" and never be tested at all. if items_exchange := src_manifest.get("source", {}).get("exchange_items", 0): source["exchange_items"] = items_exchange + # Carried through the same way, though what gates the mod pools in the app is the file + # itself: this is the bundle's own record of how many the build emitted. + if pool_count := src_manifest.get("source", {}).get("mod_pools", 0): + source["mod_pools"] = pool_count manifest = { "schema_version": 1, "data_version": "fixture", @@ -391,8 +422,8 @@ def main() -> int: "files": [], } (out / "manifest.json").write_bytes(json.dumps(manifest, indent=2).encode() + b"\n") - print(f"wrote {len(stats)} stats, {len(items)} items, {len(uniques)} unique-mod records " - f"and {len(classes)} item classes to {out}") + print(f"wrote {len(stats)} stats, {len(items)} items, {len(uniques)} unique-mod records, " + f"{len(pools)} pool modifiers and {len(classes)} item classes to {out}") return 0 diff --git a/src/app.cpp b/src/app.cpp index bd8b3ba..ef24068 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -12,6 +12,7 @@ #include "icon.hpp" #include "item/resolve.hpp" +#include "mapcheck/filter.hpp" #include "net/http.hpp" #include "paths.hpp" #include "platform/clipboard.hpp" @@ -21,6 +22,7 @@ #include "platform/platform.hpp" #include "platform/single_instance.hpp" #include "quickpaste.hpp" +#include "screens/mapcheck_screen.hpp" #include "screens/pricecheck_screen.hpp" #include "screens/quickpaste_screen.hpp" #include "screens/report_screen.hpp" @@ -137,6 +139,11 @@ constexpr int kNoticeW = 420, kNoticeH = 150; // data version at the size below, and no wider — the window is what swallows mouse input, and // while idle it is only click-through because nothing else is open. constexpr int kStatusW = 200, kStatusH = 48; + +/// The map check popup's width. Wide enough for the longest map modifier at the tooltip's own +/// size without wrapping it — the panel is a reconstructed item and a wrapped affix stops +/// reading as one line of a tooltip. +constexpr int kMapCheckW = 500; constexpr int kStatusUpdateH = 68; ///< one line taller while an update is waiting constexpr float kStatusFontSize = 15.0f; constexpr float kStatusAlpha = 0.5f; ///< it sits on top of the game's own HUD @@ -310,6 +317,10 @@ int App::run(bool relaunched_after_update) { currency_exchange_.init(exchange_event_); report_.init(report_event_); icons_.init(); + // The rating tables. A file read of a handful of small files, so it happens here rather + // than on the first press — the popup opens on a hotkey and has nowhere to wait. + map_store_.open(mapcheck::profiles_dir(), config_.map_profiles, config_.map_profile); + map_profiles_changed(); // Reclaim superseded bundles and map the installed one before anything else can hold a // mapping — on Windows a mapped directory cannot be removed. @@ -348,16 +359,23 @@ int App::run(bool relaunched_after_update) { // without the game, since it is placed against a cursor the hotkey sampled. float mx = 0, my = 0; SDL_GetGlobalMouseState(&mx, &my); - paste_x_ = static_cast(mx); - paste_y_ = static_cast(my); + cursor_x_ = static_cast(mx); + cursor_y_ = static_cast(my); set_screen(Screen::QuickPaste); - } else if (const char* path = std::getenv("PPC_DEV_ITEM")) { + } else if (const char* path = std::getenv("PPC_DEV_MAP") ?: std::getenv("PPC_DEV_ITEM")) { + const bool map = std::getenv("PPC_DEV_MAP") != nullptr; std::ifstream in(path, std::ios::binary); if (in) { std::ostringstream ss; ss << in.rdbuf(); accept_clipboard(ss.str()); - set_screen(Screen::PriceCheck); + if (map) { // at the pointer, as the hotkey would have left it + float mx = 0, my = 0; + SDL_GetGlobalMouseState(&mx, &my); + cursor_x_ = static_cast(mx); + cursor_y_ = static_cast(my); + } + set_screen(map ? Screen::MapCheck : Screen::PriceCheck); } else { SDL_Log("PPC_DEV_ITEM: cannot read %s", path); set_screen(Screen::Settings); @@ -381,6 +399,7 @@ int App::run(bool relaunched_after_update) { } while (SDL_PollEvent(&e)); } SDL_UpdateTrays(); + map_store_.tick(); // writes what a click left buffered, once it has stopped moving if (!dev_mode_) update_overlay_placement(); poll_pending_copy(); poll_click_away(); @@ -404,12 +423,25 @@ int App::run(bool relaunched_after_update) { draw_pricecheck_screen(*this); else if (screen_ == Screen::QuickPaste) draw_quickpaste_screen(*this); + else if (screen_ == Screen::MapCheck) + draw_mapcheck_screen(*this); else if (screen_ == Screen::BugReport || screen_ == Screen::ReportSent) draw_report_screen(*this); else draw_status_marker(*this); overlay_.end_frame(); need_redraw_ = false; + // The popup is the one screen whose size is its content's, and the content cannot + // be measured before it is laid out. One frame of correction, on a window that has + // just appeared — see `place_overlay`. + if (screen_ == Screen::MapCheck && mapcheck_h_ > 0) { + int ww = 0, wh = 0; + SDL_GetWindowSize(overlay_.window(), &ww, &wh); + if (std::abs(static_cast(wh) - mapcheck_h_) > 1.0f) { + place_overlay(); + need_redraw_ = true; + } + } // Between frames, because every part of this needs a frame boundary: the panel has // to be redrawn in its masked face before it can be read back, the read-back has to // be of a frame that is finished, and the resize the dialog brings must not land @@ -425,6 +457,7 @@ int App::run(bool relaunched_after_update) { } if (tray_) SDL_DestroyTray(tray_); + map_store_.flush(); // nothing buffered may outlive the run hotkeys_.reset(); leagues_.shutdown(); // joins + drains its events; must precede SDL_Quit trade_.shutdown(); @@ -515,6 +548,7 @@ void App::handle_event(const SDL_Event& e) { case Action::PriceCheck: config_.price_check = Hotkey{m, name}; break; case Action::ToggleSettings: config_.settings = Hotkey{m, name}; break; case Action::QuickPaste: config_.quick_paste = Hotkey{m, name}; break; + case Action::MapCheck: config_.map_check = Hotkey{m, name}; break; } end_capture(); } @@ -523,6 +557,7 @@ void App::handle_event(const SDL_Event& e) { // editor claimed the keyboard, and closing the whole check on it would throw away the row // the user was aiming at. ImGui closes its popup on the same press, so both agree. if (filter_edit_.open()) close_filter_edit(); + else if (screen_ == Screen::MapCheck) set_screen(Screen::Hidden); else if (screen_ == Screen::BugReport) close_bug_report(); else if (screen_ == Screen::ReportSent) dismiss_report_result(); else set_screen(Screen::Hidden); @@ -542,7 +577,9 @@ void App::handle_event(const SDL_Event& e) { // Price-check and the paste popup auto-dismiss when you click back into the game; // Settings stays open until closed manually (its hotkey or the X button). had_focus_ // avoids closing before the window has actually taken focus. - if (!dev_mode_ && (screen_ == Screen::PriceCheck || screen_ == Screen::QuickPaste) && + if (!dev_mode_ && + (screen_ == Screen::PriceCheck || screen_ == Screen::QuickPaste || + screen_ == Screen::MapCheck) && had_focus_) set_screen(Screen::Hidden); } @@ -614,7 +651,17 @@ void App::poll_pending_copy() { abandon_copy(); return; } - set_screen(Screen::PriceCheck); + // The one gate on a map check, and it is about the item rather than about the data: a + // ring's modifiers resolve to stats like a map's do, and nothing else would stop them being + // rated into a map profile. Dropped silently, like every other check that finds nothing. + if (copy_target_ == Screen::MapCheck && + !mapcheck::is_map_device_item(*item_, item_data_.get())) { + debug::log("[map] dropped: '%s' is not something that opens in the map device", + item_->item_class.c_str()); + abandon_copy(); + return; + } + set_screen(copy_target_); } /// Give up on the copy in flight, leaving nothing on screen. Hands back whatever the handover @@ -773,6 +820,7 @@ void App::rebuild_plan() { // having opened the section the strategy's leftovers are behind. close_filter_edit(); hidden_filters_shown_ = false; + map_rows_.clear(); // they point into the modifiers of the item being replaced if (!item_) { if (!clipboard_.empty()) debug::log("[item] %zu bytes on the clipboard did not parse as an item", @@ -798,6 +846,202 @@ void App::rebuild_plan() { // No bundle yet: the item still parses and renders, it just cannot be priced. derived_ = item::derive(nullptr, *item_); } + map_rows_ = mapcheck::rate(*item_, map_store_, item_data_.get()); + need_redraw_ = true; +} + +/// Rate one row of the map on screen. +/// +/// The row's own verdict is re-read from the store rather than assumed, which is what keeps the +/// popup and the settings list agreeing when the same modifier is on both. +void App::rate_map_row(size_t index, std::optional v) { + if (index >= map_rows_.size() || !map_rows_[index].rateable()) return; + const mapcheck::Row& row = map_rows_[index]; + const mapcheck::Verdict next = v ? *v : mapcheck::next_verdict(row.verdict); + map_store_.set(row.refs, next); + // Every row, not just this one: one map can print the same affix twice, and a verdict is + // about the affix. + for (mapcheck::Row& r : map_rows_) + if (r.rateable()) r.verdict = map_store_.verdict_of(r.refs); + need_redraw_ = true; +} + +std::string App::auto_profile() const { + // Not built — see the header. This is the one body that changes the day `Client.txt` is + // watched, and everything reading it is already written for a name coming back. + return {}; +} + +void App::apply_auto_profile() { + const std::string want = auto_profile(); + if (want.empty()) return; + select_map_profile(want); +} + +void App::persist_map_profile() { + Config on_disk = Config::load(); + on_disk.map_profiles = config_.map_profiles; + on_disk.map_profile = config_.map_profile; + on_disk.save(); +} + +void App::select_map_profile(std::string_view name) { + if (name == map_store_.current()) return; + // Whatever the last table was owed is written before the next one is in front of the user: + // a switch is exactly where a lost buffer would be invisible. + map_store_.flush(); + map_store_.select(name); + config_.map_profile = map_store_.current(); + for (mapcheck::Row& r : map_rows_) + if (r.rateable()) r.verdict = map_store_.verdict_of(r.refs); + debug::log("[map] profile -> '%s'", config_.map_profile.c_str()); + // **A selection is remembered only when it is the user's own to make.** With no auto-load + // rule in force — which is every case today — the profile last picked in Settings *or* in + // the popup is the one the next launch opens on, and it is written now rather than waiting + // on a Save the popup has no button for. + // + // Under a rule, the character decides and picking by hand is a look at another table rather + // than a new preference: it stands until the next time a rating screen opens, when + // `apply_auto_profile` puts the character's own back. Writing it would let the look outlive + // the session that took it. + if (auto_profile().empty()) persist_map_profile(); + need_redraw_ = true; +} + +const std::vector& App::map_pool_view() { + const std::shared_ptr gd = data_; + // A proposal is its own view: the rows it names, and nothing else. That is what makes the + // list the preview the roadmap asks for rather than a count the user has to take on trust. + if (!map_edit_.proposal.empty()) { + if (map_pool_stale_) { + map_pool_view_.clear(); + if (gd) + for (mapcheck::PoolGroup& g : mapcheck::pool_groups(*gd)) + if (map_edit_.proposal.count(mapcheck::affix_key(g.refs))) + map_pool_view_.push_back(std::move(g)); + map_pool_stale_ = false; + } + return map_pool_view_; + } + // Rebuilt only when something it was built from moved: compiling a regex and running it + // over a few hundred affixes is not work for every frame the dialog is open. + if (!map_pool_stale_ && map_pool_filter_ == map_edit_.filter && map_pool_data_ == gd.get()) + return map_pool_view_; + map_pool_filter_ = map_edit_.filter; + map_pool_data_ = gd.get(); + map_pool_stale_ = false; + map_pool_view_.clear(); + map_pool_size_ = 0; + if (!gd) return map_pool_view_; + const mapcheck::SearchFilter filter(map_pool_filter_); + for (mapcheck::PoolGroup& g : mapcheck::pool_groups(*gd)) { + ++map_pool_size_; + if (filter.empty() || filter.matches(mapcheck::group_lines(g, gd.get()))) + map_pool_view_.push_back(std::move(g)); + } + return map_pool_view_; +} + +size_t App::map_pool_size() { + map_pool_view(); // the count is a by-product of building the view + return map_pool_size_; +} + +App::PoolRating App::pool_verdict(const mapcheck::PoolGroup& g) const { + const std::vector& refs = g.refs; + if (refs.empty()) return {}; + if (!map_edit_.proposal.empty()) { + const auto it = map_edit_.proposal.find(mapcheck::affix_key(refs)); + if (it == map_edit_.proposal.end()) return {}; + return {it->second, false}; + } + // This affix's own verdict first, because that is what a button writes and what it must show + // itself as having written. Only when it has none does what a shorter key lends it apply, + // and the row says so rather than passing it off as a decision made here. + if (const mapcheck::Verdict own = map_store_.exact(refs); own != mapcheck::Verdict::Unrated) + return {own, false}; + const mapcheck::Verdict lent = map_store_.verdict_of(refs); + return {lent, lent != mapcheck::Verdict::Unrated}; +} + +void App::rate_pool_group(const mapcheck::PoolGroup& g, mapcheck::Verdict v) { + const std::vector refs = g.refs; + if (refs.empty()) return; + map_edit_.clear_proposal(); // an edit by hand is an answer to the proposal + map_pool_stale_ = true; + map_store_.set(refs, v); + for (mapcheck::Row& r : map_rows_) + if (r.rateable()) r.verdict = map_store_.verdict_of(r.refs); + need_redraw_ = true; +} + +void App::propose_from_search(const std::string& search) { + map_edit_.clear_proposal(); + map_pool_stale_ = true; + const std::shared_ptr gd = data_; + if (!gd) return; + const mapcheck::SearchFilter filter(search); + if (filter.empty()) return; + // Per affix, and on any of its lines. A term hitting one wording is a term about the + // modifier printing it, and the verdict is keyed on the affix's whole set — which is what + // keeps a proposal off the *other* affixes granting that same wording. Asking per stat + // instead wrote one-wording keys, and the propagation rule then spread each of them across + // everything containing it. + for (const mapcheck::PoolGroup& g : mapcheck::pool_groups(*gd)) { + const mapcheck::SearchFilter::Hit hit = filter.classify(mapcheck::group_lines(g, gd.get())); + if (hit == mapcheck::SearchFilter::Hit::None) continue; + const mapcheck::Verdict v = hit == mapcheck::SearchFilter::Hit::Unwanted + ? mapcheck::Verdict::Deadly + : mapcheck::Verdict::Safe; + map_edit_.proposal[mapcheck::affix_key(g.refs)] = v; + if (v == mapcheck::Verdict::Deadly) ++map_edit_.proposed_deadly; + else ++map_edit_.proposed_safe; + } + debug::log("[map] '%s' proposes %d deadly and %d safe", search.c_str(), + map_edit_.proposed_deadly, map_edit_.proposed_safe); + need_redraw_ = true; +} + +void App::accept_proposal() { + for (const auto& [key, v] : map_edit_.proposal) map_store_.set(mapcheck::affix_refs(key), v); + debug::log("[map] accepted %zu proposed verdicts into '%s'", map_edit_.proposal.size(), + map_store_.current().c_str()); + map_edit_.clear_proposal(); + map_pool_stale_ = true; + // A bulk edit is the one case where the throttle would be holding a lot, and the user has + // just pressed a button that says it happened. + map_store_.flush(); + for (mapcheck::Row& r : map_rows_) + if (r.rateable()) r.verdict = map_store_.verdict_of(r.refs); + need_redraw_ = true; +} + +void App::create_map_profile(const std::string& name, const std::string& copy_from) { + if (!map_store_.create(name, copy_from)) return; + debug::log("[map] created profile '%s'%s", name.c_str(), + copy_from.empty() ? "" : (" from '" + copy_from + "'").c_str()); + map_edit_.clear_proposal(); + map_pool_stale_ = true; + map_profiles_changed(); +} + +void App::delete_map_profile(const std::string& name) { + if (!map_store_.remove(name)) return; + debug::log("[map] deleted profile '%s'", name.c_str()); + map_edit_.clear_proposal(); + map_pool_stale_ = true; + map_profiles_changed(); +} + +void App::map_profiles_changed() { + config_.map_profiles = map_store_.names(); + config_.map_profile = map_store_.current(); + // The table itself is already on disk — `create` and `remove` write it there and then — so + // this is the list catching up. Without it a profile made and used could still be missing + // from the config, and `Store::open` would order it by name rather than by where it was put. + persist_map_profile(); + for (mapcheck::Row& r : map_rows_) + if (r.rateable()) r.verdict = map_store_.verdict_of(r.refs); need_redraw_ = true; } @@ -952,12 +1196,16 @@ void App::handle_action(Action a) { // keyboard focus itself, so the game can't be foreground while it's open, and its hotkey // still has to close it. const bool game_focused = foreground_title_contains(config_.poe_window_title); - if (a == Action::PriceCheck) log_state("hotkey"); - // The exception is a hotkey closing the screen it opened. Both of those screens hold the - // keyboard focus themselves, so the game *cannot* be foreground while one is up, and the - // hotkey that opened it has to be able to take it away again. + // Both checks read an item off the clipboard, so both want the copy path's own state in the + // log beside the press. + const bool reads_item = a == Action::PriceCheck || a == Action::MapCheck; + if (reads_item) log_state("hotkey"); + // The exception is a hotkey closing the screen it opened. All three of those screens hold + // the keyboard focus themselves, so the game *cannot* be foreground while one is up, and + // the hotkey that opened it has to be able to take it away again. const bool closes_own_screen = (a == Action::ToggleSettings && screen_ == Screen::Settings) || - (a == Action::QuickPaste && screen_ == Screen::QuickPaste); + (a == Action::QuickPaste && screen_ == Screen::QuickPaste) || + (a == Action::MapCheck && screen_ == Screen::MapCheck); if (!game_focused && !dev_mode_ && !closes_own_screen) { debug::trace("[copy] hotkey ignored: game not focused"); return; @@ -966,20 +1214,33 @@ void App::handle_action(Action a) { // fired into a browser. Whatever it finds is news for the next press, never for this one. refresh_checks(); - if (a == Action::PriceCheck) { + if (reads_item) { + // The two checks are the same gesture on the same item and share the whole copy path; + // this is the only thing that differs, and it is consumed once there is something to + // show. See `poll_pending_copy`. + copy_target_ = a == Action::MapCheck ? Screen::MapCheck : Screen::PriceCheck; // Sample the cursor now, while it's still on the item — the user will have moved - // on by the time the clipboard lands. + // on by the time the clipboard lands. The price check docks against a frame and needs + // only the half; the map check opens at the pointer and needs the point. side_ = cursor_side(); - debug::trace("[copy] price-check hotkey, game focused=%d", game_focused); + float mx = 0, my = 0; + SDL_GetGlobalMouseState(&mx, &my); + cursor_x_ = static_cast(mx); + cursor_y_ = static_cast(my); + debug::trace("[copy] %s hotkey, game focused=%d", + a == Action::MapCheck ? "map-check" : "price-check", (int)game_focused); if (!game_focused) { // dev mode: no game to copy from, just show what's already there accept_clipboard(read_clipboard("clipboard.dev")); - if (item_) set_screen(Screen::PriceCheck); + if (item_ && (copy_target_ != Screen::MapCheck || + mapcheck::is_map_device_item(*item_, item_data_.get()))) + set_screen(copy_target_); return; } // A check already on screen is about the *previous* item. Drop it before starting: // if this copy then fails silently, leaving the old panel up would read as a price // check of the item now under the cursor. - if (screen_ == Screen::PriceCheck) set_screen(Screen::Hidden); + if (screen_ == Screen::PriceCheck || screen_ == Screen::MapCheck) + set_screen(Screen::Hidden); // Take the stamp before injecting, not after — simulate_copy blocks for the length of // a human keypress and the copy can land inside it. copy_stamp_ = clipboard_stamp(); @@ -1021,8 +1282,8 @@ void App::handle_action(Action a) { // Where the hand is now, not where it will be when the window is placed. float mx = 0, my = 0; SDL_GetGlobalMouseState(&mx, &my); - paste_x_ = static_cast(mx); - paste_y_ = static_cast(my); + cursor_x_ = static_cast(mx); + cursor_y_ = static_cast(my); set_screen(Screen::QuickPaste); } else { set_screen(screen_ == Screen::Settings ? Screen::Hidden : Screen::Settings); @@ -1127,7 +1388,7 @@ void App::update_overlay_placement() { void App::reclaim_keyboard() { if (overlay_.has_focus()) return; if (screen_ != Screen::Settings && screen_ != Screen::QuickPaste && - screen_ != Screen::BugReport) + screen_ != Screen::BugReport && screen_ != Screen::MapCheck) return; debug::log("[app] reclaiming the keyboard for screen %d", (int)screen_); take_keyboard(); @@ -1170,26 +1431,39 @@ void App::place_overlay() { return; } - if (screen_ == Screen::QuickPaste) { + if (screen_ == Screen::QuickPaste || screen_ == Screen::MapCheck) { int w = 0, h = 0; - quickpaste_size(active_pastes(config_.pastes).size(), &w, &h); + if (screen_ == Screen::MapCheck) { + // The popup's height is its content's, and the content is an item nobody has laid + // out yet — so this is an estimate the screen corrects. It reports what it actually + // drew (`set_mapcheck_height`) and the window follows on the next frame, which is + // one frame either way for a window that has just appeared. Over-estimating rather + // than under: ImGui clamps a window to the viewport, so a first frame that is too + // short is content that cannot be measured at all. + w = kMapCheckW; + h = mapcheck_h_ > 0 ? static_cast(mapcheck_h_ + 0.5f) : gh; + } else { + quickpaste_size(active_pastes(config_.pastes).size(), &w, &h); + } + h = std::min(h, gh); // Right of the cursor and starting at it, which is where a menu opens — then clamped // into the game window, which is what turns "downwards" into "upwards" near the bottom // edge and into somewhere between the two in the middle. No decision of its own: a rule // that picks a direction and a clamp that has to override it would disagree in the // cases that matter. constexpr int kCursorGap = 14; - int x = paste_x_ + kCursorGap; - if (x + w > gx + gw) x = paste_x_ - kCursorGap - w; // no room on the right: open left + int x = cursor_x_ + kCursorGap; + if (x + w > gx + gw) x = cursor_x_ - kCursorGap - w; // no room on the right: open left // max(min()), not clamp: a game window narrower or shorter than the popup puts the low // bound above the high one, which clamp is not defined for. x = std::max(gx, std::min(x, gx + gw - w)); - const int y = std::max(gy, std::min(paste_y_ - kCursorGap, gy + gh - h)); + const int y = std::max(gy, std::min(cursor_y_ - kCursorGap, gy + gh - h)); SDL_SetWindowSize(overlay_.window(), w, h); SDL_SetWindowPosition(overlay_.window(), x, y); layout_ = PanelLayout{0, float(w), 0, 0}; - debug::log("[paste] placed %dx%d+%d+%d for a cursor at %d,%d", w, h, x, y, paste_x_, - paste_y_); + debug::log("[%s] placed %dx%d+%d+%d for a cursor at %d,%d", + screen_ == Screen::MapCheck ? "map] " : "paste] ", w, h, x, y, cursor_x_, + cursor_y_); return; } @@ -1361,6 +1635,18 @@ void App::dismiss_report_result() { void App::set_screen(Screen s) { debug::log("[app] screen %d -> %d", (int)screen_, (int)s); + // Every way out of a screen that can rate — its own X, Escape, the hotkey, a click into the + // game, a focus-out — arrives here, which is why the write is here rather than on any of + // them. `kWriteDelay` batches the clicks; this is what makes it safe to. + if ((screen_ == Screen::MapCheck || screen_ == Screen::Settings) && s != screen_) + map_store_.flush(); + // And on the way in, the other half of the same rule: a profile picked by hand under an + // auto-load rule lasts until a rating screen opens again, and this is that moment. A no-op + // while `auto_profile` has nothing to say, which is every case today. + if ((s == Screen::MapCheck || s == Screen::Settings) && s != screen_) apply_auto_profile(); + // A fresh popup measures itself from scratch: the last map's height is not an estimate of + // this one's, and starting from it would draw one frame at the wrong size. + if (s == Screen::MapCheck && screen_ != Screen::MapCheck) mapcheck_h_ = 0; screen_ = s; // The card is re-measured on the first frame of the new screen, and poll_click_away runs // before that frame: without this it would spend one iteration on the last check's card. @@ -1379,7 +1665,12 @@ void App::set_screen(Screen s) { // Settings needs keyboard focus immediately (text fields); a price check takes it only when // something on it has to be typed into (edit_filter) or the copy stalls // (nudge_clipboard_handover). Closing hands focus back to the game. - if (s == Screen::QuickPaste) { + if (s == Screen::MapCheck) { + // For Escape, and so that clicking back into the game is a focus-out this can dismiss + // on. The same claim the paste popup makes, and not the window manager's activation — + // see `take_keyboard`. + take_keyboard(); + } else if (s == Screen::QuickPaste) { // The number keys are the feature; without the keyboard the popup is a menu you have to // aim at. This is the server's input focus and not the window manager's activation — // the same thing the range editor takes, and the same reason it is not a violation of @@ -1449,7 +1740,8 @@ void App::apply_and_save_config() { void App::rebind_hotkeys() { hotkeys_->rebind({{config_.price_check, Action::PriceCheck}, {config_.settings, Action::ToggleSettings}, - {config_.quick_paste, Action::QuickPaste}}); + {config_.quick_paste, Action::QuickPaste}, + {config_.map_check, Action::MapCheck}}); } } // namespace ppc diff --git a/src/app.hpp b/src/app.hpp index 3aa86df..1dde7d7 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -13,6 +14,8 @@ #include "item/derive.hpp" #include "item/plan.hpp" #include "icon_cache.hpp" +#include "mapcheck/rate.hpp" +#include "mapcheck/store.hpp" #include "league_service.hpp" #include "exchange_service.hpp" #include "ninja_service.hpp" @@ -27,7 +30,7 @@ union SDL_Event; namespace ppc { -enum class Screen { Hidden, PriceCheck, Settings, QuickPaste, BugReport, ReportSent }; +enum class Screen { Hidden, PriceCheck, Settings, QuickPaste, MapCheck, BugReport, ReportSent }; /// How long a price check waits for the game to publish its copy before dropping it. Past this /// the user has moved on, and a panel that opens late is a panel about the wrong item. @@ -98,6 +101,33 @@ struct PasteEdit { Paste draft; }; +/// What the Map Check settings tab is in the middle of. Held on `App` for the same reason +/// `paste_edit_` is — the screen is a free function rebuilt from scratch every frame. +struct MapCheckEdit { + /// The search box. Narrows the list, and is what the propose button reads. + std::string filter; + + bool adding = false; ///< the new-profile dialog is open + std::string draft_name; + std::string copy_from; ///< empty for an empty profile, which is the default + + std::string deleting; ///< the profile the confirmation is about; empty for none + + /// Verdicts the search proposed and the user has not accepted yet, by **affix key** — the + /// same key `Store::set` writes, so the preview is the write. + /// + /// **Nothing here is in the table**: this is the whole of "the import proposes and the user + /// confirms", and the list draws these instead of the stored verdicts, and only these rows, + /// for as long as it is non-empty. + std::map> proposal; + int proposed_deadly = 0, proposed_safe = 0; + + void clear_proposal() { + proposal.clear(); + proposed_deadly = proposed_safe = 0; + } +}; + /// The bug report being written, and the exact bytes it would send. /// /// **Everything but the comment is fixed when the dialog opens.** The dialog's promise is that @@ -240,6 +270,70 @@ class App { /// the same frame. PasteEdit& paste_edit() { return paste_edit_; } + // Map check. The rating tables, the map in hand as a list of rateable rows, and the one + // thing pressing a row does. + mapcheck::Store& map_store() { return map_store_; } + const mapcheck::Store& map_store() const { return map_store_; } + /// The rows of the map on screen, rebuilt whenever a rating or the profile changes — a few + /// dozen rows against a map that is already parsed, so there is nothing to cache. + const std::vector& map_rows() const { return map_rows_; } + /// Rate the row at `index` as `v`, or walk it to the next verdict when `v` is absent. + /// Buffered; see `mapcheck::Store`. + void rate_map_row(size_t index, std::optional v = std::nullopt); + /// Switch the table in use, from the popup's dropdown or from Settings. Re-reads every row. + void select_map_profile(std::string_view name); + /// Take up the profile list Settings has been editing and rebuild the rows behind it. + void map_profiles_changed(); + /// What the Map Check settings tab is in the middle of. Mutable: the tab reads and writes + /// it in the same frame. + MapCheckEdit& map_edit() { return map_edit_; } + + // The pool browser. Everything here is per domain rather than per map — see mapcheck/rate. + /// The pool entries the search box leaves, rebuilt only when the search or the bundle + /// changes: matching a few hundred entries against a regex is not per-frame work. While a + /// proposal is pending this is the proposal's own rows and nothing else, so the list *is* + /// the preview of what accepting would write. + const std::vector& map_pool_view(); + /// How many affixes the pool holds at all, for the "%zu of %zu" line. + size_t map_pool_size(); + /// What a pool row shows, and on whose authority. + /// + /// `inherited` is the propagation rule made visible: a verdict set on a *shorter* affix + /// speaks for every affix whose wordings contain it, and a page that drew only what was set + /// on this exact row would leave that rule invisible until a map opened. The row still + /// distinguishes the two — pressing a button always writes this affix's own verdict, so a + /// control never moves except by being pressed. + struct PoolRating { + mapcheck::Verdict verdict = mapcheck::Verdict::Unrated; + bool inherited = false; ///< lent by a shorter key, not set on this affix + }; + /// What to draw on a pool row: the pending proposal where there is one, otherwise this + /// affix's own verdict, otherwise whatever a shorter one lends it. + PoolRating pool_verdict(const mapcheck::PoolGroup& g) const; + /// Rate one affix of the pool: the verdict is keyed on its whole set of wordings, so an + /// affix sharing one with another is not the same decision as that other. + void rate_pool_group(const mapcheck::PoolGroup& g, mapcheck::Verdict v); + /// Run the search over the whole pool and hold what it proposes. Writes nothing. + void propose_from_search(const std::string& search); + /// Write the proposal into the table in use. The one place a bulk edit lands. + void accept_proposal(); + + void create_map_profile(const std::string& name, const std::string& copy_from); + void delete_map_profile(const std::string& name); + + /// The profile the auto-load rule says this character should be rating into, or empty when + /// no rule applies — which is what makes a selection the user's own to keep. + /// + /// **Always empty today.** Knowing which character is logged in means reading `Client.txt`, + /// which is 0.7's "might" and is not built — it is what the Settings checkbox is disabled + /// for. With no character there is nothing to look up in `Config::map_profile_by_character`. + /// Everything either side of it is written against this one day returning a name, so that + /// day is a function body and not a design. + std::string auto_profile() const; + /// How tall the popup actually drew. Reported back by the screen because the window has to + /// be sized before there is a frame to measure in — see `place_overlay`. + void set_mapcheck_height(float h) { mapcheck_h_ = h; } + // Bug reports. The panel's own button opens the dialog; nothing here sends anything until // the dialog's Send is pressed, and the dialog shows the whole payload first. /// Capture the panel as it stands and open the report dialog on it. Called from the panel @@ -308,6 +402,17 @@ class App { void place_overlay(); ///< size + position the overlay for the current screen Side cursor_side() const; ///< which half of the game window the mouse is in void set_screen(Screen s); + /// Put the auto-loaded profile back, if there is one. Called on the way into either screen + /// that rates, which is what makes a hand-picked profile last exactly as long as the screen + /// it was picked on. + void apply_auto_profile(); + /// Write the map-check half of the configuration, and nothing else. + /// + /// **Re-read from disk first, deliberately.** Settings edits `config_` in place and only its + /// Save button was ever meant to commit that, so saving the live object here would push out + /// a league or an account name the user is still typing. What goes to disk is the last saved + /// state with the profile fields laid over it. + void persist_map_profile(); void log_state(const char* when); ///< dump everything the copy path depends on void log_session_start(); ///< the run's configuration, once void rebind_hotkeys(); @@ -351,6 +456,19 @@ class App { int settings_tab_ = 0; ///< which Settings tab is open FilterEdit filter_edit_; ///< which filter row has its range editor open PasteEdit paste_edit_; ///< the paste Settings has open in its editor + mapcheck::Store map_store_; ///< the rating tables, and the throttle in front of them + MapCheckEdit map_edit_; ///< what the Map Check settings tab is in the middle of + /// The pool browser's list, and what it was built for. Rebuilt when either moves — see + /// `map_pool_view`. + std::vector map_pool_view_; + std::string map_pool_filter_; + const data::GameData* map_pool_data_ = nullptr; + size_t map_pool_size_ = 0; + bool map_pool_stale_ = true; + /// The map on screen as rateable rows. Rebuilt rather than kept in step: it points into + /// `item_`, so every place that replaces the item clears it. + std::vector map_rows_; + float mapcheck_h_ = 0; ///< the popup's drawn height (see set_mapcheck_height) ReportDraft report_draft_; ///< the bug report being written /// How far along opening the report dialog is. Two frames pass between the press and the /// dialog, and both of them are the point — see `open_bug_report`. @@ -360,9 +478,13 @@ class App { Capturing, ///< that redraw is being read back at the end of this frame }; Opening report_opening_ = Opening::No; - /// Where the cursor was when the paste hotkey fired. Sampled there rather than read at - /// placement time for the same reason `side_` is: by then the hand has moved. - int paste_x_ = 0, paste_y_ = 0; + /// Where the cursor was when a hotkey that opens at it fired — the paste list and the map + /// check. Sampled there rather than read at placement time for the same reason `side_` is: + /// by then the hand has moved. + int cursor_x_ = 0, cursor_y_ = 0; + /// Which screen the copy in flight is for. The two checks share the whole copy path and + /// part company only once there is an item — see `poll_pending_copy`. + Screen copy_target_ = Screen::PriceCheck; bool hidden_filters_shown_ = false; ///< the filters the strategy left out are expanded std::string clipboard_; bool running_ = true; diff --git a/src/config.cpp b/src/config.cpp index 3b647e6..e14d89d 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -7,6 +7,7 @@ #include +#include "mapcheck/verdict.hpp" #include "paths.hpp" using json = nlohmann::json; @@ -43,6 +44,24 @@ void read_into(Config& c, const json& j) { if (h.contains("settings")) c.settings = parse_hotkey(h["settings"].get()); if (h.contains("quick_paste")) c.quick_paste = parse_hotkey(h["quick_paste"].get()); + if (h.contains("map_check")) c.map_check = parse_hotkey(h["map_check"].get()); + } + if (j.contains("map_check")) { + const auto& m = j["map_check"]; + if (m.contains("profiles") && m["profiles"].is_array()) + for (const auto& p : m["profiles"]) + if (p.is_string()) { + // Sanitised on the way in as well as on the way out: this file is + // hand-editable and a name here is a file name, so a slash in one would be + // a profile that can be listed and never opened. + std::string name = mapcheck::sanitize_profile_name(p.get()); + if (!name.empty()) c.map_profiles.push_back(std::move(name)); + } + c.map_profile = mapcheck::sanitize_profile_name(m.value("profile", std::string())); + if (m.contains("by_character") && m["by_character"].is_object()) + for (const auto& [character, profile] : m["by_character"].items()) + if (profile.is_string()) + c.map_profile_by_character.emplace_back(character, profile.get()); } if (j.contains("pastes") && j["pastes"].is_array()) { for (const auto& p : j["pastes"]) { @@ -109,6 +128,12 @@ bool Config::save() const { j["hotkeys"]["price_check"] = to_string(price_check); j["hotkeys"]["settings"] = to_string(settings); j["hotkeys"]["quick_paste"] = to_string(quick_paste); + j["hotkeys"]["map_check"] = to_string(map_check); + j["map_check"]["profiles"] = map_profiles; + j["map_check"]["profile"] = map_profile; + j["map_check"]["by_character"] = json::object(); + for (const auto& [character, profile] : map_profile_by_character) + j["map_check"]["by_character"][character] = profile; // Written even when empty, so the file says the feature exists and where its entries go. j["pastes"] = json::array(); for (const Paste& p : pastes) diff --git a/src/config.hpp b/src/config.hpp index 097643e..02ac317 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -47,12 +48,33 @@ struct Config { /// modifier: this fires while the game has the keyboard, so it has to be a combination /// nobody arrives at by accident mid-fight. Hotkey quick_paste{Mod::Alt, "V"}; + /// Reads the map under the cursor and says what you decided about its modifiers. Three + /// keys, and the price check's own letter: the two are the same gesture on the same item + /// and are worth being neighbours, and the extra modifier is what keeps a check the user + /// wanted from being the one they got. + Hotkey map_check{Mod::Ctrl | Mod::Shift, "D"}; /// The saved snippets that hotkey offers, in the order the popup lists them. No more than /// `kMaxActivePastes` of them may be enabled at once — enforced on load as well as in /// Settings, since this file is hand-editable. std::vector pastes; + /// The map-check rating tables, by name, in the order Settings lists them. **The verdicts + /// themselves are not here** — each profile is a file of its own beside this one, because a + /// few hundred ratings per profile would swamp a file somebody is meant to be able to open + /// and read. See `mapcheck::Profile`. + /// + /// A record rather than the authority: `MapCheckService` also reads the directory, so a + /// file dropped in by hand appears and one listed here that has gone is dropped. + std::vector map_profiles; + /// Which of them is in use. Empty until there is one. + std::string map_profile; + /// Character name → profile name, for the automatic switch that watching `Client.txt` + /// would drive. **Nothing reads it yet** — that watching is 0.7's "might" and is not built, + /// which is what the Settings checkbox is disabled for. Round-tripped so a hand-written + /// entry survives until it can be honoured. + std::vector> map_profile_by_character; + /// Run the trade search as soon as the panel opens, rather than on the Search button. /// Off by default and deliberately so: a hotkey the user pressed to *read* an item /// would otherwise spend a request against their rate limit every time. diff --git a/src/data/game_data.cpp b/src/data/game_data.cpp index 027f215..84ccffd 100644 --- a/src/data/game_data.cpp +++ b/src/data/game_data.cpp @@ -129,6 +129,14 @@ std::shared_ptr GameData::open(const fs::path& dir, std::string_view l gd->unique_mods_name_index_.attach(gd->unique_mods_name_idx_.data(), gd->unique_mods_name_idx_.size()); + // Optional in the same way and for the same reason. What a domain *could* have rolled is + // never needed to price the item in hand, so a bundle without it loses only the ability to + // rate a modifier nobody is holding. + if (gd->mod_pools_nd_.open(dir / (p + "mod-pools.ndjson")) && + gd->mod_pools_ref_idx_.open(dir / (p + "mod-pools-ref.index.bin"))) + gd->mod_pools_ref_index_.attach(gd->mod_pools_ref_idx_.data(), + gd->mod_pools_ref_idx_.size()); + // Small table, read eagerly. std::ifstream cls(dir / "item-classes.ndjson"); if (!cls) return fail("cannot read item-classes.ndjson"); @@ -140,6 +148,7 @@ std::shared_ptr GameData::open(const fs::path& dir, std::string_view l ic.item_class = j.value("itemClass", std::string()); ic.id = j.value("id", std::string()); ic.trade_category = j.value("tradeCategory", std::string()); + ic.mod_domain = j.value("domain", 0); if (!ic.item_class.empty()) gd->classes_.emplace(ic.item_class, std::move(ic)); } if (gd->classes_.empty()) return fail("item-classes.ndjson is empty"); @@ -242,6 +251,7 @@ const BaseType* GameData::base_at(uint32_t offset) const { b->trade_disc = j.value("tradeDisc", std::string()); b->trade_name = j.value("tradeName", std::string()); b->metadata_id = j.value("metadataId", std::string()); + b->mod_domain = j.value("domain", 0); b->exchange = j.value("exchange", false); b->art = j.value("art", std::string()); b->w = j.value("w", 0); @@ -296,6 +306,88 @@ const UniqueMods* GameData::unique_mods_at(uint32_t offset) const { return unique_mods_cache_.emplace(offset, std::move(u)).first->second.get(); } +const PoolMod* GameData::pool_mod_at(uint32_t offset) const { + if (const auto it = pool_mod_cache_.find(offset); it != pool_mod_cache_.end()) + return it->second.get(); + + const std::string_view line = line_at(mod_pools_nd_, offset); + if (line.empty()) return nullptr; + const json j = json::parse(line, nullptr, false); + if (j.is_discarded() || !j.is_object()) return nullptr; + + auto m = std::make_unique(); + m->domain = j.value("domain", 0); + m->gen = j.value("gen", 0); + m->tiers = j.value("tiers", 0); + m->name = j.value("name", std::string()); + if (const auto ids = j.find("mods"); ids != j.end() && ids->is_array()) + for (const json& id : *ids) + if (id.is_string()) m->mods.push_back(id.get()); + if (const auto ss = j.find("stats"); ss != j.end() && ss->is_array()) { + for (const json& s : *ss) { + if (!s.is_object()) continue; + PoolStat ps; + ps.ref = s.value("ref", std::string()); + ps.trade_id = s.value("trade", std::string()); + // Both or neither: a wording printing no number carries no bounds, and half a pair + // would render as a range with an open end it does not have. + if (const auto lo = s.find("min"), hi = s.find("max"); + lo != s.end() && hi != s.end() && lo->is_number() && hi->is_number()) { + ps.min = lo->get(); + ps.max = hi->get(); + } + if (!ps.ref.empty()) m->stats.push_back(std::move(ps)); + } + } + return pool_mod_cache_.emplace(offset, std::move(m)).first->second.get(); +} + +std::span GameData::mod_pool(int domain) const { + if (!pools_scanned_) { + pools_scanned_ = true; + // The file has no index by domain and needs none: it is a few hundred lines and a pool + // is only ever wanted whole, so one walk fills every domain at once. + const std::string_view all = mod_pools_nd_.view(); + for (size_t at = 0; at < all.size();) { + const size_t nl = all.find('\n', at); + if (const PoolMod* m = pool_mod_at(static_cast(at))) + pools_by_domain_[m->domain].push_back(m); + if (nl == std::string_view::npos) break; + at = nl + 1; + } + } + const auto it = pools_by_domain_.find(domain); + return it == pools_by_domain_.end() ? std::span() : it->second; +} + +std::vector GameData::find_pool_mods(int domain, + std::string_view normalized) const { + std::string key = std::to_string(domain); + key += "::"; + key += normalized; + + std::vector offsets; + mod_pools_ref_index_.lookup(key, offsets); + std::vector out; + for (uint32_t off : offsets) { + // Re-verify: fnv1a32 collides, and a run can mix distinct keys. + const PoolMod* m = pool_mod_at(off); + if (!m || m->domain != domain) continue; + for (const PoolStat& s : m->stats) + if (s.ref == normalized) { + out.push_back(m); + break; + } + } + return out; +} + +int GameData::mod_domain_for(const BaseType* base, std::string_view item_class) const { + if (base && base->mod_domain) return base->mod_domain; + const ItemClass* ic = this->item_class(item_class); + return ic ? ic->mod_domain : 0; +} + const UniqueMods* GameData::find_unique_mods(std::string_view name) const { std::string key(to_string(Namespace::Unique)); key += "::"; diff --git a/src/data/game_data.hpp b/src/data/game_data.hpp index ac8f2ca..1144262 100644 --- a/src/data/game_data.hpp +++ b/src/data/game_data.hpp @@ -78,6 +78,36 @@ class GameData { /// what tells "this unique has no record" apart from "nothing here has one". bool has_unique_mods() const { return unique_mods_name_index_.valid(); } + /// Every modifier `domain`'s pool can spawn, in file order — the whole set, whether or not + /// anything is holding one. This is the one lookup here that starts from no item. + /// + /// Empty is two different answers, as it is for the unique indices: a domain the bundle + /// publishes no pool for, and a bundle published before the dataset existed + /// (`has_mod_pools()`). Parsing is one pass over the file, memoised, since a pool is only + /// ever asked for whole. + std::span mod_pool(int domain) const; + + /// The entries in `domain`'s pool that print `normalized`, which is how a wording resolved + /// off an item finds what the pool says about it. Usually one; two where a prefix and a + /// suffix word the same thing, which the game does 42 times in the map pool alone. + /// + /// **Never a gate.** An empty answer means the pool does not mention this wording, which is + /// normal — see `PoolMod`. + std::vector find_pool_mods(int domain, std::string_view normalized) const; + + /// False for a bundle published before the mod-pool dataset existed, which is what tells + /// "this domain has no pool" apart from "nothing here has one". + bool has_mod_pools() const { return mod_pools_ref_index_.valid(); } + + /// Which pool an item resolved to `base` and printing `item_class` rolls from, or 0. + /// + /// The base is asked first and the class only answers where it cannot: trade lists all 491 + /// maps under one entry whose game row is a stand-in in the stackable-currency domain, so a + /// map's own record states no domain and its class is what knows the answer. The other way + /// round would be wrong — a class holding genuinely different things (Jewels covers two + /// domains) publishes none. + int mod_domain_for(const BaseType* base, std::string_view item_class) const; + /// False for a bundle published before the currency-exchange flags existed, which is what /// tells "this item does not trade there" apart from "nothing here says either way". /// @@ -128,18 +158,24 @@ class GameData { const Stat* stat_at(uint32_t offset) const; const BaseType* base_at(uint32_t offset) const; const UniqueMods* unique_mods_at(uint32_t offset) const; + const PoolMod* pool_mod_at(uint32_t offset) const; std::string_view line_at(const MappedFile& f, uint32_t offset) const; - MappedFile stats_nd_, items_nd_, unique_mods_nd_; + MappedFile stats_nd_, items_nd_, unique_mods_nd_, mod_pools_nd_; MappedFile stats_matcher_idx_, stats_ref_idx_, items_name_idx_, items_base_idx_, - items_ref_idx_, unique_mods_name_idx_; + items_ref_idx_, unique_mods_name_idx_, mod_pools_ref_idx_; HashIndex stats_matcher_index_, stats_ref_index_, items_name_index_, items_base_index_, - items_ref_index_, unique_mods_name_index_; + items_ref_index_, unique_mods_name_index_, mod_pools_ref_index_; // Parsed on demand. mutable because lookups are logically const. mutable std::unordered_map> stat_cache_; mutable std::unordered_map> base_cache_; mutable std::unordered_map> unique_mods_cache_; + mutable std::unordered_map> pool_mod_cache_; + /// The whole file grouped by domain, filled on the first `mod_pool()` call. A pool is only + /// ever wanted entire, and there are a few hundred records, so one pass beats an index. + mutable std::unordered_map> pools_by_domain_; + mutable bool pools_scanned_ = false; // Small enough (90 rows) that parsing it up front beats indexing it. Keyed on the // English printed class name, which is what the file states; `by_class_id_` is the same diff --git a/src/data/install.cpp b/src/data/install.cpp index eae748f..1469a75 100644 --- a/src/data/install.cpp +++ b/src/data/install.cpp @@ -114,6 +114,7 @@ bool BundleStore::commit(const Manifest& m, std::string* err) const { // says a bundle predates the currency-exchange flags, so writing a 0 would claim the // opposite of what it means. if (m.exchange_items > 0) j["source"]["exchange_items"] = m.exchange_items; + if (m.mod_pools > 0) j["source"]["mod_pools"] = m.mod_pools; if (!write_file(staging / "manifest.json", j.dump(2) + "\n")) return fail("cannot write manifest.json"); diff --git a/src/data/manifest.cpp b/src/data/manifest.cpp index 55eed29..4a0e705 100644 --- a/src/data/manifest.cpp +++ b/src/data/manifest.cpp @@ -54,6 +54,7 @@ bool parse_manifest(std::string_view json_text, Manifest& out, std::string* err) if (const auto s = j.find("source"); s != j.end() && s->is_object()) { out.unique_mods_attribution = s->value("unique_mods_attribution", std::string()); out.exchange_items = s->value("exchange_items", 0); + out.mod_pools = s->value("mod_pools", 0); } const auto files = j.find("files"); diff --git a/src/data/manifest.hpp b/src/data/manifest.hpp index 9b726a3..fbee0a4 100644 --- a/src/data/manifest.hpp +++ b/src/data/manifest.hpp @@ -38,6 +38,10 @@ struct Manifest { /// which is not the same answer as "this item does not trade there" — see /// `GameData::has_exchange_flags()`. int exchange_items = 0; + /// How many pooled modifiers the data build emitted, and so whether this bundle has the + /// dataset at all. Written through on install like the two above; 0 is "no pool", which is + /// the only honest reading when there is no file behind it either. + int mod_pools = 0; std::vector files; const ManifestFile* find(std::string_view name) const; diff --git a/src/data/types.hpp b/src/data/types.hpp index ac35328..41600a0 100644 --- a/src/data/types.hpp +++ b/src/data/types.hpp @@ -50,6 +50,18 @@ struct BaseType { std::string name; std::string ref_name; Namespace ns = Namespace::Item; + /// The pool namespace this base's modifiers are generated from — `Mods.Domain`, an integer + /// the game has no published name for past the first few. A base has exactly one; the + /// domains are mutually exclusive, so this is what says a chart rolls from a different pool + /// than the map it is sailed from without compiling in a list of names. + /// + /// **0 means the record does not say**, which is not the same as "no modifiers": a unique, + /// anything the build could not match to game data, and every bundle published before the + /// field existed all read as 0 — and so does trade's one "Map" entry, whose game row is a + /// stand-in for all 491 of them and states the wrong domain outright. Ask + /// `GameData::mod_domain_for` rather than this field, and it will fall back to the item + /// class, which is exact for that case. + int mod_domain = 0; std::string category; ///< craftable.category, e.g. "Rings" std::string trade_disc; ///< discriminator when name/type alone is ambiguous /// The trade `type` term, where it is not the display name. Only gems have one: trade @@ -148,6 +160,46 @@ struct ItemClass { std::string item_class; ///< as printed by the clipboard, e.g. "Rings" std::string id; ///< the game's internal class id std::string trade_category; ///< trade `category` option; empty when unmapped + /// The mod domain every base of this class agrees on, or 0 where they do not — a class + /// holding genuinely different things (Jewels covers two) can answer for a base and never + /// for itself. It exists for the records whose own domain is missing, which is why `Maps` + /// carries one: all 511 map bases are domain 5 while the "Map" trade lists them under is a + /// stand-in row that says 43. + int mod_domain = 0; +}; + +/// One stat a pooled modifier grants, as the mod-pool dataset states it — the same shape a +/// resolved `Stat` has, minus everything only a printed roll can answer. +struct PoolStat { + /// The canonical '#'-placeholder wording, and the key this entry is indexed under. Present + /// to render and to match a regex against; it resolves to a `Stat` only where `trade_id` + /// is set, since the rest are wordings trade indexes under no hash at all. + std::string ref; + /// The ready-to-use stat hash. Empty means the modifier is real but not searchable, which + /// is the ordinary case here and costs the entry nothing: a pool is rated, not searched. + std::string trade_id; + /// The lowest tier's floor and the highest tier's ceiling, in displayed units — what a + /// wording has to be rendered with before a regex written against printed item text can be + /// tested against it. Absent together for a wording that prints no number at all. + std::optional min, max; +}; + +/// One modifier a mod domain's pool can spawn — one *wording-set*, not one roll: the tiers of +/// an affix all print the same wordings and a verdict attaches to a wording, so they collapse. +/// +/// The pool **describes and never gates**. It is what spawns naturally, which is strictly less +/// than what an item can print: an essence, a craft, a veiled mod or Harvest all put modifiers +/// on an item whose weights would never have produced them, and the published list is trimmed +/// by naming conventions besides. A printed modifier no entry covers is normal, not an error. +struct PoolMod { + int domain = 0; + /// `Mods.GenerationType` — 1 prefix, 2 suffix, 5 a Vaal corruption implicit, and the rest + /// named by number because the game publishes no name for them either. + int gen = 0; + int tiers = 0; ///< how many mod rows print these wordings + std::string name; ///< the affix name, as Advanced Mod Descriptions prints it + std::vector mods; ///< GGG's own mod ids; provenance, for the debug log + std::vector stats; ///< one per wording, in the order the modifier prints them }; } // namespace ppc::data diff --git a/src/glyph_data.inc b/src/glyph_data.inc index 35f70b0..f02c79c 100644 --- a/src/glyph_data.inc +++ b/src/glyph_data.inc @@ -3,18 +3,18 @@ // Which codepoints: scripts/fetch-glyphs.sh. What they are called: src/ui/glyphs.hpp. // See assets/fonts/README.md for the license this is bundled under. -static const unsigned char ppc_glyphs_ttf[2852] = { +static const unsigned char ppc_glyphs_ttf[3692] = { 0x00,0x01,0x00,0x00,0x00,0x0a,0x00,0x80,0x00,0x03,0x00,0x20,0x4f,0x53,0x2f,0x32, - 0x51,0x4b,0x59,0xf8,0x00,0x00,0x08,0x98,0x00,0x00,0x00,0x60,0x63,0x6d,0x61,0x70, - 0xc5,0xb6,0xbc,0xd8,0x00,0x00,0x08,0xf8,0x00,0x00,0x00,0x74,0x67,0x6c,0x79,0x66, - 0xe3,0x82,0x07,0xdd,0x00,0x00,0x00,0xac,0x00,0x00,0x07,0x2e,0x68,0x65,0x61,0x64, - 0x2b,0xa9,0x1a,0xa3,0x00,0x00,0x08,0x14,0x00,0x00,0x00,0x36,0x68,0x68,0x65,0x61, - 0x04,0x4d,0x02,0x35,0x00,0x00,0x08,0x74,0x00,0x00,0x00,0x24,0x68,0x6d,0x74,0x78, - 0x12,0x81,0x00,0x0d,0x00,0x00,0x08,0x4c,0x00,0x00,0x00,0x28,0x6c,0x6f,0x63,0x61, - 0x0a,0xa0,0x08,0xcf,0x00,0x00,0x07,0xfc,0x00,0x00,0x00,0x16,0x6d,0x61,0x78,0x70, - 0x00,0x20,0x07,0x84,0x00,0x00,0x07,0xdc,0x00,0x00,0x00,0x20,0x6e,0x61,0x6d,0x65, - 0x1d,0x87,0x38,0x73,0x00,0x00,0x09,0x6c,0x00,0x00,0x01,0x98,0x70,0x6f,0x73,0x74, - 0xff,0xde,0x00,0x19,0x00,0x00,0x0b,0x04,0x00,0x00,0x00,0x20,0x00,0x05,0x00,0x00, + 0x51,0x4b,0x59,0xf8,0x00,0x00,0x0b,0xc0,0x00,0x00,0x00,0x60,0x63,0x6d,0x61,0x70, + 0xad,0x09,0x9f,0x64,0x00,0x00,0x0c,0x20,0x00,0x00,0x00,0x94,0x67,0x6c,0x79,0x66, + 0x18,0xa9,0xbb,0x91,0x00,0x00,0x00,0xac,0x00,0x00,0x0a,0x40,0x68,0x65,0x61,0x64, + 0x2b,0xa9,0x1a,0xa3,0x00,0x00,0x0b,0x2c,0x00,0x00,0x00,0x36,0x68,0x68,0x65,0x61, + 0x04,0x4d,0x02,0x38,0x00,0x00,0x0b,0x9c,0x00,0x00,0x00,0x24,0x68,0x6d,0x74,0x78, + 0x17,0xc3,0x00,0x16,0x00,0x00,0x0b,0x64,0x00,0x00,0x00,0x36,0x6c,0x6f,0x63,0x61, + 0x13,0x66,0x10,0xd8,0x00,0x00,0x0b,0x0c,0x00,0x00,0x00,0x1e,0x6d,0x61,0x78,0x70, + 0x00,0x24,0x07,0x84,0x00,0x00,0x0a,0xec,0x00,0x00,0x00,0x20,0x6e,0x61,0x6d,0x65, + 0x1d,0x87,0x38,0x73,0x00,0x00,0x0c,0xb4,0x00,0x00,0x01,0x98,0x70,0x6f,0x73,0x74, + 0xff,0xde,0x00,0x19,0x00,0x00,0x0e,0x4c,0x00,0x00,0x00,0x20,0x00,0x05,0x00,0x00, 0xff,0xc0,0x01,0x80,0x01,0xc0,0x00,0x06,0x00,0x0d,0x00,0x14,0x00,0x1b,0x00,0x35, 0x00,0x00,0x37,0x37,0x07,0x37,0x27,0x31,0x11,0x17,0x33,0x23,0x33,0x27,0x31,0x07, 0x37,0x17,0x27,0x17,0x11,0x31,0x07,0x37,0x23,0x33,0x23,0x17,0x31,0x37,0x25,0x36, @@ -25,162 +25,214 @@ static const unsigned char ppc_glyphs_ttf[2852] = { 0x14,0x0e,0x0d,0x01,0x3a,0x86,0x86,0x86,0x86,0xfe,0xf4,0x3a,0x86,0x86,0xc0,0x86, 0x86,0x86,0x01,0x0c,0x86,0xc0,0x86,0x86,0x10,0x14,0x0e,0x0d,0x01,0x01,0x0d,0x0e, 0x14,0xfe,0x60,0x14,0x0e,0x0d,0x01,0x01,0x0d,0x0e,0x14,0x01,0xa0,0x00,0x00,0x02, - 0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x1c,0x00,0x37,0x00,0x00,0x25,0x06, - 0x07,0x17,0x31,0x16,0x15,0x14,0x07,0x06,0x23,0x22,0x27,0x27,0x31,0x06,0x07,0x26, - 0x27,0x26,0x27,0x36,0x37,0x36,0x37,0x16,0x17,0x16,0x17,0x07,0x32,0x37,0x31,0x31, - 0x36,0x37,0x36,0x35,0x34,0x27,0x26,0x27,0x26,0x23,0x22,0x07,0x06,0x07,0x06,0x15, - 0x14,0x17,0x16,0x17,0x16,0x33,0x01,0xa0,0x01,0x27,0x7f,0x09,0x09,0x0a,0x0d,0x0d, - 0x0a,0x7e,0x35,0x46,0x58,0x3b,0x3b,0x02,0x02,0x3b,0x3b,0x58,0x58,0x3b,0x3b,0x02, - 0xd0,0x27,0x21,0x21,0x14,0x13,0x13,0x14,0x21,0x21,0x27,0x27,0x21,0x21,0x14,0x13, - 0x13,0x14,0x21,0x21,0x27,0xf0,0x46,0x35,0x7e,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x7f, - 0x27,0x01,0x02,0x3b,0x3b,0x58,0x58,0x3b,0x3b,0x02,0x02,0x3b,0x3b,0x58,0x90,0x13, - 0x13,0x22,0x22,0x26,0x26,0x22,0x22,0x13,0x13,0x13,0x13,0x22,0x22,0x26,0x26,0x22, - 0x22,0x13,0x13,0x00,0x00,0x01,0x00,0x00,0x00,0x20,0x01,0xc0,0x01,0x60,0x00,0x1e, - 0x00,0x00,0x01,0x16,0x15,0x31,0x31,0x14,0x07,0x01,0x31,0x06,0x23,0x22,0x27,0x27, - 0x31,0x26,0x35,0x34,0x37,0x36,0x33,0x32,0x17,0x17,0x31,0x37,0x31,0x36,0x33,0x32, - 0x17,0x01,0xb7,0x09,0x09,0xff,0x00,0x0a,0x0d,0x0d,0x0a,0x80,0x09,0x09,0x0a,0x0d, - 0x0d,0x0a,0x69,0xe9,0x0a,0x0d,0x0d,0x0a,0x01,0x57,0x0a,0x0d,0x0d,0x0a,0xff,0x00, - 0x09,0x09,0x80,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x6a,0xea,0x09,0x09,0x00,0x00,0x02, - 0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x29,0x00,0x5d,0x00,0x00,0x01,0x22, - 0x07,0x31,0x31,0x06,0x15,0x14,0x17,0x16,0x33,0x33,0x31,0x07,0x31,0x06,0x15,0x14, - 0x17,0x16,0x33,0x32,0x37,0x37,0x31,0x15,0x31,0x14,0x17,0x16,0x33,0x32,0x37,0x36, - 0x35,0x35,0x31,0x34,0x27,0x26,0x23,0x23,0x07,0x06,0x07,0x31,0x31,0x06,0x07,0x11, - 0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36,0x37,0x35,0x31,0x34,0x27,0x26, - 0x23,0x22,0x07,0x06,0x15,0x15,0x31,0x06,0x07,0x21,0x31,0x26,0x27,0x11,0x31,0x36, - 0x37,0x33,0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x23,0x01,0x40,0x0e,0x09, - 0x09,0x09,0x09,0x0e,0x53,0xca,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0xc9,0x09,0x09,0x0e, - 0x0e,0x09,0x09,0x09,0x09,0x0e,0xa0,0xf0,0x22,0x17,0x16,0x01,0x01,0x16,0x17,0x22, - 0x01,0x40,0x22,0x17,0x16,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x01,0x0f,0xfe,0xc0, - 0x0f,0x01,0x01,0x0f,0x70,0x0e,0x09,0x09,0x09,0x09,0x0e,0x70,0x01,0xc0,0x09,0x09, - 0x0e,0x0e,0x09,0x09,0xc9,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0xca,0x53,0x0e,0x09,0x09, - 0x09,0x09,0x0e,0xa0,0x0e,0x09,0x09,0x20,0x01,0x16,0x17,0x22,0xfe,0xc0,0x22,0x17, - 0x16,0x01,0x01,0x16,0x17,0x22,0x70,0x0e,0x09,0x09,0x09,0x09,0x0e,0x70,0x0f,0x01, - 0x01,0x0f,0x01,0x40,0x0f,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x01,0x00,0x10, - 0xff,0xd9,0x01,0xe7,0x01,0xa7,0x00,0x46,0x00,0x00,0x13,0x33,0x23,0x33,0x32,0x17, - 0x16,0x15,0x14,0x07,0x06,0x23,0x23,0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x34,0x37, - 0x36,0x33,0x32,0x17,0x16,0x15,0x15,0x31,0x37,0x31,0x36,0x37,0x36,0x17,0x16,0x17, - 0x16,0x17,0x16,0x07,0x06,0x07,0x06,0x07,0x06,0x27,0x26,0x27,0x26,0x35,0x34,0x37, - 0x36,0x33,0x32,0x17,0x16,0x33,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x22,0x07, - 0x07,0x7e,0x32,0x32,0x32,0x0e,0x09,0x09,0x09,0x09,0x0e,0x80,0x0e,0x09,0x09,0x09, - 0x09,0x0e,0x0e,0x09,0x09,0x12,0x2c,0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e, - 0x0f,0x2c,0x2c,0x39,0x39,0x39,0x39,0x2c,0x0a,0x0a,0x09,0x0d,0x0d,0x0a,0x31,0x40, - 0x40,0x31,0x2f,0x2f,0x31,0x40,0x40,0x31,0x11,0x01,0x20,0x09,0x09,0x0e,0x0e,0x09, - 0x09,0x09,0x09,0x0e,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x33,0x11,0x2c,0x0f,0x0e, - 0x0e,0x0f,0x2c,0x2c,0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x09, - 0x0d,0x0d,0x0a,0x09,0x09,0x2f,0x2f,0x31,0x40,0x40,0x31,0x2f,0x2f,0x11,0x00,0x02, - 0x00,0x00,0xff,0xe0,0x01,0xc0,0x01,0xa0,0x00,0x19,0x00,0x3b,0x00,0x00,0x13,0x06, - 0x07,0x31,0x31,0x06,0x07,0x11,0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36, - 0x37,0x11,0x31,0x26,0x27,0x26,0x27,0x21,0x13,0x35,0x15,0x35,0x23,0x31,0x26,0x27, - 0x36,0x37,0x33,0x31,0x35,0x31,0x36,0x37,0x16,0x17,0x15,0x31,0x33,0x31,0x16,0x17, - 0x06,0x07,0x23,0x31,0x15,0x31,0x06,0x07,0x26,0x27,0x40,0x1b,0x12,0x12,0x01,0x01, - 0x12,0x12,0x1b,0x01,0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x88, - 0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40, - 0x02,0x16,0x16,0x02,0x01,0xa0,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x1b,0x12,0x12,0x01, - 0x01,0x12,0x12,0x1b,0x01,0x40,0x1b,0x12,0x12,0x01,0xfe,0xc8,0x40,0x40,0x40,0x02, - 0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02, - 0x02,0x16,0x00,0x02,0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x18,0x00,0x8b, - 0x00,0x00,0x01,0x16,0x17,0x31,0x31,0x16,0x17,0x15,0x31,0x14,0x07,0x06,0x23,0x23, - 0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x36,0x37,0x36,0x37,0x07,0x36,0x33,0x31,0x31, - 0x32,0x17,0x17,0x31,0x16,0x17,0x30,0x15,0x36,0x33,0x33,0x31,0x32,0x17,0x34,0x37, - 0x37,0x31,0x36,0x33,0x32,0x17,0x16,0x15,0x14,0x07,0x07,0x31,0x06,0x07,0x16,0x17, - 0x33,0x31,0x32,0x17,0x16,0x15,0x14,0x07,0x06,0x23,0x23,0x31,0x14,0x07,0x16,0x17, - 0x17,0x31,0x16,0x15,0x14,0x07,0x06,0x23,0x22,0x27,0x27,0x31,0x06,0x07,0x35,0x31, - 0x26,0x27,0x06,0x07,0x15,0x31,0x26,0x27,0x07,0x31,0x06,0x23,0x22,0x27,0x26,0x35, - 0x34,0x37,0x37,0x31,0x36,0x37,0x26,0x35,0x23,0x31,0x22,0x27,0x26,0x35,0x34,0x37, - 0x36,0x33,0x33,0x31,0x36,0x37,0x26,0x27,0x27,0x31,0x26,0x35,0x34,0x37,0x01,0x00, - 0x29,0x1b,0x1b,0x01,0x08,0x08,0x0c,0x87,0x0d,0x08,0x08,0x01,0x1b,0x1b,0x29,0xd7, - 0x0a,0x0d,0x0d,0x0a,0x40,0x01,0x01,0x15,0x1a,0x70,0x1a,0x16,0x01,0x40,0x0a,0x0d, - 0x0d,0x0a,0x09,0x09,0x40,0x01,0x01,0x09,0x02,0x40,0x0e,0x09,0x09,0x09,0x09,0x0e, - 0x40,0x0f,0x03,0x03,0x40,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x3f,0x25,0x35,0x01,0x0f, - 0x0f,0x01,0x35,0x25,0x3f,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x40,0x03,0x03,0x0f,0x40, - 0x0e,0x09,0x09,0x09,0x09,0x0e,0x40,0x02,0x09,0x01,0x01,0x40,0x09,0x09,0x01,0xc0, - 0x01,0x1b,0x1b,0x29,0x04,0x0c,0x08,0x08,0x08,0x08,0x0c,0x04,0x29,0x1b,0x1b,0x01, - 0x69,0x09,0x09,0x40,0x01,0x01,0x01,0x0c,0x0c,0x02,0x01,0x40,0x09,0x09,0x0a,0x0d, - 0x0d,0x0a,0x40,0x01,0x01,0x12,0x15,0x09,0x09,0x0e,0x0e,0x09,0x09,0x25,0x20,0x02, - 0x02,0x40,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x3f,0x21,0x06,0xef,0x0f,0x01,0x01,0x0f, - 0xef,0x06,0x21,0x3f,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x40,0x02,0x02,0x20,0x25,0x09, - 0x09,0x0e,0x0e,0x09,0x09,0x15,0x12,0x01,0x01,0x40,0x0a,0x0d,0x0d,0x0a,0x00,0x05, - 0x00,0x00,0xff,0xc0,0x01,0xc0,0x01,0xc0,0x00,0x1f,0x00,0x30,0x00,0x3d,0x00,0x4a, - 0x00,0x57,0x00,0x00,0x13,0x36,0x37,0x33,0x31,0x16,0x17,0x17,0x31,0x33,0x31,0x32, - 0x17,0x16,0x15,0x14,0x07,0x06,0x23,0x21,0x31,0x22,0x27,0x26,0x35,0x34,0x37,0x36, - 0x33,0x33,0x31,0x37,0x07,0x21,0x21,0x21,0x11,0x31,0x06,0x07,0x06,0x07,0x21,0x31, - 0x26,0x27,0x26,0x27,0x11,0x17,0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31, - 0x26,0x27,0x33,0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x33, - 0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x87,0x09,0x14,0x78, - 0x14,0x09,0x07,0x60,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0x0e,0x09,0x09,0x09, - 0x09,0x0e,0x60,0x07,0x67,0x01,0x80,0xfe,0x80,0x01,0x80,0x01,0x12,0x12,0x1b,0xff, - 0x00,0x1b,0x12,0x12,0x01,0x60,0x0f,0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f,0x60,0x0f, - 0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f,0x60,0x0f,0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f, - 0x01,0xae,0x11,0x01,0x01,0x11,0x0e,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, - 0x0e,0x09,0x09,0x0e,0x6e,0xfe,0xc0,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x01, - 0x40,0x40,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f, - 0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01, - 0x00,0x02,0xff,0xfd,0xff,0xbd,0x01,0xff,0x01,0xbf,0x00,0x11,0x00,0x24,0x00,0x00, - 0x01,0x07,0x37,0x07,0x17,0x31,0x37,0x31,0x36,0x35,0x34,0x27,0x27,0x31,0x26,0x23, - 0x22,0x07,0x07,0x07,0x37,0x07,0x06,0x07,0x07,0x31,0x06,0x17,0x16,0x37,0x37,0x31, - 0x36,0x37,0x37,0x31,0x27,0x01,0x6b,0x31,0x31,0x31,0x82,0x31,0x12,0x12,0x28,0x13, - 0x1a,0x19,0x14,0x47,0xe9,0xe9,0xe9,0x10,0x07,0x23,0x04,0x0a,0x0a,0x0e,0x78,0x15, - 0x10,0xea,0x82,0x01,0xad,0x31,0x31,0x31,0x82,0x31,0x13,0x1a,0x19,0x14,0x28,0x12, - 0x12,0x47,0xea,0xea,0xea,0x0f,0x16,0x78,0x0e,0x0a,0x0a,0x04,0x23,0x07,0x0f,0xea, - 0x82,0x00,0x00,0x02,0x00,0x00,0x00,0x60,0x01,0xc0,0x01,0x20,0x00,0x15,0x00,0x2b, - 0x00,0x00,0x37,0x22,0x07,0x31,0x31,0x06,0x15,0x14,0x17,0x16,0x33,0x21,0x31,0x32, - 0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x21,0x35,0x22,0x07,0x31,0x31,0x06,0x15,0x14, - 0x17,0x16,0x33,0x21,0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x21,0x20,0x0e, - 0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0x0e, - 0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0xa0, - 0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x80,0x09,0x09,0x0e, - 0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x00,0x00,0x00,0x01,0x00,0x00, - 0x00,0x0a,0x07,0x83,0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, + 0x00,0x10,0xff,0xe0,0x01,0x30,0x01,0xa0,0x00,0x3c,0x00,0x49,0x00,0x00,0x13,0x36, + 0x37,0x31,0x31,0x36,0x37,0x33,0x31,0x16,0x17,0x16,0x17,0x15,0x31,0x06,0x07,0x07, + 0x31,0x06,0x07,0x15,0x31,0x14,0x17,0x16,0x33,0x32,0x37,0x36,0x35,0x35,0x31,0x34, + 0x37,0x37,0x31,0x36,0x37,0x36,0x35,0x35,0x31,0x26,0x27,0x26,0x27,0x23,0x31,0x06, + 0x07,0x06,0x07,0x14,0x17,0x16,0x33,0x32,0x37,0x36,0x35,0x13,0x36,0x37,0x36,0x27, + 0x26,0x27,0x06,0x07,0x06,0x17,0x16,0x17,0x50,0x01,0x12,0x12,0x1b,0x20,0x1b,0x12, + 0x12,0x01,0x01,0x1c,0x2b,0x27,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x0b,0x2a,0x1c, + 0x0f,0x10,0x01,0x24,0x25,0x36,0x20,0x36,0x25,0x24,0x01,0x09,0x09,0x0e,0x0e,0x09, + 0x09,0x50,0x17,0x0c,0x0a,0x0a,0x0c,0x17,0x17,0x0c,0x0a,0x0a,0x0c,0x17,0x01,0x20, + 0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x04,0x22,0x13,0x1c,0x1a,0x30,0x01,0x0e, + 0x09,0x09,0x09,0x09,0x0e,0x01,0x0d,0x08,0x1b,0x12,0x1c,0x1c,0x22,0x03,0x36,0x25, + 0x24,0x01,0x01,0x24,0x25,0x36,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0xc0,0x01,0x13, + 0x14,0x14,0x13,0x01,0x01,0x13,0x14,0x14,0x13,0x01,0x00,0x02,0x00,0x00,0xff,0xc0, + 0x02,0x00,0x01,0xc0,0x00,0x1c,0x00,0x37,0x00,0x00,0x25,0x06,0x07,0x17,0x31,0x16, + 0x15,0x14,0x07,0x06,0x23,0x22,0x27,0x27,0x31,0x06,0x07,0x26,0x27,0x26,0x27,0x36, + 0x37,0x36,0x37,0x16,0x17,0x16,0x17,0x07,0x32,0x37,0x31,0x31,0x36,0x37,0x36,0x35, + 0x34,0x27,0x26,0x27,0x26,0x23,0x22,0x07,0x06,0x07,0x06,0x15,0x14,0x17,0x16,0x17, + 0x16,0x33,0x01,0xa0,0x01,0x27,0x7f,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x7e,0x35,0x46, + 0x58,0x3b,0x3b,0x02,0x02,0x3b,0x3b,0x58,0x58,0x3b,0x3b,0x02,0xd0,0x27,0x21,0x21, + 0x14,0x13,0x13,0x14,0x21,0x21,0x27,0x27,0x21,0x21,0x14,0x13,0x13,0x14,0x21,0x21, + 0x27,0xf0,0x46,0x35,0x7e,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x7f,0x27,0x01,0x02,0x3b, + 0x3b,0x58,0x58,0x3b,0x3b,0x02,0x02,0x3b,0x3b,0x58,0x90,0x13,0x13,0x22,0x22,0x26, + 0x26,0x22,0x22,0x13,0x13,0x13,0x13,0x22,0x22,0x26,0x26,0x22,0x22,0x13,0x13,0x00, + 0x00,0x01,0x00,0x00,0x00,0x20,0x01,0xc0,0x01,0x60,0x00,0x1e,0x00,0x00,0x01,0x16, + 0x15,0x31,0x31,0x14,0x07,0x01,0x31,0x06,0x23,0x22,0x27,0x27,0x31,0x26,0x35,0x34, + 0x37,0x36,0x33,0x32,0x17,0x17,0x31,0x37,0x31,0x36,0x33,0x32,0x17,0x01,0xb7,0x09, + 0x09,0xff,0x00,0x0a,0x0d,0x0d,0x0a,0x80,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x69,0xe9, + 0x0a,0x0d,0x0d,0x0a,0x01,0x57,0x0a,0x0d,0x0d,0x0a,0xff,0x00,0x09,0x09,0x80,0x0a, + 0x0d,0x0d,0x0a,0x09,0x09,0x6a,0xea,0x09,0x09,0x00,0x00,0x03,0xff,0xfb,0xff,0xe0, + 0x02,0x05,0x01,0xa0,0x00,0x12,0x00,0x1f,0x00,0x32,0x00,0x00,0x01,0x16,0x17,0x13, + 0x31,0x16,0x07,0x06,0x07,0x21,0x31,0x26,0x27,0x26,0x37,0x13,0x31,0x36,0x37,0x15, + 0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x17,0x34,0x27,0x31, + 0x31,0x26,0x23,0x22,0x07,0x06,0x15,0x14,0x17,0x16,0x33,0x32,0x37,0x36,0x35,0x01, + 0x00,0x17,0x0c,0xd8,0x0a,0x0a,0x0c,0x17,0xfe,0x50,0x17,0x0c,0x0a,0x0a,0xd9,0x0c, + 0x16,0x16,0x02,0x02,0x16,0x16,0x02,0x02,0x16,0x20,0x09,0x09,0x0e,0x0e,0x09,0x09, + 0x09,0x09,0x0e,0x0e,0x09,0x09,0x01,0xa0,0x01,0x13,0xfe,0x90,0x14,0x14,0x13,0x01, + 0x01,0x13,0x14,0x14,0x01,0x70,0x13,0x01,0x80,0x02,0x16,0x70,0x16,0x02,0x02,0x16, + 0x70,0x16,0x02,0xe0,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, + 0x00,0x02,0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x29,0x00,0x5d,0x00,0x00, + 0x01,0x22,0x07,0x31,0x31,0x06,0x15,0x14,0x17,0x16,0x33,0x33,0x31,0x07,0x31,0x06, + 0x15,0x14,0x17,0x16,0x33,0x32,0x37,0x37,0x31,0x15,0x31,0x14,0x17,0x16,0x33,0x32, + 0x37,0x36,0x35,0x35,0x31,0x34,0x27,0x26,0x23,0x23,0x07,0x06,0x07,0x31,0x31,0x06, + 0x07,0x11,0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36,0x37,0x35,0x31,0x34, + 0x27,0x26,0x23,0x22,0x07,0x06,0x15,0x15,0x31,0x06,0x07,0x21,0x31,0x26,0x27,0x11, + 0x31,0x36,0x37,0x33,0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x23,0x01,0x40, + 0x0e,0x09,0x09,0x09,0x09,0x0e,0x53,0xca,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0xc9,0x09, + 0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0xa0,0xf0,0x22,0x17,0x16,0x01,0x01,0x16, + 0x17,0x22,0x01,0x40,0x22,0x17,0x16,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x01,0x0f, + 0xfe,0xc0,0x0f,0x01,0x01,0x0f,0x70,0x0e,0x09,0x09,0x09,0x09,0x0e,0x70,0x01,0xc0, + 0x09,0x09,0x0e,0x0e,0x09,0x09,0xc9,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0xca,0x53,0x0e, + 0x09,0x09,0x09,0x09,0x0e,0xa0,0x0e,0x09,0x09,0x20,0x01,0x16,0x17,0x22,0xfe,0xc0, + 0x22,0x17,0x16,0x01,0x01,0x16,0x17,0x22,0x70,0x0e,0x09,0x09,0x09,0x09,0x0e,0x70, + 0x0f,0x01,0x01,0x0f,0x01,0x40,0x0f,0x01,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x02, + 0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x19,0x00,0x22,0x00,0x00,0x17,0x26, + 0x35,0x31,0x31,0x34,0x37,0x01,0x31,0x36,0x33,0x32,0x17,0x17,0x31,0x16,0x15,0x14, + 0x07,0x01,0x31,0x06,0x23,0x22,0x27,0x27,0x01,0x37,0x07,0x37,0x27,0x31,0x07,0x31, + 0x17,0x0e,0x0e,0x0e,0x01,0x7d,0x0f,0x13,0x13,0x0f,0x23,0x0e,0x0e,0xfe,0x83,0x0f, + 0x13,0x14,0x0e,0x23,0x01,0x4e,0x69,0x69,0x69,0x18,0x69,0x18,0x0f,0x0e,0x14,0x13, + 0x0f,0x01,0x7d,0x0e,0x0e,0x23,0x0e,0x14,0x13,0x0f,0xfe,0x83,0x0e,0x0e,0x23,0x01, + 0x13,0x69,0x69,0x69,0x18,0x69,0x18,0x00,0x00,0x01,0x00,0x10,0xff,0xd9,0x01,0xe7, + 0x01,0xa7,0x00,0x46,0x00,0x00,0x13,0x33,0x23,0x33,0x32,0x17,0x16,0x15,0x14,0x07, + 0x06,0x23,0x23,0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x34,0x37,0x36,0x33,0x32,0x17, + 0x16,0x15,0x15,0x31,0x37,0x31,0x36,0x37,0x36,0x17,0x16,0x17,0x16,0x17,0x16,0x07, + 0x06,0x07,0x06,0x07,0x06,0x27,0x26,0x27,0x26,0x35,0x34,0x37,0x36,0x33,0x32,0x17, + 0x16,0x33,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x22,0x07,0x07,0x7e,0x32,0x32, + 0x32,0x0e,0x09,0x09,0x09,0x09,0x0e,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09, + 0x09,0x12,0x2c,0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x2c,0x39, + 0x39,0x39,0x39,0x2c,0x0a,0x0a,0x09,0x0d,0x0d,0x0a,0x31,0x40,0x40,0x31,0x2f,0x2f, + 0x31,0x40,0x40,0x31,0x11,0x01,0x20,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, + 0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x33,0x11,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x2c, + 0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x09,0x0d,0x0d,0x0a,0x09, + 0x09,0x2f,0x2f,0x31,0x40,0x40,0x31,0x2f,0x2f,0x11,0x00,0x02,0x00,0x00,0xff,0xe0, + 0x01,0xc0,0x01,0xa0,0x00,0x19,0x00,0x3b,0x00,0x00,0x13,0x06,0x07,0x31,0x31,0x06, + 0x07,0x11,0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36,0x37,0x11,0x31,0x26, + 0x27,0x26,0x27,0x21,0x13,0x35,0x15,0x35,0x23,0x31,0x26,0x27,0x36,0x37,0x33,0x31, + 0x35,0x31,0x36,0x37,0x16,0x17,0x15,0x31,0x33,0x31,0x16,0x17,0x06,0x07,0x23,0x31, + 0x15,0x31,0x06,0x07,0x26,0x27,0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x01, + 0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x88,0x40,0x16,0x02,0x02, + 0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02, + 0x01,0xa0,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b, + 0x01,0x40,0x1b,0x12,0x12,0x01,0xfe,0xc8,0x40,0x40,0x40,0x02,0x16,0x16,0x02,0x40, + 0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x00,0x02, + 0x00,0x00,0xff,0xc0,0x02,0x00,0x01,0xc0,0x00,0x18,0x00,0x8b,0x00,0x00,0x01,0x16, + 0x17,0x31,0x31,0x16,0x17,0x15,0x31,0x14,0x07,0x06,0x23,0x23,0x31,0x22,0x27,0x26, + 0x35,0x35,0x31,0x36,0x37,0x36,0x37,0x07,0x36,0x33,0x31,0x31,0x32,0x17,0x17,0x31, + 0x16,0x17,0x30,0x15,0x36,0x33,0x33,0x31,0x32,0x17,0x34,0x37,0x37,0x31,0x36,0x33, + 0x32,0x17,0x16,0x15,0x14,0x07,0x07,0x31,0x06,0x07,0x16,0x17,0x33,0x31,0x32,0x17, + 0x16,0x15,0x14,0x07,0x06,0x23,0x23,0x31,0x14,0x07,0x16,0x17,0x17,0x31,0x16,0x15, + 0x14,0x07,0x06,0x23,0x22,0x27,0x27,0x31,0x06,0x07,0x35,0x31,0x26,0x27,0x06,0x07, + 0x15,0x31,0x26,0x27,0x07,0x31,0x06,0x23,0x22,0x27,0x26,0x35,0x34,0x37,0x37,0x31, + 0x36,0x37,0x26,0x35,0x23,0x31,0x22,0x27,0x26,0x35,0x34,0x37,0x36,0x33,0x33,0x31, + 0x36,0x37,0x26,0x27,0x27,0x31,0x26,0x35,0x34,0x37,0x01,0x00,0x29,0x1b,0x1b,0x01, + 0x08,0x08,0x0c,0x87,0x0d,0x08,0x08,0x01,0x1b,0x1b,0x29,0xd7,0x0a,0x0d,0x0d,0x0a, + 0x40,0x01,0x01,0x15,0x1a,0x70,0x1a,0x16,0x01,0x40,0x0a,0x0d,0x0d,0x0a,0x09,0x09, + 0x40,0x01,0x01,0x09,0x02,0x40,0x0e,0x09,0x09,0x09,0x09,0x0e,0x40,0x0f,0x03,0x03, + 0x40,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x3f,0x25,0x35,0x01,0x0f,0x0f,0x01,0x35,0x25, + 0x3f,0x0a,0x0d,0x0d,0x0a,0x09,0x09,0x40,0x03,0x03,0x0f,0x40,0x0e,0x09,0x09,0x09, + 0x09,0x0e,0x40,0x02,0x09,0x01,0x01,0x40,0x09,0x09,0x01,0xc0,0x01,0x1b,0x1b,0x29, + 0x04,0x0c,0x08,0x08,0x08,0x08,0x0c,0x04,0x29,0x1b,0x1b,0x01,0x69,0x09,0x09,0x40, + 0x01,0x01,0x01,0x0c,0x0c,0x02,0x01,0x40,0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x40,0x01, + 0x01,0x12,0x15,0x09,0x09,0x0e,0x0e,0x09,0x09,0x25,0x20,0x02,0x02,0x40,0x0a,0x0d, + 0x0d,0x0a,0x09,0x09,0x3f,0x21,0x06,0xef,0x0f,0x01,0x01,0x0f,0xef,0x06,0x21,0x3f, + 0x09,0x09,0x0a,0x0d,0x0d,0x0a,0x40,0x02,0x02,0x20,0x25,0x09,0x09,0x0e,0x0e,0x09, + 0x09,0x15,0x12,0x01,0x01,0x40,0x0a,0x0d,0x0d,0x0a,0x00,0x05,0x00,0x00,0xff,0xc0, + 0x01,0xc0,0x01,0xc0,0x00,0x1f,0x00,0x30,0x00,0x3d,0x00,0x4a,0x00,0x57,0x00,0x00, + 0x13,0x36,0x37,0x33,0x31,0x16,0x17,0x17,0x31,0x33,0x31,0x32,0x17,0x16,0x15,0x14, + 0x07,0x06,0x23,0x21,0x31,0x22,0x27,0x26,0x35,0x34,0x37,0x36,0x33,0x33,0x31,0x37, + 0x07,0x21,0x21,0x21,0x11,0x31,0x06,0x07,0x06,0x07,0x21,0x31,0x26,0x27,0x26,0x27, + 0x11,0x17,0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x33,0x06, + 0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x33,0x06,0x07,0x15,0x31, + 0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x87,0x09,0x14,0x78,0x14,0x09,0x07,0x60, + 0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x60,0x07, + 0x67,0x01,0x80,0xfe,0x80,0x01,0x80,0x01,0x12,0x12,0x1b,0xff,0x00,0x1b,0x12,0x12, + 0x01,0x60,0x0f,0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f,0x60,0x0f,0x01,0x01,0x0f,0x0f, + 0x01,0x01,0x0f,0x60,0x0f,0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f,0x01,0xae,0x11,0x01, + 0x01,0x11,0x0e,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x0e, + 0x6e,0xfe,0xc0,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x01,0x40,0x40,0x01,0x0f, + 0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0, + 0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x00,0x02,0xff,0xfd, + 0xff,0xbd,0x01,0xff,0x01,0xbf,0x00,0x11,0x00,0x24,0x00,0x00,0x01,0x07,0x37,0x07, + 0x17,0x31,0x37,0x31,0x36,0x35,0x34,0x27,0x27,0x31,0x26,0x23,0x22,0x07,0x07,0x07, + 0x37,0x07,0x06,0x07,0x07,0x31,0x06,0x17,0x16,0x37,0x37,0x31,0x36,0x37,0x37,0x31, + 0x27,0x01,0x6b,0x31,0x31,0x31,0x82,0x31,0x12,0x12,0x28,0x13,0x1a,0x19,0x14,0x47, + 0xe9,0xe9,0xe9,0x10,0x07,0x23,0x04,0x0a,0x0a,0x0e,0x78,0x15,0x10,0xea,0x82,0x01, + 0xad,0x31,0x31,0x31,0x82,0x31,0x13,0x1a,0x19,0x14,0x28,0x12,0x12,0x47,0xea,0xea, + 0xea,0x0f,0x16,0x78,0x0e,0x0a,0x0a,0x04,0x23,0x07,0x0f,0xea,0x82,0x00,0x00,0x04, + 0xff,0xfe,0xff,0xbe,0x01,0xc2,0x01,0xc0,0x00,0x20,0x00,0x33,0x00,0x46,0x00,0x79, + 0x00,0x00,0x01,0x14,0x07,0x31,0x31,0x06,0x07,0x15,0x31,0x14,0x07,0x06,0x23,0x23, + 0x31,0x22,0x27,0x26,0x35,0x35,0x31,0x26,0x27,0x26,0x35,0x36,0x37,0x36,0x37,0x16, + 0x17,0x16,0x17,0x07,0x32,0x37,0x31,0x31,0x36,0x35,0x34,0x27,0x26,0x23,0x22,0x07, + 0x06,0x15,0x14,0x17,0x16,0x33,0x37,0x34,0x27,0x31,0x31,0x26,0x23,0x22,0x07,0x06, + 0x15,0x14,0x17,0x16,0x33,0x32,0x37,0x36,0x35,0x05,0x36,0x37,0x31,0x31,0x36,0x17, + 0x17,0x31,0x37,0x31,0x36,0x17,0x16,0x17,0x16,0x07,0x06,0x07,0x07,0x31,0x17,0x31, + 0x16,0x17,0x16,0x07,0x06,0x07,0x06,0x27,0x27,0x31,0x07,0x31,0x06,0x27,0x26,0x27, + 0x26,0x37,0x36,0x37,0x37,0x31,0x27,0x31,0x26,0x27,0x26,0x37,0x01,0x70,0x12,0x11, + 0x1d,0x09,0x09,0x0e,0x60,0x0e,0x09,0x09,0x1d,0x11,0x12,0x02,0x28,0x29,0x3d,0x3d, + 0x29,0x28,0x02,0xc8,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, + 0x90,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0xfe,0xcb,0x07, + 0x0c,0x0c,0x0c,0xb2,0xb2,0x0c,0x0c,0x0c,0x07,0x05,0x04,0x04,0x0c,0x86,0x86,0x0c, + 0x04,0x04,0x05,0x07,0x0c,0x0c,0x0c,0xb2,0xb2,0x0c,0x0c,0x0c,0x07,0x05,0x04,0x04, + 0x0c,0x86,0x86,0x0c,0x04,0x04,0x05,0x01,0x40,0x22,0x1b,0x1c,0x11,0x16,0x0e,0x09, + 0x09,0x09,0x09,0x0e,0x16,0x11,0x1c,0x1b,0x22,0x36,0x25,0x24,0x01,0x01,0x24,0x25, + 0x36,0x30,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x20,0x0e, + 0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x82,0x0c,0x04,0x04,0x05, + 0x59,0x59,0x05,0x04,0x04,0x0c,0x0c,0x0c,0x0c,0x07,0x43,0x43,0x07,0x0c,0x0c,0x0c, + 0x0c,0x04,0x04,0x05,0x59,0x59,0x05,0x04,0x04,0x0c,0x0c,0x0c,0x0c,0x07,0x43,0x43, + 0x07,0x0c,0x0c,0x0c,0x00,0x02,0x00,0x00,0x00,0x60,0x01,0xc0,0x01,0x20,0x00,0x15, + 0x00,0x2b,0x00,0x00,0x37,0x22,0x07,0x31,0x31,0x06,0x15,0x14,0x17,0x16,0x33,0x21, + 0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x21,0x35,0x22,0x07,0x31,0x31,0x06, + 0x15,0x14,0x17,0x16,0x33,0x21,0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x21, + 0x20,0x0e,0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe, + 0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe, + 0x80,0xa0,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x80,0x09, + 0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x00,0x01,0x00,0x00, + 0x00,0x0e,0x07,0x83,0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x51, - 0x00,0xa4,0x00,0xd1,0x01,0x48,0x01,0xa9,0x01,0xfb,0x02,0xa9,0x03,0x22,0x03,0x5b, - 0x03,0x97,0x00,0x00,0x00,0x01,0x00,0x00,0x03,0x07,0x05,0x00,0xdb,0x57,0x4a,0x7f, - 0x5f,0x0f,0x3c,0xf5,0x00,0x0b,0x02,0x00,0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93, - 0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93,0xff,0xf4,0xff,0xb5,0x02,0x8b,0x01,0xcb, - 0x00,0x00,0x00,0x08,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00, - 0x02,0x00,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x00,0x02,0x00,0x00,0x10, - 0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0xff,0xfd, - 0x01,0xc0,0x00,0x00,0x00,0x01,0x00,0x00,0x01,0xcb,0xff,0xb5,0x00,0x00,0x02,0x80, - 0xff,0xf4,0xff,0xf5,0x02,0x8b,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0a,0x00,0x04,0x02,0x03,0x03,0x84,0x00,0x05, - 0x00,0x00,0x01,0x4c,0x01,0x66,0x00,0x00,0x00,0x47,0x01,0x4c,0x01,0x66,0x00,0x00, - 0x00,0xf5,0x00,0x19,0x00,0x84,0x00,0x00,0x02,0x00,0x09,0x03,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x41,0x57,0x53,0x4d,0x00,0x80,0xf0,0x02,0xf7,0xa4,0x01,0xcb,0xff,0xb5, - 0x00,0x00,0x01,0xcb,0x00,0x4b,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x01,0x41, - 0x01,0xaf,0x00,0x00,0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x03, - 0x00,0x00,0x00,0x14,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x14,0x00,0x04,0x00,0x60, - 0x00,0x00,0x00,0x14,0x00,0x10,0x00,0x03,0x00,0x04,0xf0,0x02,0xf0,0x0c,0xf0,0x8e, - 0xf0,0xe2,0xf0,0xfe,0xf1,0x88,0xf2,0xed,0xf3,0x04,0xf7,0xa4,0xff,0xff,0x00,0x00, - 0xf0,0x02,0xf0,0x0c,0xf0,0x8e,0xf0,0xe2,0xf0,0xfe,0xf1,0x88,0xf2,0xed,0xf3,0x04, - 0xf7,0xa4,0xff,0xff,0x0f,0xff,0x0f,0xf6,0x0f,0x75,0x0f,0x22,0x0f,0x07,0x0e,0x7e, - 0x0d,0x1a,0x0d,0x04,0x08,0x65,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x07, - 0x00,0x5a,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x00,0x00,0x34,0x00,0x00,0x00,0x03, - 0x00,0x01,0x04,0x09,0x00,0x01,0x00,0x32,0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09, - 0x00,0x02,0x00,0x0a,0x00,0x66,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x03,0x00,0x3e, - 0x00,0x70,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x04,0x00,0x32,0x00,0x34,0x00,0x03, - 0x00,0x01,0x04,0x09,0x00,0x05,0x00,0x64,0x00,0xae,0x00,0x03,0x00,0x01,0x04,0x09, - 0x00,0x06,0x00,0x2c,0x01,0x12,0x00,0x43,0x00,0x6f,0x00,0x70,0x00,0x79,0x00,0x72, - 0x00,0x69,0x00,0x67,0x00,0x68,0x00,0x74,0x00,0x20,0x00,0x28,0x00,0x63,0x00,0x29, - 0x00,0x20,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77, - 0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x46,0x00,0x6f,0x00,0x6e, - 0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d, - 0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65, - 0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x53,0x00,0x6f, - 0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20, - 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20, - 0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53, - 0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x2d,0x00,0x36,0x00,0x2e,0x00,0x37, - 0x00,0x2e,0x00,0x32,0x00,0x56,0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f, - 0x00,0x6e,0x00,0x20,0x00,0x37,0x00,0x37,0x00,0x35,0x00,0x2e,0x00,0x30,0x00,0x31, - 0x00,0x39,0x00,0x35,0x00,0x33,0x00,0x31,0x00,0x32,0x00,0x35,0x00,0x20,0x00,0x28, - 0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65, - 0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x76,0x00,0x65,0x00,0x72, - 0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x3a,0x00,0x20,0x00,0x36,0x00,0x2e, - 0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x29,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74, - 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x36, - 0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x2d,0x00,0x53,0x00,0x6f,0x00,0x6c, - 0x00,0x69,0x00,0x64,0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0xdb,0x00,0x19, + 0x00,0xb7,0x01,0x0a,0x01,0x37,0x01,0x82,0x01,0xf9,0x02,0x2e,0x02,0x8f,0x02,0xe1, + 0x03,0x8f,0x04,0x08,0x04,0x41,0x04,0xe4,0x05,0x20,0x00,0x00,0x00,0x01,0x00,0x00, + 0x03,0x07,0x05,0x00,0x86,0x49,0xee,0x35,0x5f,0x0f,0x3c,0xf5,0x00,0x0b,0x02,0x00, + 0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93,0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93, + 0xff,0xf4,0xff,0xb5,0x02,0x8b,0x01,0xcb,0x00,0x00,0x00,0x08,0x00,0x02,0x00,0x00, + 0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00,0x01,0x40,0x00,0x10,0x02,0x00,0x00,0x00, + 0x01,0xc0,0x00,0x00,0x02,0x00,0xff,0xfb,0x02,0x00,0x00,0x00,0x02,0x00,0x00,0x00, + 0x02,0x00,0x00,0x10,0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x00,0x01,0xc0,0x00,0x00, + 0x02,0x00,0xff,0xfd,0x01,0xc0,0xff,0xfe,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, + 0x01,0xcb,0xff,0xb5,0x00,0x00,0x02,0x80,0xff,0xf4,0xff,0xf5,0x02,0x8b,0x00,0x01, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0d, + 0x00,0x04,0x02,0x03,0x03,0x84,0x00,0x05,0x00,0x00,0x01,0x4c,0x01,0x66,0x00,0x00, + 0x00,0x47,0x01,0x4c,0x01,0x66,0x00,0x00,0x00,0xf5,0x00,0x19,0x00,0x84,0x00,0x00, + 0x02,0x00,0x09,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x41,0x57,0x53,0x4d,0x00,0x80, + 0xf0,0x02,0xf7,0xa4,0x01,0xcb,0xff,0xb5,0x00,0x00,0x01,0xcb,0x00,0x4b,0x00,0x00, + 0x00,0x01,0x00,0x00,0x00,0x00,0x01,0x41,0x01,0xaf,0x00,0x00,0x00,0x20,0x00,0x00, + 0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x14,0x00,0x03,0x00,0x01, + 0x00,0x00,0x00,0x14,0x00,0x04,0x00,0x80,0x00,0x00,0x00,0x1c,0x00,0x10,0x00,0x03, + 0x00,0x0c,0xf0,0x02,0xf0,0x0c,0xf0,0x71,0xf0,0x8e,0xf0,0xd0,0xf0,0xe2,0xf0,0xfe, + 0xf1,0x28,0xf1,0x88,0xf2,0xed,0xf3,0x04,0xf7,0x14,0xf7,0xa4,0xff,0xff,0x00,0x00, + 0xf0,0x02,0xf0,0x0c,0xf0,0x71,0xf0,0x8e,0xf0,0xd0,0xf0,0xe2,0xf0,0xfe,0xf1,0x28, + 0xf1,0x88,0xf2,0xed,0xf3,0x04,0xf7,0x14,0xf7,0xa4,0xff,0xff,0x10,0x00,0x0f,0xf7, + 0x0f,0x93,0x0f,0x77,0x0f,0x36,0x0f,0x25,0x0f,0x0a,0x0e,0xd9,0x0e,0x81,0x0d,0x1d, + 0x0d,0x07,0x08,0xf8,0x08,0x69,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x07,0x00,0x5a,0x00,0x03,0x00,0x01,0x04,0x09, + 0x00,0x00,0x00,0x34,0x00,0x00,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x01,0x00,0x32, + 0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x02,0x00,0x0a,0x00,0x66,0x00,0x03, + 0x00,0x01,0x04,0x09,0x00,0x03,0x00,0x3e,0x00,0x70,0x00,0x03,0x00,0x01,0x04,0x09, + 0x00,0x04,0x00,0x32,0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x05,0x00,0x64, + 0x00,0xae,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x06,0x00,0x2c,0x01,0x12,0x00,0x43, + 0x00,0x6f,0x00,0x70,0x00,0x79,0x00,0x72,0x00,0x69,0x00,0x67,0x00,0x68,0x00,0x74, + 0x00,0x20,0x00,0x28,0x00,0x63,0x00,0x29,0x00,0x20,0x00,0x46,0x00,0x6f,0x00,0x6e, + 0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d, + 0x00,0x65,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77, + 0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20, + 0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c, + 0x00,0x69,0x00,0x64,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x46, + 0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73, + 0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72, + 0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64, + 0x00,0x2d,0x00,0x36,0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x56,0x00,0x65, + 0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x20,0x00,0x37,0x00,0x37, + 0x00,0x35,0x00,0x2e,0x00,0x30,0x00,0x31,0x00,0x39,0x00,0x35,0x00,0x33,0x00,0x31, + 0x00,0x32,0x00,0x35,0x00,0x20,0x00,0x28,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74, + 0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65, + 0x00,0x20,0x00,0x76,0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e, + 0x00,0x3a,0x00,0x20,0x00,0x36,0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x29, + 0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73, + 0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x36,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65, + 0x00,0x2d,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x03,0x00,0x00, + 0x00,0x00,0x00,0x00,0xff,0xdb,0x00,0x19,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, }; diff --git a/src/mapcheck/filter.cpp b/src/mapcheck/filter.cpp new file mode 100644 index 0000000..7b485b2 --- /dev/null +++ b/src/mapcheck/filter.cpp @@ -0,0 +1,190 @@ +#include "mapcheck/filter.hpp" + +#include +#include +#include +#include + +namespace ppc::mapcheck { + +bool asks_about_item(std::string_view term) { + for (size_t i = 0; i < term.size(); ++i) { + const char c = term[i]; + if (c == ':') return i > 0; + // Letters and spaces only, so a pattern that merely contains a colon — `(?:…)`, a + // character class — is not mistaken for a keyword. + if (!std::isalpha(static_cast(c)) && c != ' ') return false; + } + return false; +} + +std::vector parse_search(std::string_view s) { + std::vector out; + size_t i = 0; + while (i < s.size()) { + if (std::isspace(static_cast(s[i]))) { ++i; continue; } + SearchTerm t; + // Outside the quotes as well as inside: a generator writes `"!a|b"` and a player types + // `!"a b"`, and both mean the same thing. + if (s[i] == '!') { t.negated = true; ++i; } + if (i < s.size() && s[i] == '"') { + const size_t end = s.find('"', ++i); + // Unterminated runs to the end rather than being dropped: the string is being + // typed, and a filter that empties itself on the opening quote is unusable. + t.text = std::string(s.substr(i, end == std::string_view::npos ? end : end - i)); + i = end == std::string_view::npos ? s.size() : end + 1; + } else { + const size_t end = std::min(s.find(' ', i), std::min(s.find('\t', i), s.find('\n', i))); + t.text = std::string(s.substr(i, end == std::string_view::npos ? end : end - i)); + i = end == std::string_view::npos ? s.size() : end; + } + if (!t.negated && !t.text.empty() && t.text.front() == '!') { + t.negated = true; + t.text.erase(t.text.begin()); + } + if (!t.text.empty()) out.push_back(std::move(t)); + } + return out; +} + +struct SearchFilter::Impl { + struct Term { + std::regex re; + bool valid = true; ///< false when the term would not compile, and it then hits nothing + bool negated = false; + + bool hits(std::span lines) const; + }; + std::vector terms; + size_t set_aside = 0; +}; + +bool SearchFilter::Impl::Term::hits(std::span lines) const { + if (!valid) return false; + for (const std::string& line : lines) { + // A pattern that compiles can still exhaust the stack on a pathological input; + // std::regex reports that as an exception, and one term is not worth the run. + try { + if (std::regex_search(line, re)) return true; + } catch (const std::regex_error&) { + } + } + return false; +} + +SearchFilter::SearchFilter() : impl_(std::make_unique()) {} + +SearchFilter::SearchFilter(std::string_view s) : impl_(std::make_unique()) { + for (SearchTerm& t : parse_search(s)) { + if (asks_about_item(t.text)) { + ++impl_->set_aside; + continue; + } + Impl::Term term; + term.negated = t.negated; + try { + term.re = std::regex(t.text, std::regex::ECMAScript | std::regex::icase | + std::regex::optimize); + } catch (const std::regex_error&) { + // Nothing, rather than the literal text it is made of: see the header. A half-typed + // pattern emptying the list is the same thing the game's own box does, and it is + // the price of the box having one syntax instead of two. + term.valid = false; + } + impl_->terms.push_back(std::move(term)); + } +} + +SearchFilter::SearchFilter(SearchFilter&&) noexcept = default; +SearchFilter& SearchFilter::operator=(SearchFilter&&) noexcept = default; +SearchFilter::~SearchFilter() = default; + +bool SearchFilter::empty() const { return impl_->terms.empty(); } +size_t SearchFilter::size() const { return impl_->terms.size(); } +size_t SearchFilter::set_aside() const { return impl_->set_aside; } + +bool SearchFilter::matches(std::span lines) const { + for (const Impl::Term& t : impl_->terms) + if (t.hits(lines) == t.negated) return false; + return true; +} + +SearchFilter::Hit SearchFilter::classify(std::span lines) const { + bool wanted = false; + for (const Impl::Term& t : impl_->terms) { + if (!t.hits(lines)) continue; + if (t.negated) return Hit::Unwanted; // the strongest thing a search string says + wanted = true; + } + return wanted ? Hit::Wanted : Hit::None; +} + +std::string render_wording(std::string_view ref, std::optional value) { + if (!value || ref.find('#') == std::string_view::npos) return std::string(ref); + char buf[32]; + // Trailing zeros off: the game prints "20% increased", never "20.00% increased", and a + // term ending in `0%$` would match the wrong one of the two. + if (*value == static_cast(*value)) + std::snprintf(buf, sizeof buf, "%lld", static_cast(*value)); + else + std::snprintf(buf, sizeof buf, "%.2f", *value); + std::string out; + out.reserve(ref.size() + 8); + for (const char c : ref) { + if (c == '#') out += buf; + else out += c; + } + return out; +} + +std::string printed_wording(const data::Stat* rec, std::string_view ref, + std::optional value) { + if (!rec || rec->matchers.empty()) return render_wording(ref, value); + // Below zero the game says the stat the other way round, which is exactly what a `negate` + // matcher is. At or above it, the plain wording. + const bool below = value && *value < 0; + for (const data::StatMatcher& m : rec->matchers) { + if (m.negate != below) continue; + return render_wording(m.string, below ? std::optional(-*value) : value); + } + return render_wording(ref, value); +} + +std::string display_wording(const data::Stat* rec, const data::PoolStat& s) { + if (!rec || !s.min || !s.max || *s.min >= 0 || *s.max >= 0) return s.ref; + for (const data::StatMatcher& m : rec->matchers) + if (m.negate) return m.string; + return s.ref; +} + +namespace { + +/// Every line one wording can print, at either end of what it rolls. +void push_wordings(std::vector& out, const data::PoolStat& s, const data::Stat* rec) { + out.push_back(printed_wording(rec, s.ref, s.max)); + if (!s.min || s.min == s.max) return; + std::string lo = printed_wording(rec, s.ref, s.min); + if (lo != out.back()) out.push_back(std::move(lo)); +} + +} // namespace + +std::vector matchable_lines(const data::PoolMod& m, + std::span recs) { + std::vector out; + out.reserve(m.stats.size() * 2 + 1); + for (size_t i = 0; i < m.stats.size(); ++i) + push_wordings(out, m.stats[i], i < recs.size() ? recs[i] : nullptr); + if (!m.name.empty()) out.push_back(m.name); + return out; +} + +std::vector matchable_lines(const data::PoolMod& m, const data::GameData* gd) { + std::vector recs; + recs.reserve(m.stats.size()); + for (const data::PoolStat& s : m.stats) + recs.push_back(gd ? gd->find_stat_by_ref(s.ref) : nullptr); + return matchable_lines(m, std::span(recs)); +} + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/filter.hpp b/src/mapcheck/filter.hpp new file mode 100644 index 0000000..a7e43bd --- /dev/null +++ b/src/mapcheck/filter.hpp @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "data/game_data.hpp" +#include "data/types.hpp" + +/// Path of Exile's item-search syntax — the strings players already keep for the map device, +/// and what a tool like poe.re writes. +/// +/// **GGG publishes no grammar, but the community has written one down**, and it agrees with +/// what [docs/roadmap.md](../../docs/roadmap.md) fixed for this project before any of this was +/// written. The reference is [Guide:Regex](https://www.poewiki.net/wiki/Guide:Regex), and every +/// rule below is from it rather than inferred: +/// +/// - an **unquoted space is a logical AND**, and every search field in the game takes the same +/// syntax, so a string kept for the stash is a string that works here; +/// - **double quotes group a term that contains spaces**, and only that — a quoted term is still +/// expanded as a pattern, so `|` inside quotes is still an alternation and not a literal; +/// - a leading **`!` negates**, inside the quotes or outside them (`!corrupted`, `"!(^str)"`); +/// - **string literals are case-insensitive**; +/// - **`^` and `$` anchor to a printed line**, which is why every term here is asked about each +/// of a modifier's wordings separately rather than about them joined. +/// +/// So `"!\d+ e|te of|ents$" pte` is two terms, the first negated. +/// +/// The terms themselves are regular expressions, matched here by `std::regex` in its ECMAScript +/// dialect. The game's is a custom engine, so the two can disagree on a corner — lookbehind is +/// the known one, since ECMAScript has none — which costs a proposed verdict the user is about +/// to confirm or reject anyway, and never anything the game does. +/// +/// **A term that will not compile matches nothing**, rather than falling back to the literal +/// text it is made of. The fallback was tried and is what made this box two search languages at +/// once: `Damage (` found a substring, `Damage (Fire|Cold)` found a pattern, and nothing on +/// screen said which reading a given term had got. One language, and an unfinished pattern +/// showing an empty list is what the game does too. +namespace ppc::mapcheck { + +/// Whether a term asks about the *item* rather than about one of its modifiers. +/// +/// The game's search has keywords — `ilvl:84`, `"rarity: rare"`, `ts:`, `"item level: 78"` — and +/// they are questions no modifier wording can answer. Left in, one of them ANDed into a filter +/// empties the list, which is what pasting a real map string would otherwise do here. +/// +/// **Recognised by shape, not by a list of the keywords.** A term is item-scope when it opens +/// with `:`; no wording in the pool contains a colon at all, checked against the published +/// bundle, so nothing a modifier could answer is caught by that. The bare keywords are +/// deliberately *not* recognised, because the wiki's own list is partial and the words are real +/// modifier text: `currency` alone appears in 17 wordings and `corrupted` in two, so treating +/// them as keywords would silently swallow the searches most worth typing. +bool asks_about_item(std::string_view term); + +/// One term, with the `!` and the quotes taken off. +struct SearchTerm { + std::string text; + bool negated = false; +}; + +/// Split a search string into its terms. An unterminated quote runs to the end of the string, +/// which is what makes a search usable while it is still being typed. +std::vector parse_search(std::string_view s); + +/// A parsed search, ready to be asked about a modifier. +/// +/// Every term is tested against each line **on its own** rather than against the lines joined: +/// these strings were written against a tooltip, where `$` means the end of a printed line, and +/// a join would put that anchor somewhere no term's author has ever seen. A term hits a +/// modifier when it hits any of its lines, which is also the answer to what a modifier printing +/// two to four of them counts as. +/// +/// **The two questions below read the same terms differently, and have to.** The game ANDs +/// terms because it is deciding about a whole *map*, where each term can be answered by a +/// different modifier on it. Here the subject is one modifier, and a modifier cannot satisfy +/// two unrelated wanted terms at once — so proposing a verdict asks each term separately, which +/// is also what [ROADMAP.md](../../ROADMAP.md) promises ("every modifier an excluding term hits +/// is proposed dangerous, every one a wanted term hits proposed safe"). Filtering keeps the +/// game's rule, because there the subject really is "show me the rows matching all of this". +class SearchFilter { +public: + SearchFilter(); + explicit SearchFilter(std::string_view s); + SearchFilter(SearchFilter&&) noexcept; + SearchFilter& operator=(SearchFilter&&) noexcept; + ~SearchFilter(); + + /// Whether nothing here can be asked about a modifier — which a search made only of + /// item-scope keywords is, and which has to read the same as an empty box rather than as a + /// filter matching nothing. + bool empty() const; + /// Terms that will be asked. + size_t size() const; + /// Terms `asks_about_item` set aside, so a screen can say so instead of leaving the user to + /// work out why a word they typed made no difference. + size_t set_aside() const; + + /// **Filtering**, in the game's own reading: every positive term must hit and no negated + /// term may. This is what the settings list narrows on, and it is why typing two plain + /// words there means both of them. + bool matches(std::span lines) const; + + /// Which side of the search a modifier falls on. + /// + /// A **negated** term hitting says the player refuses this modifier, which is the strongest + /// thing a search string states and outranks a positive term that also hit. `None` is the + /// ordinary answer: most of the pool is not mentioned by any one search. + enum class Hit : uint8_t { None, Wanted, Unwanted }; + Hit classify(std::span lines) const; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +/// `ref` with every `#` replaced by `value`, as the game would print it. +/// +/// A search term was written against a printed line and can never match a placeholder, so a +/// pool entry has to be rendered before it can be tested. `value` absent leaves the wording +/// alone, which is right for the wordings that print no number at all. +std::string render_wording(std::string_view ref, std::optional value); + +/// The line the game would actually print for this stat at `value` — which is very often not +/// the canonical wording. +/// +/// A stat record carries alternative wordings, and one of them may be flagged `negate`: the +/// same stat said the other way round, for a roll below zero. `Players have #% more Defences` +/// rolls `[-30, -25]` and the game therefore *always* prints `Players have 30% less Defences`. +/// Rendering the canonical wording gives `-25% more Defences`, a line no player has ever seen +/// and no search term was ever written against — which is how `s def` misses the single nastiest +/// defence modifier in the pool. 16 of the pool's wordings are in that position, and they are +/// disproportionately the ones a hardcore search string is about. +/// +/// This never affected a *verdict*: the popup keys on the stat record's `ref` and the matcher +/// resolves the printed line back to it, so a rating made here is read correctly off a real map +/// either way. It affected only being able to find the modifier and being shown what it says. +std::string printed_wording(const data::Stat* rec, std::string_view ref, + std::optional value); + +/// The placeholder wording to *show* for a pool entry: the negated alternative where the range +/// cannot produce anything but it, and the canonical one otherwise. A range straddling zero +/// keeps the canonical wording, because either is a line the game may print and the canonical +/// one is the record's identity. +std::string display_wording(const data::Stat* rec, const data::PoolStat& s); + +/// Everything a term may be tested against for one pool entry: every one of its wordings +/// rendered at both ends of its range, and the affix name. +/// +/// **The whole affix, because the affix is the unit.** A term hitting any one line is a term +/// about the modifier that prints it, and both things this feature does with a hit — narrowing +/// the list, proposing a verdict — are about an affix rather than about a wording. Asking per +/// stat instead was tried, and what it produced was a verdict keyed on one wording: a key that +/// short then speaks for every *other* affix granting the same line, which is the propagation +/// rule working exactly as written on a key that had no business being one wording long. +/// +/// **The affix name is in.** With Advanced Mod Descriptions on it is a line of the tooltip the +/// game's own search reads, so a string written there was written knowing it is matchable — +/// leaving it out would silently drop the terms that name one. +/// +/// **Both ends of the range, not just the top.** The old rule was the top alone, on the grounds +/// that these strings are written to catch the roll that ends a map — but a negated wording +/// prints the top of `[-30, -25]` as the *smallest* number it can say, so "the top" stops +/// meaning anything. A modifier can roll anywhere in its range, so a term naming a number hits +/// if any roll would say it. +std::vector matchable_lines(const data::PoolMod& m, const data::GameData* gd); + +/// The same, with the stat records handed over directly rather than looked up — `recs` runs +/// parallel to `m.stats`, and a stat it is too short for is read without one. What the lookup +/// form resolves to, and what a test can build without a bundle behind it. +std::vector matchable_lines(const data::PoolMod& m, + std::span recs); + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/rate.cpp b/src/mapcheck/rate.cpp new file mode 100644 index 0000000..96d92fd --- /dev/null +++ b/src/mapcheck/rate.cpp @@ -0,0 +1,155 @@ +#include "mapcheck/rate.hpp" + +#include +#include + +#include "mapcheck/filter.hpp" + +namespace ppc::mapcheck { +namespace { + +/// Everything the popup rates. +/// +/// **Implicits are in it.** They were left out on the argument that an implicit is what the base +/// came with rather than what it rolled — which is true of a Nightmare map saying it is one, and +/// false of the Vaal corruption implicits, which roll, which the pool carries as generation 5, +/// and which are rateable in Settings. Leaving them off the map was an inconsistency the design +/// admitted to and paid for in a line the reader could see and not decide about. +/// +/// Enchantments stay out — nothing that opens in the map device carries one — and so does +/// `Pseudo`, which is not a printed line at all. +bool rateable_type(data::ModType t) { + switch (t) { + case data::ModType::Implicit: + case data::ModType::Explicit: + case data::ModType::Fractured: + case data::ModType::Crafted: + case data::ModType::Veiled: + case data::ModType::Scourge: + case data::ModType::Crucible: + return true; + default: + return false; + } +} + +} // namespace + +int map_domain_of(const item::Item& it, const data::GameData* gd) { + if (gd) { + const int d = gd->mod_domain_for(it.base, it.item_class); + if (d) return d; + } + // The bundle said nothing — it predates the field, or the base did not resolve. The + // clipboard still did, and these are the same items read from the other side. + if (it.is_chart()) return kChartDomain; + if (it.is_map() || it.is_logbook() || it.is_map_fragment()) return kMapDomain; + return 0; +} + +bool is_map_device_item(const item::Item& it, const data::GameData* gd) { + const int d = map_domain_of(it, gd); + return std::find(std::begin(kDomains), std::end(kDomains), d) != std::end(kDomains); +} + +std::vector pool_key_refs(const data::PoolMod& m) { + std::vector out; + out.reserve(m.stats.size()); + for (const data::PoolStat& s : m.stats) + if (!s.ref.empty()) out.push_back(s.ref); + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +std::vector pool_groups(const data::GameData& gd) { + std::vector out; + std::map> at; + // `kDomains` in order, so the first entry to claim a key is the map's where a chart shares + // it — which is what makes the row show a map's wording without anything having to ask. + for (const int domain : kDomains) + for (const data::PoolMod* m : gd.mod_pool(domain)) { + std::vector refs = pool_key_refs(*m); + if (refs.empty()) continue; + const auto [it, fresh] = at.emplace(affix_key(refs), out.size()); + if (!fresh) { + out[it->second].all.push_back(m); + continue; + } + out.push_back(PoolGroup{m, {m}, std::move(refs)}); + } + return out; +} + +std::vector group_lines(const PoolGroup& g, const data::GameData* gd) { + std::vector out; + for (const data::PoolMod* m : g.all) { + std::vector lines = matchable_lines(*m, gd); + for (std::string& l : lines) + if (std::find(out.begin(), out.end(), l) == out.end()) out.push_back(std::move(l)); + } + return out; +} + +std::vector pool_refs_for(const std::vector& printed, int domain, + const data::GameData* gd) { + if (printed.empty() || !gd) return printed; + std::vector want = printed; + std::sort(want.begin(), want.end()); + + std::vector best, sorted; + for (const data::PoolMod* m : gd->mod_pool(domain)) { + if (m->stats.size() < want.size()) continue; + sorted.clear(); + for (const data::PoolStat& s : m->stats) + if (!s.ref.empty()) sorted.push_back(s.ref); + std::sort(sorted.begin(), sorted.end()); + sorted.erase(std::unique(sorted.begin(), sorted.end()), sorted.end()); + if (!std::includes(sorted.begin(), sorted.end(), want.begin(), want.end())) continue; + // The smallest entry that covers what was printed: a bigger one would be a different + // affix that happens to grant these as well. + if (!best.empty() && best.size() <= sorted.size()) continue; + best = sorted; + } + return best.empty() ? printed : best; +} + +std::vector rate(const item::Item& it, const Store& store, const data::GameData* gd) { + const int domain = map_domain_of(it, gd); + std::vector rows; + for (const item::Modifier& m : it.mods) { + if (!rateable_type(m.type)) continue; + // The second and later stats of one affix join the row the first opened — that grouping + // is the whole of what makes a verdict about an affix rather than about a wording, and + // only Advanced Mod Descriptions supplies it. Without it every line opens its own row, + // which is right for the affixes that grant one. + if (m.continuation && !rows.empty()) { + Row& r = rows.back(); + r.mods.push_back(&m); + if (m.match && m.match->stat) r.refs.push_back(m.match->stat->ref); + continue; + } + Row r; + r.mods.push_back(&m); + // The record's identity, never the printed line: see `Profile`. + if (m.match && m.match->stat) r.refs.push_back(m.match->stat->ref); + rows.push_back(std::move(r)); + } + for (Row& r : rows) { + if (!r.rateable()) continue; + r.refs = pool_refs_for(r.refs, domain, gd); + r.verdict = store.verdict_of(r.refs); + } + return rows; +} + +Tally tally(const std::vector& rows) { + Tally t; + // A row nothing could be keyed on counts as unrated rather than being left out: it is a + // modifier on the map and the reader has not decided about it, which is exactly what + // unrated means. Leaving it out would make a map of unreadable lines look fully rated. + for (const Row& r : rows) t.add(r.verdict); + return t; +} + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/rate.hpp b/src/mapcheck/rate.hpp new file mode 100644 index 0000000..8d81db5 --- /dev/null +++ b/src/mapcheck/rate.hpp @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +#include "data/game_data.hpp" +#include "item/item.hpp" +#include "mapcheck/store.hpp" +#include "mapcheck/verdict.hpp" + +namespace ppc::mapcheck { + +/// The two pools map check reads: the map device's, and the charts'. +/// +/// One list rather than a hard-coded name anywhere, because everything above it is written per +/// domain — the settings page, the lookups, the store. A third pool costs an entry here. +inline constexpr int kMapDomain = 5; +inline constexpr int kChartDomain = 39; +inline constexpr int kDomains[]{kMapDomain, kChartDomain}; + +/// Which pool this item rolls from, or 0 when nothing says. +/// +/// `GameData::mod_domain_for` is the answer where the bundle has one. A bundle published before +/// the field existed says 0 about everything, and rather than making the hotkey silently do +/// nothing on it, the parser's own reading of the item stands in — the same items, decided from +/// the clipboard instead of from the data. +int map_domain_of(const item::Item& it, const data::GameData* gd); + +/// True for anything that opens in the map device: ordinary, nightmare and Originator maps, +/// unique maps, charts, expedition logbooks and invitations. +/// +/// The gate on the hotkey, and the only one there is. It keeps a ring's modifiers out of a map +/// profile — the rating table is keyed on stats, so nothing would stop them going in. +bool is_map_device_item(const item::Item& it, const data::GameData* gd); + +/// One line of the popup's rateable list: **an affix** the item printed, and what the profile in +/// use says about it. +struct Row { + /// The modifiers this affix printed, in order — one for an ordinary affix, several for one + /// that grants several stats. Never empty. + std::vector mods; + /// The stat records' `ref`s the verdict is keyed on. **Empty when nothing resolved**, and + /// such a row is drawn and cannot be rated: there is nothing for a verdict to attach to, and + /// a printed line is not a key — it is language-dependent and two records can share one. + std::vector refs; + Verdict verdict = Verdict::Unrated; + + bool rateable() const { return !refs.empty(); } + const item::Modifier* mod() const { return mods.empty() ? nullptr : mods.front(); } +}; + +/// Every affix the popup rates, in the order the item printed them. +/// +/// **Implicits are in it**, and enchantments are not. See `rateable_type`: the Vaal corruption +/// implicits roll, the pool carries them, and a line that can be rated in Settings and not on the +/// map in front of you is the worse half of both rules. +/// +/// **Grouped by affix, which is what Advanced Mod Descriptions makes possible.** The parser +/// marks the second and later stats of one affix `continuation`, so `of the Juggernaut`'s three +/// lines are one row and one decision. Without that setting an item is a flat list of lines with +/// nothing saying where an affix ends, and each line stands alone — which still rates every +/// single-wording affix correctly, and is why this degrades rather than fails. +/// +/// `gd` is consulted to turn the affix's *printed* wordings into the pool entry's full set, +/// since an affix can grant stats the item does not print — the `#% more Currency found in Area` +/// on every Nightmare-map modifier is never on the tooltip. Without that step a verdict set in +/// Settings could never be found again from a map. +std::vector rate(const item::Item& it, const Store& store, const data::GameData* gd); + +/// The pool entry's full wording set for an affix that printed `refs`, or `refs` unchanged when +/// nothing in the pool covers them. +/// +/// The smallest superset wins. `Impaling` is two pool entries — the ordinary one granting only +/// `Monsters' Attacks have #% chance to Impale on Hit`, and the Nightmare one granting that plus +/// a reflect mechanic plus more currency — and a map printing the one line means the ordinary +/// one. A map printing both means the other, which no smaller entry can cover. +std::vector pool_refs_for(const std::vector& printed, int domain, + const data::GameData* gd); + +/// A pool entry's own wording set — what rating it in Settings keys on, and what +/// `pool_refs_for` resolves a map's printed affix to. +std::vector pool_key_refs(const data::PoolMod& m); + +/// One row of the settings pool browser: an **affix**, and every pool entry that grants +/// exactly its set of wordings. +/// +/// A map and a chart word 42 modifiers identically and roll them from pools of their own, so +/// one affix arrives here as two entries differing only in their ranges — `Resistant` is +/// `10-25` chaos on a map and `0-40` on a chart. The verdict store keys on the sorted ref set +/// and has no domain in it, so two such entries can never hold different verdicts: rating +/// either one rates both. **82 of 270 entries are in that position**, and two rows that must +/// always agree are one decision drawn twice. +struct PoolGroup { + /// What the row draws. The first domain in `kDomains` with an entry, so a map's wording + /// and affix name win over a chart's for the six groups where the two disagree. + const data::PoolMod* mod = nullptr; + /// Every entry sharing the key, `mod` among them. A search is tested against all of them, + /// so a term naming a number hits if *either* pool's range would print it. + std::vector all; + /// The affix key: sorted, deduplicated, never empty. + std::vector refs; +}; + +/// The pool browser's rows, in the order the pools list them, `kDomains` first to last. +/// +/// An entry keyed on nothing — no stat carries a `ref` — is left out: there is no verdict to +/// attach to it and no row worth drawing. +std::vector pool_groups(const data::GameData& gd); + +/// Every line a search term may be tested against for one group: its entries' `matchable_lines` +/// unioned, in order, without repeats. +std::vector group_lines(const PoolGroup& g, const data::GameData* gd); + +/// How those rows came out, for `assess`. +Tally tally(const std::vector& rows); + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/store.cpp b/src/mapcheck/store.cpp new file mode 100644 index 0000000..d7f1683 --- /dev/null +++ b/src/mapcheck/store.cpp @@ -0,0 +1,168 @@ +#include "mapcheck/store.hpp" + +#include +#include +#include +#include + +#include "paths.hpp" + +namespace fs = std::filesystem; + +namespace ppc::mapcheck { +namespace { + +const Profile& empty_profile() { + static const Profile p; + return p; +} + +std::string read_file(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + if (!in) return {}; + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +} // namespace + +fs::path profiles_dir() { return config_dir() / "map-profiles"; } + +void Store::open(const fs::path& dir, const std::vector& listed, + std::string_view current) { + dir_ = dir; + names_.clear(); + profiles_.clear(); + dirty_.clear(); + current_.clear(); + + // What is actually there. A name is the file's stem, so renaming the file renames the + // profile and there is never a mapping between the two to get wrong. + std::vector found; + std::error_code ec; + for (const fs::directory_entry& e : fs::directory_iterator(dir, ec)) { + if (!e.is_regular_file(ec) || e.path().extension() != ".json") continue; + std::string name = sanitize_profile_name(e.path().stem().string()); + if (!name.empty()) found.push_back(std::move(name)); + } + std::sort(found.begin(), found.end()); + + // The config's order first, for the names it still has files for; then whatever else is in + // the directory, so a table shared between machines by hand appears without an edit. + const auto take = [&](const std::string& name) { + if (std::find(names_.begin(), names_.end(), name) != names_.end()) return; + names_.push_back(name); + profiles_.push_back(profile_from_json(name, read_file(dir_ / (name + ".json")))); + }; + for (const std::string& n : listed) + if (std::binary_search(found.begin(), found.end(), n)) take(n); + for (const std::string& n : found) take(n); + + // A profile is what every rating is written into, so there is always one — and on a first + // run, that means making it here rather than asking the user to before the feature works. + if (names_.empty()) { + create(kDefaultProfile); + return; + } + if (!current.empty()) select(current); + if (current_.empty()) current_ = names_.front(); +} + +void Store::select(std::string_view name) { + if (std::find(names_.begin(), names_.end(), name) == names_.end()) return; + current_ = name; +} + +const Profile& Store::profile() const { + const auto it = std::find(names_.begin(), names_.end(), current_); + if (it == names_.end()) return empty_profile(); + return profiles_[static_cast(it - names_.begin())]; +} + +size_t Store::rated_in(std::string_view name) const { + const auto it = std::find(names_.begin(), names_.end(), name); + return it == names_.end() ? 0 + : profiles_[static_cast(it - names_.begin())].rated(); +} + +Verdict Store::verdict_of(const std::vector& refs) const { + return profile().verdict_of(refs); +} + +Verdict Store::exact(const std::vector& refs) const { + return profile().exact(refs); +} + +void Store::set(const std::vector& refs, Verdict v) { + const auto it = std::find(names_.begin(), names_.end(), current_); + if (it == names_.end()) return; // no profile, nowhere to put it + Profile& p = profiles_[static_cast(it - names_.begin())]; + if (p.set(refs, v)) mark(current_); +} + +bool Store::create(std::string_view name, std::string_view copy_from) { + const std::string clean = sanitize_profile_name(name); + if (clean.empty()) return false; + if (std::find(names_.begin(), names_.end(), clean) != names_.end()) return false; + + Profile p(clean); + if (const auto src = std::find(names_.begin(), names_.end(), copy_from); src != names_.end()) + for (const auto& [ref, r] : profiles_[static_cast(src - names_.begin())].ratings()) + p.put(ref, r); + + names_.push_back(clean); + profiles_.push_back(std::move(p)); + current_ = clean; + write(clean); // now, not on the throttle: see the header + return true; +} + +bool Store::remove(std::string_view name) { + const auto it = std::find(names_.begin(), names_.end(), name); + if (it == names_.end()) return false; + const size_t i = static_cast(it - names_.begin()); + const std::string gone = names_[i]; + + // Before the file goes, or a flush riding on the throttle would write it straight back. + dirty_.erase(gone); + names_.erase(names_.begin() + static_cast(i)); + profiles_.erase(profiles_.begin() + static_cast(i)); + std::error_code ec; + fs::remove(dir_ / (gone + ".json"), ec); + + if (names_.empty()) { + create(kDefaultProfile); // there is always one to rate into + return true; + } + // The neighbour, so deleting down a list leaves the selection where the hand is. + if (current_ == gone) current_ = names_[std::min(i, names_.size() - 1)]; + return true; +} + +void Store::mark(const std::string& name) { + dirty_.insert(name); + touched_ = std::chrono::steady_clock::now(); +} + +void Store::write(const std::string& name) { + const auto it = std::find(names_.begin(), names_.end(), name); + if (it == names_.end()) return; + if (!ensure_dir(dir_)) return; + std::ofstream out(dir_ / (name + ".json"), std::ios::binary); + if (!out) return; + out << profile_to_json(profiles_[static_cast(it - names_.begin())]); +} + +void Store::flush() { + for (const std::string& name : dirty_) write(name); + dirty_.clear(); +} + +void Store::tick() { + if (dirty_.empty()) return; + if (std::chrono::steady_clock::now() - touched_ < kWriteDelay) return; + flush(); +} + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/store.hpp b/src/mapcheck/store.hpp new file mode 100644 index 0000000..511a1b9 --- /dev/null +++ b/src/mapcheck/store.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "mapcheck/verdict.hpp" + +namespace ppc::mapcheck { + +/// Where the rating tables live: one `.json` per profile, named after it. +std::filesystem::path profiles_dir(); + +/// How long a rating waits before it reaches the disk. Long enough that walking a modifier +/// through all four states is one write, short enough that a session ending badly loses at most +/// the last click. Whatever is outstanding is written whenever a screen that can rate closes, +/// so this is a ceiling on batching and never on durability. +inline constexpr std::chrono::milliseconds kWriteDelay{1500}; + +/// What a first run is given, so that a map check always has somewhere to put a verdict. Not a +/// translated word: it is a file name. +inline constexpr std::string_view kDefaultProfile = "Default"; + +/// Every rating table on disk, the one in use, and the throttle between a click and a write. +/// +/// **The directory is the authority, not the config.** `config.json` records the names and +/// their order because that is where the rest of the application's state lives, but a file +/// dropped in by hand shows up and a name whose file has gone is dropped — which is also what +/// makes creating a profile safe without saving Settings first: the file is written at once and +/// the list catches up on the next save either way. +class Store { +public: + /// Read `dir`, reconciling it against the names `listed` gives and selecting `current`. + /// + /// **Ends with at least one profile.** A directory with nothing in it gets `kDefaultProfile`, + /// written there and then: a verdict is only ever put into a table, so a popup opening with + /// no table is one where every click does nothing, and the user has no way to find out why + /// without going to Settings. Missing directory is not an error — it is a first run. + void open(const std::filesystem::path& dir, const std::vector& listed, + std::string_view current); + + const std::vector& names() const { return names_; } + /// How many ratings a named table holds, for the line the delete confirmation shows. 0 for + /// a name there is no table for. + size_t rated_in(std::string_view name) const; + const std::string& current() const { return current_; } + /// No-op for a name there is no table for, so a stale `config.json` cannot leave the popup + /// rating something that does not exist. + void select(std::string_view name); + + /// The table in use. Never null: with no profile at all this is an empty one, which reads + /// as every modifier unrated and accepts no writes. + const Profile& profile() const; + /// What the current profile says about the affix granting `refs` — including anything a + /// shorter key lends it. See `Profile::verdict_of`. + Verdict verdict_of(const std::vector& refs) const; + /// What it says about that affix and nothing else, which is what a control shows. + Verdict exact(const std::vector& refs) const; + /// Rate an affix in the current profile. Buffered — see `tick` and `flush`. + void set(const std::vector& refs, Verdict v); + + /// Create `name`, empty or as a copy of `copy_from`, and select it. **Written immediately**: + /// a profile is a file, and a file that exists only in memory is one a crash turns into a + /// list entry pointing at nothing. False when the name is unusable or already taken. + bool create(std::string_view name, std::string_view copy_from = {}); + + /// Delete `name` and its file, selecting whatever is left — and putting `kDefaultProfile` + /// back when that was the last one, for the reason `open` seeds it. Immediate for the same + /// reason `create` is: a table gone from the list and still on disk comes back on the next + /// launch, which reads as the delete not having worked. + bool remove(std::string_view name); + + /// Write every table that has changed. Called when a screen that can rate closes and on the + /// way out, so nothing depends on the throttle having fired. + void flush(); + /// Write anything that has been waiting longer than `kWriteDelay`. From the main loop. + void tick(); + bool dirty() const { return !dirty_.empty(); } + +private: + void mark(const std::string& name); + void write(const std::string& name); + + std::filesystem::path dir_; + std::vector names_; ///< display order + std::vector profiles_; ///< parallel to `names_` + std::string current_; + std::set dirty_; + std::chrono::steady_clock::time_point touched_{}; +}; + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/verdict.cpp b/src/mapcheck/verdict.cpp new file mode 100644 index 0000000..e35164d --- /dev/null +++ b/src/mapcheck/verdict.cpp @@ -0,0 +1,247 @@ +#include "mapcheck/verdict.hpp" + +#include +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace ppc::mapcheck { +namespace { + +constexpr std::array kIds{"unrated", "safe", "dangerous", "deadly"}; + +/// Names MS-DOS claimed and Windows still refuses, with or without an extension. +constexpr std::string_view kReserved[]{"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", + "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", + "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", + "LPT9"}; + +bool forbidden(unsigned char c) { + return c < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '/' || c == '\\' || + c == '|' || c == '?' || c == '*' || c == 0x7F; +} + +std::optional number_or_none(const json& j, const char* key) { + if (!j.contains(key) || !j[key].is_number()) return std::nullopt; + return j[key].get(); +} + +} // namespace + +std::string_view verdict_id(Verdict v) { + const size_t i = static_cast(v); + return i < kIds.size() ? kIds[i] : kIds[0]; +} + +Verdict verdict_from_id(std::string_view s) { + for (size_t i = 0; i < kIds.size(); ++i) + if (s == kIds[i]) return static_cast(i); + return Verdict::Unrated; +} + +void Tally::add(Verdict v) { + switch (v) { + case Verdict::Safe: ++safe; break; + case Verdict::Dangerous: ++dangerous; break; + case Verdict::Deadly: ++deadly; break; + case Verdict::Unrated: ++unrated; break; + } +} + +Outlook assess(const Tally& t) { + const int n = t.total(); + if (n == 0) return Outlook::NoMods; + // One is enough, whatever the other modifiers say: this is the verdict the whole feature + // exists to carry, and averaging it away would be the confident wrong answer. + if (t.deadly > 0) return Outlook::Fatal; + // Doubled rather than divided, so an odd count needs no rule about which way it rounds. + if (t.safe * 2 > n) return Outlook::Safe; + // Half the map is a question mark and the rest is fine — a map worth running and worth + // reading, which no count of safe against dangerous can express. + if (t.unrated * 2 >= n && t.dangerous == 0) return Outlook::Unrated; + return t.safe > t.dangerous ? Outlook::Likely : Outlook::Careful; +} + +std::string affix_key(std::vector refs) { + std::erase_if(refs, [](const std::string& r) { return r.empty(); }); + std::sort(refs.begin(), refs.end()); + refs.erase(std::unique(refs.begin(), refs.end()), refs.end()); + std::string out; + for (const std::string& r : refs) { + if (!out.empty()) out += kKeySep; + out += r; + } + return out; +} + +std::vector affix_refs(std::string_view key) { + std::vector out; + size_t i = 0; + while (i <= key.size() && !key.empty()) { + const size_t end = key.find(kKeySep, i); + out.emplace_back(key.substr(i, end == std::string_view::npos ? end : end - i)); + if (end == std::string_view::npos) break; + i = end + 1; + } + return out; +} + +void Profile::reindex() { + index_.clear(); + index_.reserve(ratings_.size()); + for (const auto& [key, rating] : ratings_) index_.push_back({affix_refs(key), rating}); +} + +void Profile::put(std::string key, Rating r) { + ratings_[std::move(key)] = r; + reindex(); +} + +const Rating* Profile::rating_of(const std::vector& refs) const { + if (refs.empty()) return nullptr; + // Sorted once, so each candidate is a linear walk rather than a search per wording. + std::vector have = refs; + std::sort(have.begin(), have.end()); + + const Rating* best = nullptr; + size_t best_len = 0; + Verdict tied = Verdict::Unrated; + for (const Entry& e : index_) { + if (e.refs.size() > have.size()) continue; + if (!std::includes(have.begin(), have.end(), e.refs.begin(), e.refs.end())) continue; + if (e.refs.size() > best_len) { + best_len = e.refs.size(); + best = &e.rating; + tied = e.rating.verdict; + } else if (e.refs.size() == best_len) { + // Two keys of equal length both covering this affix: neither is the more particular + // statement, so take the one the reader would least like to be surprised by. + tied = worse_of(tied, e.rating.verdict); + if (tied != best->verdict) best = &e.rating; + } + } + return best; +} + +Verdict Profile::verdict_of(const std::vector& refs) const { + const Rating* r = rating_of(refs); + return r ? r->verdict : Verdict::Unrated; +} + +Verdict Profile::exact(const std::vector& refs) const { + const auto it = ratings_.find(affix_key(refs)); + return it == ratings_.end() ? Verdict::Unrated : it->second.verdict; +} + +bool Profile::set(const std::vector& refs, Verdict v) { + const std::string key = affix_key(refs); + if (key.empty()) return false; + const auto it = ratings_.find(key); + if (v == Verdict::Unrated) { + if (it == ratings_.end()) return false; + ratings_.erase(it); + reindex(); + return true; + } + if (it != ratings_.end()) { + if (it->second.verdict == v) return false; + it->second.verdict = v; + reindex(); + return true; + } + ratings_.emplace(key, Rating{v, std::nullopt, std::nullopt}); + reindex(); + return true; +} + +std::string sanitize_profile_name(std::string_view name) { + std::string out; + out.reserve(std::min(name.size(), kMaxProfileName)); + for (const char ch : name) { + if (out.size() >= kMaxProfileName) break; + out += forbidden(static_cast(ch)) ? '_' : ch; + } + // Windows drops trailing dots and spaces without saying so, which would make two names that + // do not look alike open the same file. + const auto trim = [](char c) { return c == ' ' || c == '.'; }; + while (!out.empty() && trim(out.front())) out.erase(out.begin()); + while (!out.empty() && trim(out.back())) out.pop_back(); + if (out.empty()) return out; + + std::string stem = out.substr(0, out.find('.')); + for (char& c : stem) c = static_cast(std::toupper(static_cast(c))); + for (const std::string_view r : kReserved) + if (stem == r) return "_" + out; + return out; +} + +std::string profile_to_json(const Profile& p) { + json j; + j["profile"] = p.name(); + // An array of rows rather than an object keyed by wording, because the key is now a *set* of + // them and JSON has no such key. Joining them into one string would work and would be + // unreadable: this file is hand-editable and a wording is exactly what somebody searches it + // for, so each stays a string of its own on a line of its own. + json v = json::array(); + for (const auto& [key, r] : p.ratings()) { + json row{{"verdict", std::string(verdict_id(r.verdict))}, {"mods", affix_refs(key)}}; + if (r.min) row["min"] = *r.min; + if (r.max) row["max"] = *r.max; + v.push_back(std::move(row)); + } + j["verdicts"] = v; + return j.dump(2) + "\n"; +} + +Profile profile_from_json(std::string name, std::string_view text) { + Profile p(std::move(name)); + try { + const json j = json::parse(text); + if (!j.contains("verdicts")) return p; + const json& v = j["verdicts"]; + // The object form is what tables written before the key became a set hold, and a + // one-wording affix keys as that wording alone — so those rows mean today exactly what + // they meant then and are read rather than migrated. + if (v.is_object()) { + for (const auto& [ref, value] : v.items()) { + Rating r; + if (value.is_string()) { + r.verdict = verdict_from_id(value.get()); + } else if (value.is_object()) { + r.verdict = verdict_from_id(value.value("verdict", std::string())); + r.min = number_or_none(value, "min"); + r.max = number_or_none(value, "max"); + } + // Not a row: an unrated affix is one nothing was said about, and writing it back + // would grow the file by every modifier somebody clicked past. + if (r.verdict == Verdict::Unrated) continue; + p.put(affix_key({ref}), r); + } + return p; + } + if (!v.is_array()) return p; + for (const json& row : v) { + if (!row.is_object() || !row.contains("mods") || !row["mods"].is_array()) continue; + std::vector refs; + for (const json& m : row["mods"]) + if (m.is_string()) refs.push_back(m.get()); + Rating r; + r.verdict = verdict_from_id(row.value("verdict", std::string())); + r.min = number_or_none(row, "min"); + r.max = number_or_none(row, "max"); + if (r.verdict == Verdict::Unrated) continue; + std::string key = affix_key(std::move(refs)); + if (key.empty()) continue; + p.put(std::move(key), r); + } + } catch (...) { + // Hand-editable, so a stray comma must cost the ratings and never the run. + } + return p; +} + +} // namespace ppc::mapcheck diff --git a/src/mapcheck/verdict.hpp b/src/mapcheck/verdict.hpp new file mode 100644 index 0000000..e845b98 --- /dev/null +++ b/src/mapcheck/verdict.hpp @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +/// Map check's model: what a player decided about a modifier, and the table those decisions +/// live in. +/// +/// This half is `ppc_core` — no ImGui, no filesystem. The popup is +/// `screens/mapcheck_screen`, the settings page is the Map Check tab, and the files these +/// tables are read from and written to are `MapCheckService`. +namespace ppc::mapcheck { + +/// What the user decided about one modifier. +/// +/// **Ordered worst-last on purpose.** `worse_of` is a `max`, which is the whole of "lead with +/// the worst verdict on the map" and of how a modifier printing several stats reports one +/// answer. `Unrated` is zero because it is the *absence* of a decision rather than a fourth +/// one — it is what every modifier starts as, and it is why the table grows by being used +/// instead of having to be filled in first. +enum class Verdict : uint8_t { Unrated = 0, Safe, Dangerous, Deadly }; + +std::string_view verdict_id(Verdict v); ///< the word the file stores, "safe" +Verdict verdict_from_id(std::string_view s); ///< `Unrated` for anything it does not know + +constexpr Verdict worse_of(Verdict a, Verdict b) { return a > b ? a : b; } + +/// The next verdict a click walks to: unrated → safe → dangerous → deadly → unrated. +constexpr Verdict next_verdict(Verdict v) { + return v == Verdict::Deadly ? Verdict::Unrated : static_cast(uint8_t(v) + 1); +} + +/// How the modifiers on the map in hand came out. Implicits are not in it — they are printed +/// and never rated — and neither is anything that resolved to no stat, since there is nothing +/// for a verdict to attach to. +struct Tally { + int safe = 0, dangerous = 0, deadly = 0, unrated = 0; + int total() const { return safe + dangerous + deadly + unrated; } + + void add(Verdict v); +}; + +/// What the map as a whole is worth saying, which is the line the popup leads with. +/// +/// Severity, not a count: the same order as `Verdict`, so the strongest thing true of the map +/// is what it reports. `Safe` covers two sentences rather than two outlooks, because a map that +/// is mostly safe with a few unrated modifiers is still a map you can run — it just has a +/// footnote. +enum class Outlook : uint8_t { + NoMods, ///< nothing rolled; a white map, or one whose lines all failed to resolve + Unrated, ///< half or more unrated and nothing worse than safe under them + Safe, ///< more than half rated safe + Likely, ///< more safe than dangerous + Careful, ///< as many dangerous as safe, or more + Fatal ///< one deadly modifier is enough +}; + +/// The strongest true statement about `t`, in the order they are checked: a deadly modifier +/// first, because one of them decides the map on its own; then the two majorities; then the +/// balance between safe and dangerous. +Outlook assess(const Tally& t); + +/// One row of a table. +/// +/// `min`/`max` are **parsed and written back and never shown**. The practice this feature +/// copies — map regexes — has no notion of a threshold, and asking for one on each of a few +/// hundred modifiers is the UI 0.7 exists to avoid. They are in the format from the first +/// version because a reader that accepts both shapes costs one branch now, and teaching every +/// user's file a new shape later costs a migration. +struct Rating { + Verdict verdict = Verdict::Unrated; + std::optional min, max; +}; + +/// The identity a verdict attaches to: **an affix**, written as the set of stat wordings it +/// grants, sorted and joined. +/// +/// **Keyed on the stat records' `ref`s**, never on printed lines: a wording is +/// language-dependent the moment a localised bundle exists, and two records sharing one are +/// something `find_stat` already refuses to guess between. So a modifier is resolved first and +/// the verdict attaches to what it resolved to. +/// +/// **A set and not one wording**, because a wording is not an affix. 21 of the pool's wordings +/// sit on more than one affix — `Monsters cannot be Stunned` is granted by `Unwavering` and by +/// `of the Juggernaut`, which are not the same decision — and 50 affixes grant more than one. +/// One verdict per wording cannot express either, and rating them together is what made rating +/// one affix silently change others. +/// +/// Sorted, so the key does not depend on the order the pool happens to list the stats in. A +/// one-wording affix keys as that wording alone, which is what a table written before this +/// already holds — so an old file reads correctly rather than needing a migration. +/// What joins the wordings in a key. A unit separator, because it is the one byte that cannot +/// occur in one: the wordings are game text. +inline constexpr char kKeySep = '\x1f'; + +std::string affix_key(std::vector refs); +std::vector affix_refs(std::string_view key); + +/// A named table of affix → verdict. +class Profile { +public: + Profile() = default; + explicit Profile(std::string name) : name_(std::move(name)) {} + + const std::string& name() const { return name_; } + void rename(std::string name) { name_ = std::move(name); } + + /// What was decided about the affix granting exactly `refs`. + /// + /// **The most specific decision that covers it.** A stored key applies when its wordings are + /// all present, so rating the one-wording affix `Monsters cannot be Stunned` also speaks for + /// `of the Juggernaut`, which grants that and two more — but only until the Juggernaut is + /// rated in its own right, at which point the longer key wins because it is the more + /// particular statement. Equal-length keys that both apply fall back to `worse_of`, since a + /// reader who called something deadly is not served by being shown the milder half. + Verdict verdict_of(const std::vector& refs) const; + const Rating* rating_of(const std::vector& refs) const; + + /// Returns whether this changed anything, which is what the throttled save watches. + /// Setting `Unrated` **erases** the row: unrated is the absence of a decision, and a file + /// listing every affix somebody once clicked past and back is a file of nothing. + bool set(const std::vector& refs, Verdict v); + /// What this table itself says about that exact affix, ignoring anything a shorter key + /// would lend it. This is what a control shows, so that pressing it is the only thing that + /// ever changes it. + Verdict exact(const std::vector& refs) const; + + size_t rated() const { return ratings_.size(); } + const std::map>& ratings() const { return ratings_; } + void put(std::string key, Rating r); + +private: + /// A stored key, split once so that a lookup is not re-parsing the whole table. + struct Entry { + std::vector refs; + Rating rating; + }; + + std::string name_; + std::map> ratings_; + std::vector index_; + void reindex(); +}; + +/// A name that can be a file. Everything Windows or POSIX refuses becomes `_`, the ends are +/// trimmed of the spaces and dots Windows silently drops, a reserved DOS device name is given +/// a leading `_`, and the result is cut to `kMaxProfileName`. Empty out means there was nothing +/// left to keep, which is what the dialog disables its Create button on. +/// +/// Substituted rather than dropped, so two names that do not look alike cannot collapse into +/// one file: `a/b` and `ab` are different profiles and stay that way. +/// +/// The sanitised form **is** the name: it is what the file is called and what the dropdown +/// shows, so there is never a mapping between the two to get wrong. +std::string sanitize_profile_name(std::string_view name); + +/// How long a profile name may be. A file-name limit, not a display one — 255 bytes is the +/// usual ceiling and this leaves room for the directory and the `.json`. +inline constexpr size_t kMaxProfileName = 64; + +/// The table as the file holds it. A rating with no bound is written as the bare word, since +/// that is every row this version can produce and it keeps a hand-edited file readable. +std::string profile_to_json(const Profile& p); + +/// The inverse. `name` is the profile's name — taken from the file it was read from rather +/// than from anything inside it, so renaming the file renames the profile. +/// +/// Anything malformed reads as an empty table rather than throwing: this file is hand-editable +/// and a stray comma is not a reason for a hotkey to stop working. +Profile profile_from_json(std::string name, std::string_view text); + +} // namespace ppc::mapcheck diff --git a/src/platform/input.hpp b/src/platform/input.hpp index 96c904f..8708f67 100644 --- a/src/platform/input.hpp +++ b/src/platform/input.hpp @@ -26,7 +26,7 @@ struct Hotkey { bool valid() const { return !key.empty(); } }; -enum class Action { PriceCheck, ToggleSettings, QuickPaste }; +enum class Action { PriceCheck, ToggleSettings, QuickPaste, MapCheck }; std::string to_string(const Hotkey& h); ///< e.g. "Ctrl+D" Hotkey parse_hotkey(const std::string& s); ///< inverse of to_string diff --git a/src/screens/mapcheck_screen.cpp b/src/screens/mapcheck_screen.cpp new file mode 100644 index 0000000..cf9744a --- /dev/null +++ b/src/screens/mapcheck_screen.cpp @@ -0,0 +1,428 @@ +#include "screens/mapcheck_screen.hpp" + +#include +#include + +#include + +#include "app.hpp" +#include "mapcheck/rate.hpp" +#include "screens/item_view.hpp" +#include "ui/glyphs.hpp" +#include "ui/strings.hpp" +#include "ui/theme.hpp" + +namespace ppc { +namespace { + +using mapcheck::Outlook; +using mapcheck::Verdict; + +/// The tooltip's own sizes, as `item_view` sets them: a plate in small caps over the base +/// line, and the rest at the body size. +constexpr float kTitleSize = 18.0f; +constexpr float kPlateSize = 17.0f; +constexpr float kRowPad = 3.0f; ///< above and below a modifier's lines, inside its tint +/// Between one row's tint and the next. Two modifiers the user answered the same way would +/// otherwise draw as a single block, and the list is meant to be counted at a glance. +constexpr float kRowGap = 2.0f; +constexpr float kGlyphColumn = 22.0f; + +// The game's palette, as item_view uses it. Repeated rather than shared because that file's +// copies are its own private business and this panel is not a second caller of them. +constexpr ImU32 kColLabel = IM_COL32(127, 127, 127, 255); +constexpr ImU32 kColValue = IM_COL32(255, 255, 255, 255); +constexpr ImU32 kColMod = IM_COL32(136, 136, 255, 255); +constexpr ImU32 kColAugmented = IM_COL32(136, 136, 255, 255); +/// Dulled towards the background from `kColMod`, and no further: an implicit shares the list +/// with the affixes now, and the grey a row that resolved to nothing is drawn in is only +/// (150,150,150). A tint between those two says "unreadable" to the reader far more often than +/// it says "implicit". +constexpr ImU32 kColImplicit = IM_COL32(140, 140, 220, 255); + +ImU32 rarity_tint(item::Rarity r) { + switch (r) { + case item::Rarity::Magic: return IM_COL32(136, 136, 255, 255); + case item::Rarity::Rare: return IM_COL32(255, 255, 119, 255); + case item::Rarity::Unique: return IM_COL32(214, 129, 62, 255); + default: return IM_COL32(200, 200, 200, 255); + } +} + +/// A dimmer rule than ImGui's own, matching the one the item card draws. +void draw_rule() { + ImGui::PushStyleColor(ImGuiCol_Separator, IM_COL32(90, 90, 90, 160)); + ImGui::Separator(); + ImGui::PopStyleColor(); +} + +void centred(const std::string& s, ImU32 colour) { + const float w = ImGui::CalcTextSize(s.c_str()).x; + const float avail = ImGui::GetContentRegionAvail().x; + if (w <= avail) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail - w) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Text, colour); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(s.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); +} + +Outlook outlook_of(const App& app) { + return mapcheck::assess(mapcheck::tally(app.map_rows())); +} + +ImVec4 outlook_colour(Outlook o) { + switch (o) { + case Outlook::Safe: return ImVec4(0.35f, 0.78f, 0.38f, 1.0f); + case Outlook::Likely: return ImVec4(0.88f, 0.82f, 0.30f, 1.0f); + case Outlook::Careful: return ImVec4(0.92f, 0.58f, 0.20f, 1.0f); + case Outlook::Fatal: return ImVec4(0.90f, 0.28f, 0.24f, 1.0f); + default: return ImVec4(0.62f, 0.64f, 0.70f, 1.0f); // NoMods and Unrated: no colour to give + } +} + +ui::Msg outlook_text(Outlook o, bool any_unrated) { + switch (o) { + case Outlook::NoMods: return ui::Msg::MapOutlookNoMods; + case Outlook::Unrated: return ui::Msg::MapOutlookUnrated; + case Outlook::Safe: + return any_unrated ? ui::Msg::MapOutlookSafeUnrated : ui::Msg::MapOutlookSafe; + case Outlook::Likely: return ui::Msg::MapOutlookLikely; + case Outlook::Careful: return ui::Msg::MapOutlookCareful; + case Outlook::Fatal: return ui::Msg::MapOutlookFatal; + } + return ui::Msg::MapOutlookNoMods; +} + +/// The glyph the banner leads with: the worst verdict on the map, except that a map nobody has +/// read yet is a question rather than a tick. +const char* outlook_glyph(Outlook o) { + switch (o) { + case Outlook::Safe: return ui::verdict_glyph(Verdict::Safe); + case Outlook::Likely: + case Outlook::Careful: return ui::verdict_glyph(Verdict::Dangerous); + case Outlook::Fatal: return ui::verdict_glyph(Verdict::Deadly); + default: return ui::verdict_glyph(Verdict::Unrated); + } +} + +/// The one line the popup exists to be read at a glance for. Drawn on its own tinted strip +/// across the whole width, above the item, because it is the answer and the item is the +/// working. +void draw_outlook(App& app) { + const Outlook o = outlook_of(app); + const mapcheck::Tally t = mapcheck::tally(app.map_rows()); + const ImVec4 tint = outlook_colour(o); + + const float pad = ImGui::GetStyle().FramePadding.y; + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + const float w = ImGui::GetContentRegionAvail().x; + // A wash rather than the colour itself: the sentence over it is what has to be read, and + // white text on a solid green bar is not a tooltip, it is a notification. + const ImU32 wash = ImGui::GetColorU32(ImVec4(tint.x, tint.y, tint.z, 0.16f)); + const std::string text = ui::text(outlook_text(o, t.unrated > 0)); + + ImGui::PushStyleColor(ImGuiCol_Text, tint); + const float glyph_w = app.fonts().has_glyphs ? kGlyphColumn : 0.0f; + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + w - glyph_w); + const float text_h = ImGui::CalcTextSize(text.c_str(), nullptr, false, w - glyph_w).y; + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(p0.x - 2, p0.y - pad), + ImVec2(p0.x + w + 2, p0.y + text_h + pad), wash, + 3.0f); + if (app.fonts().has_glyphs) { + ImGui::TextUnformatted(outlook_glyph(o)); + ImGui::SameLine(0.0f, 0.0f); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + kGlyphColumn - + ImGui::CalcTextSize(outlook_glyph(o)).x); + } + ImGui::TextUnformatted(text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::Dummy(ImVec2(0, pad)); +} + +/// The name plate, as the item card draws it but without the room for a gutter. +void draw_plate(App& app, const item::Item& it) { + const ImU32 colour = rarity_tint(it.rarity); + ImGui::PushFont(app.fonts().small_caps, kPlateSize); + if (!it.name.empty()) centred(it.name, colour); + std::string plate = it.base_type; + if (it.map_tier) plate += " (Tier " + std::to_string(*it.map_tier) + ")"; + centred(plate, colour); + ImGui::PopFont(); +} + +/// The numbers a map is opened for, laid out across the panel instead of one to a line. +/// +/// The game prints "Item Quantity: +107%" on three lines of its own; here they are a run that +/// wraps, which is the whole of what "compact" means for this block. The labels are the ones +/// the client printed rather than shortened ones — a shorter word would have to be invented per +/// language, and the wrap already buys the space. +void draw_properties(const item::Item& it) { + const float avail = ImGui::GetContentRegionAvail().x; + const float gap = ImGui::GetStyle().ItemSpacing.x * 2.0f; + float x = 0.0f; + bool first = true; + for (const item::Property& p : it.properties) { + if (p.label.empty()) continue; // prose the game prints among the properties + const std::string label = p.label + ": "; + const float w = + ImGui::CalcTextSize(label.c_str()).x + ImGui::CalcTextSize(p.value.c_str()).x; + // Measured against what is left on the line rather than against the pair's own width: + // a pair that does not fit starts a new line, and one that has never fitted anywhere + // still gets one to itself and is wrapped by ImGui. + if (!first && x + gap + w <= avail) { + ImGui::SameLine(0.0f, gap); + x += gap + w; + } else { + x = w; + } + first = false; + ImGui::PushStyleColor(ImGuiCol_Text, kColLabel); + ImGui::TextUnformatted(label.c_str()); + ImGui::PopStyleColor(); + ImGui::SameLine(0.0f, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Text, p.augmented ? kColAugmented : kColValue); + ImGui::TextUnformatted(p.value.c_str()); + ImGui::PopStyleColor(); + } +} + +/// Which table the verdicts come from. In the popup as well as in Settings because the answer +/// is per character and the popup is where the character is: switching in the middle of a map +/// is the case this exists for. +void draw_profile_row(App& app) { + const mapcheck::Store& store = app.map_store(); + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kLabel); + ImGui::TextUnformatted(ui::text(ui::Msg::MapProfile)); + ImGui::PopStyleColor(); + ImGui::SameLine(); + + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + const std::string preview = + store.current().empty() ? ui::text(ui::Msg::MapProfileNone) : store.current(); + if (ImGui::BeginCombo("##profile", preview.c_str())) { + for (const std::string& name : store.names()) { + const bool sel = name == store.current(); + if (ImGui::Selectable(name.c_str(), sel)) app.select_map_profile(name); + if (sel) ImGui::SetItemDefaultFocus(); + } + if (store.names().empty()) ImGui::TextDisabled("%s", ui::text(ui::Msg::MapNoProfile)); + ImGui::EndCombo(); + } +} + +/// One modifier: its verdict on the left, its printed lines beside it, the whole strip a +/// button. +/// +/// **Nothing is split and nothing is merged.** A hybrid modifier keeps both its lines in one +/// row, and two modifiers wording the same thing stay two rows — the item printed them that way +/// and this panel is a reading of the item. +void draw_row(App& app, size_t index) { + const mapcheck::Row& row = app.map_rows()[index]; + ImGui::PushID(static_cast(index)); + + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + const float w = ImGui::GetContentRegionAvail().x; + const float text_x = ImGui::GetCursorPosX() + kGlyphColumn; + + // **The words are drawn first and the tint is painted behind them afterwards.** A row's + // height is not knowable before the draw: a line may wrap, and ImGui puts `ItemSpacing.y` + // between each of an affix's lines because each is an item of its own. Measuring the same + // text a second time to guess at that is what made a three-line affix come out two + // spacings short, so its tint stopped where the next row's began and the colours bled into + // each other's last line. A split channel lets the rectangle be filled in once the text + // has said how tall it is, and there is then only one answer rather than two that have to + // agree. + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + + ImGui::SetCursorScreenPos(ImVec2(p0.x, p0.y + kRowPad)); + ImGui::BeginGroup(); + if (row.rateable()) { + // Dim for unrated: the column stays the same width on every row, so the list reads as + // answers with the blanks visible rather than as text starting in three places. + const ImVec4 glyph_col = + row.verdict == Verdict::Unrated ? ui::col::kTextDim : ui::verdict_colour(row.verdict); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(glyph_col.x, glyph_col.y, glyph_col.z, 1.0f)); + ImGui::TextUnformatted(app.fonts().has_glyphs ? ui::verdict_glyph(row.verdict) + : ui::verdict_word(row.verdict)); + ImGui::PopStyleColor(); + // On the glyph's own line, not under it. Without this the modifier starts one line + // below its verdict and the row is a line taller than it needs to be. + ImGui::SameLine(0.0f, 0.0f); + } + + ImGui::SetCursorPosX(text_x); + ImGui::PushTextWrapPos(0.0f); + // Implicits are rateable now and still say which they are, the way the game colours them: + // what the base carries and what it rolled are different things to be deciding about. + const bool implicit = row.mod() && row.mod()->type == data::ModType::Implicit; + ImGui::PushStyleColor(ImGuiCol_Text, + !row.rateable() ? ImU32(IM_COL32(150, 150, 150, 255)) + : implicit ? kColImplicit + : kColMod); + // Every line of every modifier the affix printed: one affix is one row and one decision, + // which is what the verdict is keyed on. + for (const item::Modifier* m : row.mods) + for (const std::string& l : m->lines) { + ImGui::SetCursorPosX(text_x); + ImGui::TextUnformatted(strip_roll_ranges(l).c_str()); + } + ImGui::PopStyleColor(); + ImGui::PopTextWrapPos(); + ImGui::EndGroup(); + + // What the row actually came out as, wrapping and inter-line spacing included. + const float h = ImGui::GetItemRectSize().y + kRowPad * 2.0f; + + dl->ChannelsSetCurrent(0); + const ImVec4 tint = ui::verdict_colour(row.verdict); + if (tint.w > 0.0f) + dl->AddRectFilled(ImVec2(p0.x - 2, p0.y), ImVec2(p0.x + w + 2, p0.y + h), + ImGui::GetColorU32(tint), 3.0f); + dl->ChannelsMerge(); + + // The whole strip is the target, not the words in it: what is being aimed at is the + // modifier. Placed over the text, which is not interactive, so nothing competes for it. + ImGui::SetCursorScreenPos(p0); + ImGui::InvisibleButton("##rate", ImVec2(w, h)); + const bool hovered = ImGui::IsItemHovered(); + if (hovered && row.rateable()) { + dl->AddRect(ImVec2(p0.x - 2, p0.y), ImVec2(p0.x + w + 2, p0.y + h), + ImGui::GetColorU32(ui::col::kBorder), 3.0f); + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + // Left walks the four states; right puts it straight back to unrated, which is otherwise + // three clicks away from deadly and is the one a misclick needs. + if (row.rateable()) { + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) app.rate_map_row(index); + else if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) + app.rate_map_row(index, Verdict::Unrated); + } + + // A wording the data cannot identify has nothing to key a verdict on, and saying so on + // hover is better than a row that quietly does nothing when it is clicked. + if (hovered && !row.rateable()) ImGui::SetTooltip("%s", ui::text(ui::Msg::MapUnresolved)); + + ImGui::SetCursorScreenPos(ImVec2(p0.x, p0.y + h + kRowGap)); + ImGui::PopID(); +} + +/// The name of the screen and the disc that leaves it, in the frame's top edge. +void draw_header(App& app) { + const float h = ImGui::GetFrameHeight(); + ImGui::PushFont(app.fonts().small_caps, kTitleSize); + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kTitle); + ImGui::TextUnformatted(ui::text(ui::Msg::MapCheckTitle)); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - h); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, h * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kClose); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::col::kCloseHovered); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::col::kCloseHovered); + if (ImGui::Button("X", ImVec2(h, h))) app.close_overlay(); + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", ui::text(ui::Msg::Close)); +} + +} // namespace + +namespace ui { + +const char* verdict_glyph(Verdict v) { + switch (v) { + case Verdict::Safe: return kGlyphSafe; + case Verdict::Dangerous: return kGlyphDangerous; + case Verdict::Deadly: return kGlyphDeadly; + default: return kGlyphUnrated; + } +} + +const char* verdict_word(Verdict v) { + switch (v) { + case Verdict::Safe: return "+"; + case Verdict::Dangerous: return "!"; + case Verdict::Deadly: return "X"; + default: return "?"; + } +} + +ImVec4 verdict_colour(Verdict v) { + switch (v) { + case Verdict::Safe: return ImVec4(0.25f, 0.62f, 0.30f, 0.30f); + case Verdict::Dangerous: return ImVec4(0.78f, 0.66f, 0.18f, 0.30f); + case Verdict::Deadly: return ImVec4(0.72f, 0.18f, 0.16f, 0.32f); + default: return ImVec4(0, 0, 0, 0); // unrated is the panel's own background + } +} + +} // namespace ui + +void draw_mapcheck_screen(App& app) { + const ui::Theme theme(app.config().reduce_transparency); + const item::Item* it = app.item(); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + // The whole SDL window, which App has sized generously; what the content actually came to + // is measured below and the window follows on the next frame. + // + // Deliberately **not** ImGui's own auto-fit. A window's size is what it was laid out at, + // so on the first frame `GetWindowSize` is the size it was given rather than the size its + // content needs — feeding that back shrank the window to a title bar's height and, since + // ImGui clamps a window to the viewport, it could never grow out of it again. + ImGui::SetNextWindowSize(ImGui::GetIO().DisplaySize); + ImGui::Begin("MapCheck", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings); + + draw_header(app); + if (!it) { // never in practice: the screen is only opened once there is an item + ImGui::End(); + return; + } + + draw_rule(); + draw_outlook(app); + draw_rule(); + ImGui::PushFont(app.fonts().small_caps, 0.0f); + draw_plate(app, *it); + if (!it->properties.empty() || it->item_level) { + draw_rule(); + draw_properties(*it); + } + ImGui::PopFont(); + + draw_rule(); + draw_profile_row(app); + + if (app.map_store().current().empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", ui::text(ui::Msg::MapNoProfileHelp)); + ImGui::PopTextWrapPos(); + } + if (!app.map_rows().empty()) { + draw_rule(); + ImGui::PushFont(app.fonts().small_caps, 0.0f); + for (size_t i = 0; i < app.map_rows().size(); ++i) draw_row(app, i); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", ui::text(ui::Msg::MapRateHint)); + ImGui::PopTextWrapPos(); + } + + // Where the content ended, plus the padding under it — the height the window needs, read + // off the cursor rather than off the window for the reason `Begin` gives above. + app.set_mapcheck_height(ImGui::GetCursorPosY() + ImGui::GetStyle().WindowPadding.y); + ImGui::End(); +} + +} // namespace ppc diff --git a/src/screens/mapcheck_screen.hpp b/src/screens/mapcheck_screen.hpp new file mode 100644 index 0000000..f0a627c --- /dev/null +++ b/src/screens/mapcheck_screen.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "mapcheck/verdict.hpp" + +namespace ppc { + +class App; + +/// The map check popup: the map under the cursor, redrawn as a tooltip, with a verdict on each +/// modifier and one line saying what the map as a whole is worth. +/// +/// It reports the height it drew back to `App`, because a popup sized to its content cannot be +/// sized before the content is laid out. See `App::set_mapcheck_height`. +void draw_mapcheck_screen(App& app); + +namespace ui { + +/// The glyph a verdict is drawn with — a check, a warning triangle, a skull, and a question +/// mark for the state that is the absence of an answer. Shared with the settings list, which is +/// the other place a verdict is set. +const char* verdict_glyph(mapcheck::Verdict v); +/// The word behind it, for a font whose glyph subset and `ui/glyphs.hpp` have drifted apart. +const char* verdict_word(mapcheck::Verdict v); +/// What the row is tinted with. The alpha is a wash: the modifier's own text still has to read +/// as the game's mod blue over it. +ImVec4 verdict_colour(mapcheck::Verdict v); + +} // namespace ui +} // namespace ppc diff --git a/src/screens/settings_screen.cpp b/src/screens/settings_screen.cpp index cd21bfa..a2c1162 100644 --- a/src/screens/settings_screen.cpp +++ b/src/screens/settings_screen.cpp @@ -13,9 +13,11 @@ #include #include "app.hpp" +#include "mapcheck/filter.hpp" #include "paths.hpp" #include "platform/clipboard.hpp" #include "quickpaste.hpp" +#include "screens/mapcheck_screen.hpp" #include "ui/glyphs.hpp" #include "ui/strings.hpp" #include "ui/theme.hpp" @@ -417,6 +419,7 @@ void general_tab(App& app, Config& c) { section(app, ui::text(ui::Msg::SectionHotkeys)); hotkey_row(app, ui::text(ui::Msg::HotkeyPriceCheck), Action::PriceCheck, c.price_check); + hotkey_row(app, ui::text(ui::Msg::HotkeyMapCheck), Action::MapCheck, c.map_check); hotkey_row(app, ui::text(ui::Msg::HotkeySettings), Action::ToggleSettings, c.settings); hotkey_row(app, ui::text(ui::Msg::HotkeyQuickPaste), Action::QuickPaste, c.quick_paste); @@ -784,6 +787,350 @@ void quickpaste_tab(App& app, Config& c) { paste_dialog(app, c); } +// --------------------------------------------------------------------------------------------- +// Map check +// +// The bulk-editing half of the feature, and the rarely-opened one: the table is meant to fill in +// by playing — rate what the popup shows you, on the spot — and this page exists for the one +// session where somebody sits down to pre-fill it, usually by pasting a search string they +// already use. Everything here is written per **domain** rather than per map, so the same page +// serves the flask, abyss jewel or idol pool the day one is published. + +/// Reading `Client.txt` to know which character is logged in is 0.7's "might" and is **not +/// built**. Both tooltips the checkbox can carry are written; this is which one it shows, and +/// the switch that turns the row on the day the log watcher lands. +constexpr bool kClientLogSupported = false; + +constexpr float kMapVerdictW = 26.0f; +constexpr float kMapIconW = 30.0f; +/// The syntax tooltip is a short reference and not a sentence, so it is given a width to wrap +/// in rather than being left to run the length of its longest example. +constexpr float kMapSyntaxTipW = 470.0f; + +/// One pool entry as one row: the four verdicts as buttons on the left, the wordings beside +/// them, the affix name after those. +/// +/// **Four buttons and not a click that cycles**, which is what the popup does. The two lists are +/// used differently: the popup shows a handful of rows and the target is the modifier, while this +/// one is a few hundred and the job is putting each into a particular state. One click to any +/// state is worth the chrome here and is not worth it there. +/// One affix: one set of buttons, all of its wordings under them. +/// +/// **The buttons belong to the affix, not to any one of its wordings.** 21 wordings sit on more +/// than one affix — `Monsters cannot be Stunned` is granted by `Unwavering` and by +/// `of the Juggernaut` — so a verdict per wording cannot tell those two decisions apart. The +/// store keys on the whole set, which is why rating one affix leaves the others alone even when +/// they share a line. +/// +/// **Three strengths of lit, not two.** A verdict set here is solid, a proposal is brighter +/// still because the Accept bar above is about exactly those rows, and one *inherited* from a +/// shorter affix is faint: it is true of this modifier and was not decided on this row, and +/// pressing the button it is under is what turns it into a decision that was. +void map_pool_row(App& app, const mapcheck::PoolGroup& group, App::PoolRating shown, + bool proposed) { + // The affix's own entry: a map's where a map and a chart both grant it, since that is the + // pool the reader is nearly always deciding about. Its twin differs only in the range each + // pool rolls, which this row does not print. + const data::PoolMod& mod = *group.mod; + ImGui::PushID(&group); + ImGui::BeginGroup(); + for (int i = 0; i < 4; ++i) { + const auto v = static_cast(i); + if (i) ImGui::SameLine(0.0f, 2.0f); + const bool on = v == shown.verdict; + const ImVec4 tint = ui::verdict_colour(v); + const float lit = proposed ? 0.75f : shown.inherited ? 0.22f : 0.55f; + ImGui::PushStyleColor(ImGuiCol_Button, + on ? ImVec4(tint.x, tint.y, tint.z, lit) : ui::col::kTabIdle); + ImGui::PushStyleColor(ImGuiCol_Text, + on && !shown.inherited ? ui::col::kSection : ui::col::kTextDim); + ImGui::PushID(i); + if (ImGui::Button(app.fonts().has_glyphs ? ui::verdict_glyph(v) : ui::verdict_word(v), + ImVec2(kMapVerdictW, 0))) + app.rate_pool_group(group, v); + // Only on the faint one: a row lit for a reason that is not on it has to say what the + // reason is, or it reads as a control that moved on its own. + if (on && shown.inherited && ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", ui::text(ui::Msg::MapVerdictInherited)); + ImGui::PopID(); + ImGui::PopStyleColor(2); + } + ImGui::EndGroup(); + ImGui::SameLine(); + + ImGui::BeginGroup(); + ImGui::PushTextWrapPos(0.0f); + const std::shared_ptr gd = app.game_data(); + for (const data::PoolStat& s : mod.stats) { + // Not `s.ref`: a stat that only ever rolls below zero is printed by the game as its + // negated wording, and rating a line nobody is shown is rating it blind. + ImGui::TextUnformatted( + mapcheck::display_wording(gd ? gd->find_stat_by_ref(s.ref) : nullptr, s).c_str()); + } + // The affix name, which is also a line the game's own search reads and therefore something + // a pasted term can be about. + if (!mod.name.empty()) ImGui::TextDisabled("%s", mod.name.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndGroup(); + ImGui::PopID(); +} + +/// The profile row: which table is in use, whether it should follow the character, and the two +/// buttons that make and unmake one. +void map_profile_row(App& app, Config& c) { + mapcheck::Store& store = app.map_store(); + MapCheckEdit& e = app.map_edit(); + + // The combo gets a width it can show a name in and the checkbox takes what is left; the two + // buttons are right-aligned so neither of the other two can push them off the row. Sharing + // the leftovers three ways put the profile's name behind an ellipsis. + constexpr float kProfileComboW = 260.0f; + row_label(ui::text(ui::Msg::MapProfile)); + ImGui::SetNextItemWidth(kProfileComboW); + const std::string preview = + store.current().empty() ? ui::text(ui::Msg::MapProfileNone) : store.current(); + if (ImGui::BeginCombo("##map_profile", preview.c_str())) { + for (const std::string& name : store.names()) { + const bool sel = name == store.current(); + if (ImGui::Selectable(name.c_str(), sel)) app.select_map_profile(name); + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + // Disabled either way today, and the tooltip is the difference: one says the feature does + // not exist, the other says what to turn on. Writing both now costs a line and means the + // day the log watcher lands, only `kClientLogSupported` moves. + ImGui::SameLine(); + bool follows = false; + ImGui::BeginDisabled(true); + ImGui::Checkbox(ui::text(ui::Msg::MapAutoLoad), &follows); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", ui::text(kClientLogSupported ? ui::Msg::MapAutoLoadNeedsLog + : ui::Msg::MapAutoLoadUnbuilt)); + + right_align(kMapIconW * 2.0f + ImGui::GetStyle().ItemSpacing.x); + ImGui::BeginDisabled(store.current().empty()); + if (icon_button(app, ui::kGlyphDelete, "X", ui::text(ui::Msg::MapProfileDelete), kMapIconW)) + e.deleting = store.current(); + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (icon_button(app, ui::kGlyphAdd, "+", ui::text(ui::Msg::MapProfileNew), kMapIconW)) { + e.adding = true; + e.draft_name.clear(); + e.copy_from.clear(); + } + + row_gutter(); + if (store.current().empty()) + ImGui::TextDisabled("%s", ui::text(ui::Msg::MapNoProfileHelp)); + else + ImGui::TextDisabled(ui::text(ui::Msg::MapRatedCount), store.profile().rated()); +} + +/// Making one. A dialog rather than a field in the row, because a name has to be finished +/// before it can be a file and "copy from" is a second decision about the same thing. +void map_profile_dialog(App& app) { + MapCheckEdit& e = app.map_edit(); + if (e.adding && !ImGui::IsPopupOpen("##map_profile_new")) ImGui::OpenPopup("##map_profile_new"); + const ImVec2 display = ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowSize(ImVec2(display.x, 0.0f)); + ImGui::SetNextWindowPos(ImVec2(display.x * 0.5f, display.y * 0.5f), ImGuiCond_Always, + ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("##map_profile_new", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove)) + return; + + section(app, ui::text(ui::Msg::MapProfileNew)); + ImGui::InputTextWithHint(row(ui::text(ui::Msg::MapProfileName)), + ui::text(ui::Msg::MapProfileNameHint), &e.draft_name); + // What it will actually be called, said before it is: the name is the file name, so a + // slash in it becomes an underscore and the user should see that here rather than later. + const std::string clean = mapcheck::sanitize_profile_name(e.draft_name); + if (!clean.empty() && clean != e.draft_name) { + row_gutter(); + ImGui::TextDisabled("%s", clean.c_str()); + } + + const char* copy_preview = + e.copy_from.empty() ? ui::text(ui::Msg::MapProfileEmpty) : e.copy_from.c_str(); + if (ImGui::BeginCombo(row(ui::text(ui::Msg::MapProfileCopyFrom)), copy_preview)) { + // Pre-selected, because a first profile has nothing to copy and a second one usually + // wants to be its own thing rather than a fork nobody asked for. + if (ImGui::Selectable(ui::text(ui::Msg::MapProfileEmpty), e.copy_from.empty())) + e.copy_from.clear(); + for (const std::string& name : app.map_store().names()) + if (ImGui::Selectable(name.c_str(), name == e.copy_from)) e.copy_from = name; + ImGui::EndCombo(); + } + + ImGui::Separator(); + const std::vector& names = app.map_store().names(); + const bool taken = std::find(names.begin(), names.end(), clean) != names.end(); + ImGui::BeginDisabled(clean.empty() || taken); + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kButtonHovered); + if (ImGui::Button(ui::text(ui::Msg::MapProfileCreate), ImVec2(120, 0))) { + app.create_map_profile(clean, e.copy_from); + e.adding = false; + ImGui::CloseCurrentPopup(); + } + ImGui::PopStyleColor(); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button(ui::text(ui::Msg::MapProfileCancel), ImVec2(120, 0))) { + e.adding = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +/// Unmaking one, behind a confirmation: a table is a few hundred decisions and a misclick on a +/// 30-pixel button is not a reason to lose them. +void map_delete_dialog(App& app) { + MapCheckEdit& e = app.map_edit(); + if (!e.deleting.empty() && !ImGui::IsPopupOpen("##map_profile_del")) + ImGui::OpenPopup("##map_profile_del"); + const ImVec2 display = ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowSize(ImVec2(display.x, 0.0f)); + ImGui::SetNextWindowPos(ImVec2(display.x * 0.5f, display.y * 0.5f), ImGuiCond_Always, + ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("##map_profile_del", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove)) + return; + + section(app, ui::text(ui::Msg::MapProfileDelete)); + ImGui::PushTextWrapPos(0.0f); + ImGui::Text(ui::text(ui::Msg::MapProfileDeleteAsk), e.deleting.c_str()); + ImGui::TextColored(kWarn, ui::text(ui::Msg::MapProfileDeleteWarn), + app.map_store().rated_in(e.deleting)); + ImGui::PopTextWrapPos(); + + ImGui::Separator(); + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kClose); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::col::kCloseHovered); + if (ImGui::Button(ui::text(ui::Msg::MapProfileDelete), ImVec2(140, 0))) { + app.delete_map_profile(e.deleting); + e.deleting.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::PopStyleColor(2); + ImGui::SameLine(); + if (ImGui::Button(ui::text(ui::Msg::MapProfileCancel), ImVec2(120, 0))) { + e.deleting.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +/// The search box, the `?` that says what it takes, and the button that turns it into verdicts. +void map_filter_row(App& app) { + MapCheckEdit& e = app.map_edit(); + const float spacing = ImGui::GetStyle().ItemSpacing.x; + // The `?` is a text item, so its width is the glyph's rather than a frame's. + const float help_w = ImGui::CalcTextSize("(?)").x; + + row_gutter(); // the section heading above already names this block + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - kMapIconW - help_w - spacing * 2.0f); + // A changed search invalidates a proposal made from the old one, which would otherwise sit + // there naming rows the list no longer shows. + if (ImGui::InputTextWithHint("##map_filter", ui::text(ui::Msg::MapFilterHint), &e.filter)) + e.clear_proposal(); + + // Next to the box rather than under it: the syntax is the game's own, and somebody who does + // not already know that will not go looking for a paragraph to tell them. + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetNextWindowSize(ImVec2(kMapSyntaxTipW, 0.0f)); + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(ui::text(ui::Msg::MapSearchSyntax)); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + ImGui::SameLine(); + ImGui::BeginDisabled(e.filter.empty() || app.map_store().current().empty()); + if (icon_button(app, ui::kGlyphApply, "=>", ui::text(ui::Msg::MapProposeTip), kMapIconW)) + app.propose_from_search(e.filter); + ImGui::EndDisabled(); + + row_gutter(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", ui::text(ui::Msg::MapFilterHelp)); + // Read off the terms rather than off a built filter: this runs every frame the tab is open, + // and parsing a line of text is not what compiling its regexes costs. Naming them beats + // counting them — the user can see which of what they pasted did nothing here. + std::string aside; + for (const mapcheck::SearchTerm& t : mapcheck::parse_search(e.filter)) { + if (!mapcheck::asks_about_item(t.text)) continue; + if (!aside.empty()) aside += ", "; + aside += t.text; + } + if (!aside.empty()) ImGui::TextDisabled(ui::text(ui::Msg::MapFilterSetAside), aside.c_str()); + ImGui::PopTextWrapPos(); +} + +void map_check_tab(App& app, Config& c) { + MapCheckEdit& e = app.map_edit(); + + section(app, ui::text(ui::Msg::SectionMapProfiles)); + map_profile_row(app, c); + map_profile_dialog(app); + map_delete_dialog(app); + + section(app, ui::text(ui::Msg::SectionMapModifiers)); + map_filter_row(app); + + const std::shared_ptr gd = app.game_data(); + if (!gd || !gd->has_mod_pools()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", ui::text(ui::Msg::MapPoolNoData)); + ImGui::PopTextWrapPos(); + return; + } + + // Which entries to draw, and what verdict to draw on each. A pending proposal replaces both + // — it *is* the preview, so the list shows exactly the rows Accept would write. + const std::vector& shown = app.map_pool_view(); + const size_t held = app.map_pool_size(); + + if (!e.proposal.empty()) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, kWarn); + ImGui::PushTextWrapPos(0.0f); + ImGui::Text(ui::text(ui::Msg::MapProposeCounts), e.proposed_deadly, e.proposed_safe); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kButtonHovered); + if (ImGui::Button(ui::text(ui::Msg::MapProposeApply), ImVec2(140, 0))) + app.accept_proposal(); + ImGui::PopStyleColor(); + ImGui::SameLine(); + if (ImGui::Button(ui::text(ui::Msg::MapProfileCancel), ImVec2(120, 0))) + e.clear_proposal(); + } else { + ImGui::TextDisabled(ui::text(ui::Msg::MapPoolCount), shown.size(), held); + } + + ImGui::Separator(); + if (shown.empty()) { + ImGui::TextDisabled("%s", ui::text(held ? ui::Msg::MapPoolEmpty : ui::Msg::MapPoolNoData)); + return; + } + // A child of its own, so the list scrolls under a profile row and a search box that do not. + ImGui::BeginChild("##map_pool", ImVec2(0, 0)); + for (const mapcheck::PoolGroup& g : shown) + map_pool_row(app, g, app.pool_verdict(g), !e.proposal.empty()); + ImGui::EndChild(); +} + void application_tab(App& app, Config& c) { section(app, ui::text(ui::Msg::SectionGameData)); data_row(app); @@ -841,6 +1188,7 @@ constexpr Tab kTabs[]{ {ui::Msg::TabGeneral, &general_tab}, {ui::Msg::TabPriceCheck, &price_check_tab}, {ui::Msg::TabQuickPaste, &quickpaste_tab}, + {ui::Msg::TabMapCheck, &map_check_tab}, {ui::Msg::TabApplication, &application_tab}, }; // The paste popup's "add one" opens Settings on this tab by number, and a tab inserted above diff --git a/src/ui/glyphs.hpp b/src/ui/glyphs.hpp index 74b316e..37d0e2e 100644 --- a/src/ui/glyphs.hpp +++ b/src/ui/glyphs.hpp @@ -21,10 +21,20 @@ inline constexpr const char* kGlyphGrip = "\xef\x9e\xa4"; ///< U+F7A4, grip-l inline constexpr const char* kGlyphSearch = "\xef\x80\x82"; ///< U+F002, magnifying-glass inline constexpr const char* kGlyphExternal = "\xef\x82\x8e"; ///< U+F08E, arrow-up-right-from-square inline constexpr const char* kGlyphBug = "\xef\x86\x88"; ///< U+F188, bug +inline constexpr const char* kGlyphApply = "\xef\x83\x90"; ///< U+F0D0, wand-magic + +/// The four map-check verdicts, in the order they cycle. `kGlyphUnrated` is drawn dim rather +/// than left out: every row keeps the same first column, so a list of them reads as a column of +/// answers with the blanks visible instead of as text that starts in three different places. +inline constexpr const char* kGlyphUnrated = "\xef\x84\xa8"; ///< U+F128, question +inline constexpr const char* kGlyphSafe = kGlyphConfirm; ///< U+F00C, check +inline constexpr const char* kGlyphDangerous = "\xef\x81\xb1"; ///< U+F071, triangle-exclamation +inline constexpr const char* kGlyphDeadly = "\xef\x9c\x94"; ///< U+F714, skull-crossbones /// The codepoints behind the above, for the one place that has to ask the atlas whether they /// actually baked rather than trusting that they did. inline constexpr unsigned int kGlyphCodepoints[]{0xF00C, 0xF0E2, 0xF0FE, 0xF304, 0xF2ED, - 0xF7A4, 0xF002, 0xF08E, 0xF188}; + 0xF7A4, 0xF002, 0xF08E, 0xF188, 0xF0D0, + 0xF128, 0xF071, 0xF714}; } // namespace ppc::ui diff --git a/src/ui/strings.cpp b/src/ui/strings.cpp index 91fec31..23133c4 100644 --- a/src/ui/strings.cpp +++ b/src/ui/strings.cpp @@ -21,6 +21,7 @@ constexpr const char* kEnglish[]{ "General", "Price check", "QuickPaste", + "Map check", "Application", "League and account", @@ -72,6 +73,7 @@ constexpr const char* kEnglish[]{ "Price check", "Settings", "QuickPaste", + "Map check", "press keys\xe2\x80\xa6", "Reduce transparency", @@ -103,6 +105,61 @@ constexpr const char* kEnglish[]{ "No pastes enabled.", "Add a paste", + "Profiles", + "Modifiers", + "Profile", + "No profiles yet", + "New profile", + "Name", + "e.g. Hardcore", + "Start from", + "— an empty profile —", + "Create", + "Cancel", + "Delete profile", + "Delete the profile \"%s\"?", + "Its %zu ratings go with it, and nothing here can bring them back.", + "Auto-load", + "Load this profile automatically for the character you are playing. Not implemented yet.", + "Load this profile automatically for the character you are playing. Needs reading the " + "client log, which has to be turned on first.", + "Search, or paste a map search string", + "Plain words narrow the list. A search string you already use for the map device works " + "too — quoted terms, ! for what you refuse.", + "The same search the game's own stash and map-device boxes take.\n\n" + "monster damage \xe2\x80\x94 both words, on one modifier\n" + "\"pack size\" \xe2\x80\x94 quotes hold a term with a space in it\n" + "!reflect \xe2\x80\x94 hide every modifier saying it\n" + "\\d+ e \xe2\x80\x94 the terms are real regular expressions\n" + "ll damage$ \xe2\x80\x94 ^ and $ anchor to a printed line\n" + "a|b \xe2\x80\x94 either, inside quotes as well as outside\n\n" + "A modifier matches when any one of its lines does, its affix name included. Terms like " + "ilvl:84 ask about the item rather than a modifier and are ignored here.", + "Ignored, as questions about the item and not about a modifier: %s", + "Rated on a shorter modifier this one contains. Click to decide it here instead.", + "Propose", + "Apply the search to every modifier. What a ! term hits is proposed deadly, what a plain " + "term hits is proposed safe. Nothing in between, and nothing until you accept it.", + "That search names nothing in the list.", + "%d deadly, %d safe — shown below, and not saved until you accept.", + "Accept", + "%zu of %zu modifiers", + "The installed data has no modifier pool. Update the game data to fill this list.", + "Nothing matches that search.", + "No profile yet", + "A profile is one table of verdicts. Make one to start rating modifiers.", + "%zu rated", + "Map check", + "Click a modifier to rate it: safe, dangerous, deadly, back to unrated.", + "This wording is not one the data can identify, so there is nothing to attach a verdict to.", + "This map has nothing rolled to rate.", + "You can probably run this map but it has too many unrated modifiers.", + "You can run this map safely.", + "You can run this map safely but check the unrated modifiers.", + "You should be able to run this map.", + "You should be able to run this map but be careful.", + "You will most probably die in this map.", + "Bundle", "Downloading %.1f / %.1f MB", "Downloading\xe2\x80\xa6", diff --git a/src/ui/strings.hpp b/src/ui/strings.hpp index 682e923..5f89b66 100644 --- a/src/ui/strings.hpp +++ b/src/ui/strings.hpp @@ -30,6 +30,7 @@ enum class Msg : uint16_t { TabGeneral, TabPriceCheck, TabQuickPaste, + TabMapCheck, TabApplication, SectionAccount, @@ -78,6 +79,7 @@ enum class Msg : uint16_t { HotkeyPriceCheck, HotkeySettings, HotkeyQuickPaste, + HotkeyMapCheck, PressKeys, ReduceTransparency, @@ -90,8 +92,8 @@ enum class Msg : uint16_t { PasteListHelp, PasteNone, ///< Settings, with nothing in the list yet - PasteSlotsLeft, ///< "%zu", "%zu" — active pastes and the ceiling - PasteSlotsFull, ///< "%zu" — the ceiling + PasteSlotsLeft, ///< "%zu", "%zu" — active pastes and the ceiling + PasteSlotsFull, ///< "%zu" — the ceiling PasteUntitled, PasteEmptyBody, PasteNew, @@ -104,10 +106,54 @@ enum class Msg : uint16_t { PasteBodyHint, PasteDone, PasteCancel, - PasteTooLong, ///< "%zu" — the byte ceiling + PasteTooLong, ///< "%zu" — the byte ceiling QuickPasteNone, ///< the popup, with nothing enabled to offer QuickPasteAdd, + SectionMapProfiles, + SectionMapModifiers, + MapProfile, + MapProfileNone, + MapProfileNew, + MapProfileName, + MapProfileNameHint, + MapProfileCopyFrom, + MapProfileEmpty, + MapProfileCreate, + MapProfileCancel, + MapProfileDelete, + MapProfileDeleteAsk, ///< "%s" — the profile's name + MapProfileDeleteWarn, ///< "%zu" — how many ratings go with it + MapAutoLoad, + MapAutoLoadUnbuilt, + MapAutoLoadNeedsLog, + MapFilterHint, + MapFilterHelp, + MapSearchSyntax, ///< the `?` beside the search box: the game's own search rules + MapFilterSetAside, ///< "%s" — the terms that ask about the item, not a modifier + MapVerdictInherited, ///< a row lit by a rating made on a shorter affix inside it + MapPropose, + MapProposeTip, + MapProposeNothing, + MapProposeCounts, ///< "%d", "%d" — deadly and safe + MapProposeApply, + MapPoolCount, ///< "%zu", "%zu" — shown and held + MapPoolNoData, + MapPoolEmpty, + MapNoProfile, + MapNoProfileHelp, + MapRatedCount, ///< "%zu" + MapCheckTitle, + MapRateHint, + MapUnresolved, + MapOutlookNoMods, + MapOutlookUnrated, + MapOutlookSafe, + MapOutlookSafeUnrated, + MapOutlookLikely, + MapOutlookCareful, + MapOutlookFatal, + Bundle, Downloading, ///< "%.1f", "%.1f" — megabytes done and total DownloadingPlain, diff --git a/tests/data/bundle/en-items-base.index.bin b/tests/data/bundle/en-items-base.index.bin index e66ad6e8087a474dee747d086622a2484c4ff067..7ca51122c85362b430395d6d70151bea52cb2faf 100644 GIT binary patch literal 104 zcmbQb{G$8@IR=J+V5PK4ObiSh8oVvXlo%Lx)y--D&dR{>CI0NhQ``&;E>0)rWUwvu~qFc@n1y_DjG(3AOq@`Y?~>{S>Tj@Mm!^G}?C F0RX=_AxQuL literal 104 zcmbQb{GxoLECWM8uu@tf69WT>25(ELA_K#&x;gEeSs56<#GjoQ$<4sv;&ft;1Umx* zi#yN4&+-fmkEgJ$w&G-9Xj*Q${)#vQgQ14s%XvHynuiZ4U&!|6u@VEr@w!WI#DVev DU&SB7 diff --git a/tests/data/bundle/en-items-name.index.bin b/tests/data/bundle/en-items-name.index.bin index 539b64798bfd4bbce539035a46c0af9588a03e9d..0958b0cc0a4b8085b50608eea1aeeeefc79eb59a 100644 GIT binary patch literal 400 zcmWMiSt!E+9R6JYF=KOXHfFO)xy!>ar9>?yc_2qQGAu8Sw2X2@l4CR;Ekh|sxmsz+ z1Cc9A5ygfUd5~sIkZiEDwtnBARc!x2Z6xu+Of|91I*3Wa<0OG z7P~nhgAGVG^!zj_P|f%XE{Rv}niW=a0Oww+NW%hLfr_Z$VBk-}mBny@;-0_QOgYfE z)Sm3}B3@B@>JeeNNToFS02f_{D$v<4e$AmTppbXgsX_s>9`!k4fHqs%@FN03CK4OB zB7pU{fwp)a>H2+k7~BEYu(NwvOntSw-Y@Exu=6;0OZjClYP~L!4DE)=p&($zte9FQ zKjTT`Of>2BJG?Ct+N<}Cy?sY}XcyDYOC`XlWB)3h@>tKCuia@!O+?AV|~{==9* e>D!ENdr?68tI5fsJ(TLR_asl!xo`T|5CH#+!GQq) literal 400 zcmWNNTPVW;6vjWdKV$6Tve{h57Dba1!(OSSq&>K$hKz{^myjVWOUq>>FD`RQDN>SF zLMhjXs7X<5k;sGe-)71K=e(Rwo$vRZ^S%2%0uO=#*IG3r5&;R*EF%;G$J>`20|TtB zH*gQ7K*y_HaK{0X_N*dzB2dSKiTgqToo`jbh5??_T&b8iKA|dE8wvc$c=DwPpknAR z?Ijr)-t5iX4kBGyZ*GemP)IfEeB#{=UT8q&GIkeR`M~GEgf=xAxMujJC9%L&|9O=p z0LXGK>o#f6^2o2rCSH-)Tf4vooa?{(lPI8AUs7L5cc>Ht1|#Y1Ry66vKv#jfg_Qu& z^BJwPallUcNY4z9xZywjZT>*;xW{CQr+>SQ=IKyCdGt2QBcA+IZ!Jn7!`G?BbIN1O zu3F9s2W(fZD=EaYupzsJF@V_}dN3y=pN%|6BIPp4p4~T--sxz6C>H>Y@s&?wel+ik hUbvKp(q(gZCIR_hOCCocpw{1b{i&Ou+rDop|9^?=e-8iv diff --git a/tests/data/bundle/en-items-ref.index.bin b/tests/data/bundle/en-items-ref.index.bin index 539b64798bfd4bbce539035a46c0af9588a03e9d..0958b0cc0a4b8085b50608eea1aeeeefc79eb59a 100644 GIT binary patch literal 400 zcmWMiSt!E+9R6JYF=KOXHfFO)xy!>ar9>?yc_2qQGAu8Sw2X2@l4CR;Ekh|sxmsz+ z1Cc9A5ygfUd5~sIkZiEDwtnBARc!x2Z6xu+Of|91I*3Wa<0OG z7P~nhgAGVG^!zj_P|f%XE{Rv}niW=a0Oww+NW%hLfr_Z$VBk-}mBny@;-0_QOgYfE z)Sm3}B3@B@>JeeNNToFS02f_{D$v<4e$AmTppbXgsX_s>9`!k4fHqs%@FN03CK4OB zB7pU{fwp)a>H2+k7~BEYu(NwvOntSw-Y@Exu=6;0OZjClYP~L!4DE)=p&($zte9FQ zKjTT`Of>2BJG?Ct+N<}Cy?sY}XcyDYOC`XlWB)3h@>tKCuia@!O+?AV|~{==9* e>D!ENdr?68tI5fsJ(TLR_asl!xo`T|5CH#+!GQq) literal 400 zcmWNNTPVW;6vjWdKV$6Tve{h57Dba1!(OSSq&>K$hKz{^myjVWOUq>>FD`RQDN>SF zLMhjXs7X<5k;sGe-)71K=e(Rwo$vRZ^S%2%0uO=#*IG3r5&;R*EF%;G$J>`20|TtB zH*gQ7K*y_HaK{0X_N*dzB2dSKiTgqToo`jbh5??_T&b8iKA|dE8wvc$c=DwPpknAR z?Ijr)-t5iX4kBGyZ*GemP)IfEeB#{=UT8q&GIkeR`M~GEgf=xAxMujJC9%L&|9O=p z0LXGK>o#f6^2o2rCSH-)Tf4vooa?{(lPI8AUs7L5cc>Ht1|#Y1Ry66vKv#jfg_Qu& z^BJwPallUcNY4z9xZywjZT>*;xW{CQr+>SQ=IKyCdGt2QBcA+IZ!Jn7!`G?BbIN1O zu3F9s2W(fZD=EaYupzsJF@V_}dN3y=pN%|6BIPp4p4~T--sxz6C>H>Y@s&?wel+ik hUbvKp(q(gZCIR_hOCCocpw{1b{i&Ou+rDop|9^?=e-8iv diff --git a/tests/data/bundle/en-items.ndjson b/tests/data/bundle/en-items.ndjson index ec02bac..0d9386a 100644 --- a/tests/data/bundle/en-items.ndjson +++ b/tests/data/bundle/en-items.ndjson @@ -1,50 +1,50 @@ -{"craftable":{"category":"Rings"},"dropLevel":20,"h":1,"metadataId":"Metadata/Items/Rings/Ring12","name":"Two-Stone Ring","namespace":"ITEM","refName":"Two-Stone Ring","w":1} -{"armour":{"es":[171,197]},"craftable":{"category":"Body Armours"},"dropLevel":68,"h":3,"metadataId":"Metadata/Items/Armours/BodyArmours/BodyInt17","name":"Vaal Regalia","namespace":"ITEM","refName":"Vaal Regalia","w":2} -{"armour":{"es":[26,30],"ev":[126,145]},"craftable":{"category":"Boots"},"dropLevel":70,"h":2,"metadataId":"Metadata/Items/Armours/Boots/BootsAtlas1","name":"Two-Toned Boots","namespace":"ITEM","refName":"Two-Toned Boots","w":2} +{"craftable":{"category":"Rings"},"domain":1,"dropLevel":20,"h":1,"metadataId":"Metadata/Items/Rings/Ring12","name":"Two-Stone Ring","namespace":"ITEM","refName":"Two-Stone Ring","w":1} +{"armour":{"es":[171,197]},"craftable":{"category":"Body Armours"},"domain":1,"dropLevel":68,"h":3,"metadataId":"Metadata/Items/Armours/BodyArmours/BodyInt17","name":"Vaal Regalia","namespace":"ITEM","refName":"Vaal Regalia","w":2} +{"armour":{"es":[26,30],"ev":[126,145]},"craftable":{"category":"Boots"},"domain":1,"dropLevel":70,"h":2,"metadataId":"Metadata/Items/Armours/Boots/BootsAtlas1","name":"Two-Toned Boots","namespace":"ITEM","refName":"Two-Toned Boots","w":2} {"art":"Art/2DItems/Armours/Boots/AbberathsHooves.png","name":"Abberath's Hooves","namespace":"UNIQUE","refName":"Abberath's Hooves","unique":{"base":"Goathide Boots"}} -{"craftable":{"category":"Divination Cards"},"dropLevel":75,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardTheDoctor","name":"The Doctor","namespace":"DIVINATION_CARD","refName":"The Doctor","w":1} -{"craftable":{"category":"Stackable Currency"},"dropLevel":12,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyRerollRare","name":"Chaos Orb","namespace":"ITEM","refName":"Chaos Orb","w":1} -{"armour":{"ar":[65,71],"es":[14,15]},"craftable":{"category":"Boots"},"dropLevel":36,"h":2,"metadataId":"Metadata/Items/Armours/Boots/BootsStrInt4","name":"Riveted Boots","namespace":"ITEM","refName":"Riveted Boots","w":2} +{"craftable":{"category":"Divination Cards"},"domain":43,"dropLevel":75,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardTheDoctor","name":"The Doctor","namespace":"DIVINATION_CARD","refName":"The Doctor","w":1} +{"craftable":{"category":"Stackable Currency"},"domain":43,"dropLevel":12,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyRerollRare","name":"Chaos Orb","namespace":"ITEM","refName":"Chaos Orb","w":1} +{"armour":{"ar":[65,71],"es":[14,15]},"craftable":{"category":"Boots"},"domain":1,"dropLevel":36,"h":2,"metadataId":"Metadata/Items/Armours/Boots/BootsStrInt4","name":"Riveted Boots","namespace":"ITEM","refName":"Riveted Boots","w":2} {"art":"Art/2DItems/Armours/Boots/RalakeshsImpatience.png","name":"Ralakesh's Impatience","namespace":"UNIQUE","refName":"Ralakesh's Impatience","unique":{"base":"Riveted Boots"}} -{"craftable":{"category":"Jewels"},"dropLevel":20,"h":1,"metadataId":"Metadata/Items/Jewels/JewelStr","name":"Crimson Jewel","namespace":"ITEM","refName":"Crimson Jewel","w":1} +{"craftable":{"category":"Jewels"},"domain":10,"dropLevel":20,"h":1,"metadataId":"Metadata/Items/Jewels/JewelStr","name":"Crimson Jewel","namespace":"ITEM","refName":"Crimson Jewel","w":1} {"art":"Art/2DItems/Jewels/AfflictionJewel.png","name":"That Which Was Taken","namespace":"UNIQUE","refName":"That Which Was Taken","unique":{"base":"Crimson Jewel"}} -{"craftable":{"category":"Utility Flasks"},"dropLevel":27,"h":2,"metadataId":"Metadata/Items/Flasks/FlaskUtility14","name":"Silver Flask","namespace":"ITEM","refName":"Silver Flask","w":1} -{"craftable":{"category":"Utility Flasks"},"dropLevel":27,"h":2,"metadataId":"Metadata/Items/Flasks/FlaskUtility5","name":"Granite Flask","namespace":"ITEM","refName":"Granite Flask","w":1} +{"craftable":{"category":"Utility Flasks"},"domain":2,"dropLevel":27,"h":2,"metadataId":"Metadata/Items/Flasks/FlaskUtility14","name":"Silver Flask","namespace":"ITEM","refName":"Silver Flask","w":1} +{"craftable":{"category":"Utility Flasks"},"domain":2,"dropLevel":27,"h":2,"metadataId":"Metadata/Items/Flasks/FlaskUtility5","name":"Granite Flask","namespace":"ITEM","refName":"Granite Flask","w":1} {"art":"Art/2DItems/Flasks/BlockFlask.png","name":"Rumi's Concoction","namespace":"UNIQUE","refName":"Rumi's Concoction","unique":{"base":"Granite Flask"}} {"craftable":{"category":"Instance Local Items"},"dropLevel":1,"h":1,"metadataId":"Metadata/Items/TradeProxy/MapKey","name":"Map","namespace":"ITEM","refName":"Map","tradeDisc":"map","w":1} -{"craftable":{"category":"Maps"},"dropLevel":83,"h":1,"metadataId":"Metadata/Items/Maps/MapKeyShaperGuardian","name":"Shaper Guardian Map","namespace":"ITEM","refName":"Shaper Guardian Map","w":1} +{"craftable":{"category":"Maps"},"domain":5,"dropLevel":83,"h":1,"metadataId":"Metadata/Items/Maps/MapKeyShaperGuardian","name":"Shaper Guardian Map","namespace":"ITEM","refName":"Shaper Guardian Map","w":1} {"art":"Art/2DItems/Maps/olmec.png","name":"Olmec's Sanctum","namespace":"UNIQUE","refName":"Olmec's Sanctum","tradeDisc":"map","unique":{"base":"Map"}} -{"craftable":{"category":"Maps"},"dropLevel":84,"h":1,"metadataId":"Metadata/Items/Maps/MapKeyReliquary","name":"Valdo Map","namespace":"ITEM","refName":"Valdo Map","w":1} +{"craftable":{"category":"Maps"},"domain":5,"dropLevel":84,"h":1,"metadataId":"Metadata/Items/Maps/MapKeyReliquary","name":"Valdo Map","namespace":"ITEM","refName":"Valdo Map","w":1} {"art":"Art/2DItems/Armours/Gloves/Hrimsorrow.png","name":"Hrimsorrow","namespace":"UNIQUE","refName":"Hrimsorrow","unique":{"base":"Goathide Gloves"}} -{"armour":{"ev":[32,42]},"craftable":{"category":"Gloves"},"dropLevel":9,"h":2,"metadataId":"Metadata/Items/Armours/Gloves/GlovesDex2","name":"Goathide Gloves","namespace":"ITEM","refName":"Goathide Gloves","w":2} +{"armour":{"ev":[32,42]},"craftable":{"category":"Gloves"},"domain":1,"dropLevel":9,"h":2,"metadataId":"Metadata/Items/Armours/Gloves/GlovesDex2","name":"Goathide Gloves","namespace":"ITEM","refName":"Goathide Gloves","w":2} {"art":"Art/2DItems/Armours/Gloves/Hrimsorrow.png","name":"Hrimburn","namespace":"UNIQUE","refName":"Hrimburn","unique":{"base":"Goathide Gloves"}} -{"craftable":{"category":"Divination Cards"},"dropLevel":62,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardTheBlazingFire","name":"The Blazing Fire","namespace":"DIVINATION_CARD","refName":"The Blazing Fire","w":1} -{"craftable":{"category":"Stackable Currency"},"dropLevel":26,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyEssenceHatred3","name":"Weeping Essence of Hatred","namespace":"ITEM","refName":"Weeping Essence of Hatred","w":1} -{"craftable":{"category":"Support Gems"},"dropLevel":38,"h":1,"metadataId":"Metadata/Items/Gems/SupportGemAdditionalLevel","name":"Empower Support","namespace":"GEM","refName":"Empower Support","w":1} -{"craftable":{"category":"Skill Gems"},"dropLevel":28,"h":1,"metadataId":"Metadata/Items/Gems/SkillGemTornadoShot","name":"Tornado Shot","namespace":"GEM","refName":"Tornado Shot","w":1} -{"craftable":{"category":"Skill Gems"},"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Gems/SkillGemVaalBlight","name":"Vaal Blight","namespace":"GEM","refName":"Vaal Blight","w":1} +{"craftable":{"category":"Divination Cards"},"domain":43,"dropLevel":62,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardTheBlazingFire","name":"The Blazing Fire","namespace":"DIVINATION_CARD","refName":"The Blazing Fire","w":1} +{"craftable":{"category":"Stackable Currency"},"domain":43,"dropLevel":26,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyEssenceHatred3","name":"Weeping Essence of Hatred","namespace":"ITEM","refName":"Weeping Essence of Hatred","w":1} +{"craftable":{"category":"Support Gems"},"domain":43,"dropLevel":38,"h":1,"metadataId":"Metadata/Items/Gems/SupportGemAdditionalLevel","name":"Empower Support","namespace":"GEM","refName":"Empower Support","w":1} +{"craftable":{"category":"Skill Gems"},"domain":43,"dropLevel":28,"h":1,"metadataId":"Metadata/Items/Gems/SkillGemTornadoShot","name":"Tornado Shot","namespace":"GEM","refName":"Tornado Shot","w":1} +{"craftable":{"category":"Skill Gems"},"domain":43,"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Gems/SkillGemVaalBlight","name":"Vaal Blight","namespace":"GEM","refName":"Vaal Blight","w":1} {"name":"Raise Zombie of Falling","namespace":"GEM","refName":"Raise Zombie of Falling","tradeDisc":"alt_y","tradeName":"Raise Zombie"} -{"craftable":{"category":"Chart"},"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Deepwater/ChartCoralReef","name":"Coral Reef Chart","namespace":"ITEM","refName":"Coral Reef Chart","w":1} +{"craftable":{"category":"Chart"},"domain":39,"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Deepwater/ChartCoralReef","name":"Coral Reef Chart","namespace":"ITEM","refName":"Coral Reef Chart","w":1} {"name":"SeafloorRidges","namespace":"ITEM","refName":"SeafloorRidges","tradeDisc":"chart"} {"name":"Wild Hellion Alpha","namespace":"CAPTURED_BEAST","refName":"Wild Hellion Alpha"} {"name":"Chrome-touched Croaker","namespace":"CAPTURED_BEAST","refName":"Chrome-touched Croaker"} {"name":"Farric Goliath","namespace":"CAPTURED_BEAST","refName":"Farric Goliath"} -{"craftable":{"category":"Misc Map Items"},"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Ultimatum/ItemisedTrial","name":"Inscribed Ultimatum","namespace":"ITEM","refName":"Inscribed Ultimatum","w":1} -{"craftable":{"category":"Stackable Currency"},"dropLevel":35,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyModValues","name":"Divine Orb","namespace":"ITEM","refName":"Divine Orb","w":1} +{"craftable":{"category":"Misc Map Items"},"domain":43,"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Ultimatum/ItemisedTrial","name":"Inscribed Ultimatum","namespace":"ITEM","refName":"Inscribed Ultimatum","w":1} +{"craftable":{"category":"Stackable Currency"},"domain":43,"dropLevel":35,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyModValues","name":"Divine Orb","namespace":"ITEM","refName":"Divine Orb","w":1} {"art":"Art/2DItems/Weapons/TwoHandWeapons/Staves/MartyrInnocence.png","name":"Martyr of Innocence","namespace":"UNIQUE","refName":"Martyr of Innocence","unique":{"base":"Highborn Staff"}} {"art":"Art/2DItems/Belts/InjectorBelt.png","name":"Mageblood","namespace":"UNIQUE","refName":"Mageblood","unique":{"base":"Heavy Belt"}} -{"craftable":{"category":"Divination Cards"},"dropLevel":23,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardBlindVenture","name":"Blind Venture","namespace":"DIVINATION_CARD","refName":"Blind Venture","w":1} -{"craftable":{"category":"Stackable Currency"},"dropLevel":35,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyRerollUnique","name":"Ancient Orb","namespace":"ITEM","refName":"Ancient Orb","w":1} -{"craftable":{"category":"Contracts"},"dropLevel":55,"h":1,"metadataId":"Metadata/Items/Heist/HeistContractRobotTunnels","name":"Contract: Tunnels","namespace":"ITEM","refName":"Contract: Tunnels","w":1} -{"craftable":{"category":"Blueprints"},"dropLevel":55,"h":1,"metadataId":"Metadata/Items/Heist/HeistBlueprintRobotTunnels","name":"Blueprint: Tunnels","namespace":"ITEM","refName":"Blueprint: Tunnels","w":1} -{"craftable":{"category":"Blueprints"},"dropLevel":50,"h":1,"metadataId":"Metadata/Items/Heist/HeistBlueprintCourts","name":"Blueprint: Records Office","namespace":"ITEM","refName":"Blueprint: Records Office","w":1} -{"craftable":{"category":"Contracts"},"dropLevel":80,"h":1,"metadataId":"Metadata/Items/Heist/QuestContracts/HeistContractQuestWhakanoRepeatable","name":"Vigilante Contract","namespace":"ITEM","refName":"Vigilante Contract","w":1} +{"craftable":{"category":"Divination Cards"},"domain":43,"dropLevel":23,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardBlindVenture","name":"Blind Venture","namespace":"DIVINATION_CARD","refName":"Blind Venture","w":1} +{"craftable":{"category":"Stackable Currency"},"domain":43,"dropLevel":35,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyRerollUnique","name":"Ancient Orb","namespace":"ITEM","refName":"Ancient Orb","w":1} +{"craftable":{"category":"Contracts"},"domain":22,"dropLevel":55,"h":1,"metadataId":"Metadata/Items/Heist/HeistContractRobotTunnels","name":"Contract: Tunnels","namespace":"ITEM","refName":"Contract: Tunnels","w":1} +{"craftable":{"category":"Blueprints"},"domain":22,"dropLevel":55,"h":1,"metadataId":"Metadata/Items/Heist/HeistBlueprintRobotTunnels","name":"Blueprint: Tunnels","namespace":"ITEM","refName":"Blueprint: Tunnels","w":1} +{"craftable":{"category":"Blueprints"},"domain":22,"dropLevel":50,"h":1,"metadataId":"Metadata/Items/Heist/HeistBlueprintCourts","name":"Blueprint: Records Office","namespace":"ITEM","refName":"Blueprint: Records Office","w":1} +{"craftable":{"category":"Contracts"},"domain":43,"dropLevel":80,"h":1,"metadataId":"Metadata/Items/Heist/QuestContracts/HeistContractQuestWhakanoRepeatable","name":"Vigilante Contract","namespace":"ITEM","refName":"Vigilante Contract","w":1} {"art":"Art/2DItems/Currency/Heist/SlaveMerchantFightContract.png","name":"Contract: The Slaver King","namespace":"UNIQUE","refName":"Contract: The Slaver King","unique":{"base":"Vigilante Contract"}} -{"craftable":{"category":"Sanctum Research"},"dropLevel":68,"h":1,"metadataId":"Metadata/Items/Sanctum/SanctumFloor2","name":"Sanctum Vaults Research","namespace":"ITEM","refName":"Sanctum Vaults Research","w":1} -{"craftable":{"category":"Expedition Logbooks"},"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Expedition/ExpeditionLogbook","name":"Expedition Logbook","namespace":"ITEM","refName":"Expedition Logbook","w":1} +{"craftable":{"category":"Sanctum Research"},"domain":43,"dropLevel":68,"h":1,"metadataId":"Metadata/Items/Sanctum/SanctumFloor2","name":"Sanctum Vaults Research","namespace":"ITEM","refName":"Sanctum Vaults Research","w":1} +{"craftable":{"category":"Expedition Logbooks"},"domain":5,"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Expedition/ExpeditionLogbook","name":"Expedition Logbook","namespace":"ITEM","refName":"Expedition Logbook","w":1} {"art":"Art/2DItems/Armours/Helmets/TheDarkMonarch.png","name":"The Dark Monarch","namespace":"UNIQUE","refName":"The Dark Monarch","unique":{"base":"Lich's Circlet"}} -{"armour":{"es":[117,134]},"craftable":{"category":"Helmets"},"dropLevel":84,"h":2,"metadataId":"Metadata/Items/Armours/Helmets/HelmetInt14","name":"Lich's Circlet","namespace":"ITEM","refName":"Lich's Circlet","w":2} +{"armour":{"es":[117,134]},"craftable":{"category":"Helmets"},"domain":1,"dropLevel":84,"h":2,"metadataId":"Metadata/Items/Armours/Helmets/HelmetInt14","name":"Lich's Circlet","namespace":"ITEM","refName":"Lich's Circlet","w":2} {"art":"Art/2DItems/Amulets/Malachai's BrillianceAmulet.png","name":"Replica Dragonfang's Flight","namespace":"UNIQUE","refName":"Replica Dragonfang's Flight","unique":{"base":"Onyx Amulet"}} -{"craftable":{"category":"Amulets"},"dropLevel":25,"h":1,"metadataId":"Metadata/Items/Amulets/Amulet7","name":"Onyx Amulet","namespace":"ITEM","refName":"Onyx Amulet","w":1} +{"craftable":{"category":"Amulets"},"domain":1,"dropLevel":25,"h":1,"metadataId":"Metadata/Items/Amulets/Amulet7","name":"Onyx Amulet","namespace":"ITEM","refName":"Onyx Amulet","w":1} {"art":"Art/2DItems/Belts/HinekoraBelt.png","name":"Bound Fate","namespace":"UNIQUE","refName":"Bound Fate","unique":{"base":"Cloth Belt"}} -{"craftable":{"category":"Belts"},"dropLevel":20,"h":1,"metadataId":"Metadata/Items/Belts/Belt5","name":"Cloth Belt","namespace":"ITEM","refName":"Cloth Belt","w":2} +{"craftable":{"category":"Belts"},"domain":1,"dropLevel":20,"h":1,"metadataId":"Metadata/Items/Belts/Belt5","name":"Cloth Belt","namespace":"ITEM","refName":"Cloth Belt","w":2} diff --git a/tests/data/bundle/en-mod-pools-ref.index.bin b/tests/data/bundle/en-mod-pools-ref.index.bin new file mode 100644 index 0000000000000000000000000000000000000000..ee38585b3ff0c5ffd874c1bba084c873dff363b0 GIT binary patch literal 48 zcmZ1)J)`Xo0|UbiR;*2hnoU|NHJf_zv&ydEQ>yAWivXuoG{h0Q&Vh zr`WxKz2i0w)M`*l-A7Y_(Js$DI)7lsecAI-C}5tF`gk1yeDuBy@gZP$?psFwJ|H8v z+;96R;4$(rU{wedmwJbcae$GpySVu-zz$2wT?hn(8xcIR&*Lk2t|GWWb8C{{9j?8~ z&ZJW@GI{L+hbu5F5+=MB0A1FLiJxPs@49CbFL(pfD6M%v*XJtHJ;Ph~bOppvn4DRQ8;(*RTb#ZriBio5h7lZ(g zZOdg_qtq!#X^01^*DF-lVu1VxdS!VE5FMpa3%S55E2xGLiDbvsi6j8;pGqsDCjM11 zrX9)zwqIDZ>4yQo12s27oq@-;w3~g=RCLMn*0vyEr6jNIQZUdmSKFxz0InKQ))Eufu@Gp(hSsUEPJ*wn=y(<{qC%4LaT zjs&dg2wxzwGQw)!wK9RbA5MN0B?9T%!lmZ0MyoR;RYIy!^mA?e6i_b|Z;>iYc=g)8 mQ5uAL&L84@O-=hts-sVccgT+cSsMz6O$==Q=IorO%K0B76ziG* delta 707 zcmWlXeJs>*9LL{Bt~@Pyyv`(#=kPlxdAK;snRK@~7KWuw+Vw}9-EwC&|D3WpL%3MT z!`!HZN+T78wHVj!njVHqq?YF_Y?fT>`~Cj&{pb6Af8L+h``yFtU~8WCw8CBhSYNMX zg*ySe&9~Vgr&^P4yAcocw>j9iaDY+U1&381z%ZQdEIS4`>s?%nyaAikkFFVq0cq+L zxA5bDUEd?Fj|fnx9lbUKfWGfm!5Nl-Xg!djcbZVeh))1zbuWZ-Jm|E38VLi)n5GEx zKA<;R6k{C$w3+h6oNzMc?u7V~BQUbu7i;nd3YwNAtdjsIur^^j7T|MLiBs-GPB?RB z#21*W=VclX03HfiX1g6Qe}kWu6%Ay5JD<(C07<>dY-JoEAF{nLPv*3`Yb_OXycz+^ z-&%PTFz*>JGd1T}o|>IVpmeRUXg~;LR9hGKNPrMQnKnNNSn=~Lr*MM&BP-QO0Q0v< zrw#*t7e3dmNP)fAMx7=UaPug?>1zQz*-p6igFtqcziRk!1Xxz4HQx6G>L)8&Te!g0 z*$DkIaj6*Wyd(7?5aV~lx`vYhNyWqNumm9A@cywP5b$2ueO7mxJQSo1Q2SdU1uv;U z$^N3D26>IZ!c{|k&V;JRc0z#K>cufirN41)Y?46#I@Uc-Wi7H*6K8`7y}(=prB_&1 zYA6>IDruvJPi}x^Rk4v$_Rf@>XxJ?|m8PG>hOzX4iA7T2-y45xMfONjnkT89lIDOZ znwFGTo;Ur@3uylw_eC88oYLjY(%5V`84FHC_?nix_~sxWNauVNoU74AHx$@x+(2CjLr0yg zrjZ1*v?-RR+01rwEg3SRoHetbTk9gr)mmIMgVwkA4|t!?^Lc)!J6E50FcQGaQhloS(ITLu&(|U)wl_$t#1=1cL5pW zcST>-fVowWKUoa?EohJ}-`EWli);q}X27FZtaBAGEG5d*0EfmbO)4E=WO>I2Du6(a%iLeYe3x=n z`9LZeJDuptO%poUO2EBrcb+Q;%1S*hwG1ecR(NS6AphfJXu|u`KFN$80FqB*OwA5J znA>ql!U66l^jC+<2%+&wk8dlmWHI?p`sli)(;zQSYC$ypm ztkL;VSqad-*6Cnd4g&(`Ggh7j%s;X|wo-sHq?m5o2P~GihFJv>>stwLo1w5z delta 576 zcmWNOc}x;;9K|1|nWD=R2%}OQhzeqcilR##GuWh-G;q1rah1Qz9B2Q@eDoL(XDZE{0mRn4rKvXn@|qKL(KaA%@E(&b z2DFBx^q*OPN~q_Tl{gP_Oj15DP%4(h6cY7~@v7mxo5MOtiMLW#} zx^iWQ7_q>wA<5xCq(y4Smu4y@(WKh~gjfB?_*KBh7bhFTr0G#j->GmUP#Il+dZh@k zE*Q;^g+QLbW@*R)ayUf}Lp8wvXLcG2fFH&o=XW`<`poY%7FYx3@qBH27ZM*JcCfqRpOG zWwVDuGxbY>seFTXC6ADAU+@M?fKT4uu_rr$h&uiF-dw@~Cv-;ME=EO-I%QLqrzi+9zM71KsZ{J{TlqNrrkh wnfRQsF!|q}uLlIXdgd&}B=3R6`6UH#L0q+@UIPp#-dbi6VW*@xe1}Z^A1k!KiU0rr diff --git a/tests/data/bundle/en-stats.ndjson b/tests/data/bundle/en-stats.ndjson index 150c2b8..f89f1ff 100644 --- a/tests/data/bundle/en-stats.ndjson +++ b/tests/data/bundle/en-stats.ndjson @@ -23,6 +23,7 @@ {"better":1,"matchers":[{"string":"Area is influenced by The Shaper","value":1.0}],"ref":"Area is influenced by The Shaper","trade":{"ids":{"implicit":["implicit.stat_1792283443|1"]}}} {"better":1,"matchers":[{"string":"Map contains Baran's Citadel\nItem Quantity increases amount of Rewards Baran drops by 20% of its value","value":1.0}],"ref":"Map contains Baran's Citadel\nItem Quantity increases amount of Rewards Baran drops by 20% of its value","trade":{"ids":{"implicit":["implicit.stat_2563183002|1"]}}} {"better":1,"matchers":[{"string":"Monsters have #% chance to Hinder on Hit with Spells"},{"string":"Monsters Hinder on Hit with Spells","value":100.0}],"ref":"Monsters have #% chance to Hinder on Hit with Spells","trade":{"ids":{"explicit":["explicit.stat_962720646"],"fractured":["fractured.stat_962720646"]}}} +{"better":1,"matchers":[{"string":"Area contains many Totems"}],"ref":"Area contains many Totems","trade":{"ids":{"explicit":["explicit.stat_1000591322"],"fractured":["fractured.stat_1000591322"]}}} {"better":1,"matchers":[{"string":"Area is infested with Fungal Growths\nMap's Item Quantity Modifiers also affect Blight Chest count at 25% value\nCan be Anointed up to 3 times","value":1.0},{"string":"Area is infested with Fungal Growths\nMap's Item Quantity Modifiers also affect Blight Chest count at 50% value\nCan be Anointed up to # times"}],"ref":"Area is infested with Fungal Growths\nMap's Item Quantity Modifiers also affect Blight Chest count at 50% value\nCan be Anointed up to # times","trade":{"ids":{"implicit":["implicit.stat_299373046"]}}} {"better":1,"matchers":[{"string":"Natural inhabitants of this area have been removed"}],"ref":"Natural inhabitants of this area have been removed","trade":{"ids":{"implicit":["implicit.stat_2656027173"]}}} {"better":1,"matchers":[{"string":"Has Logbook Faction: Druids of the Broken Circle"}],"ref":"Has Logbook Faction: Druids of the Broken Circle","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_faction_druids"]}}} diff --git a/tests/data/bundle/item-classes.ndjson b/tests/data/bundle/item-classes.ndjson index 02366e9..66512ce 100644 --- a/tests/data/bundle/item-classes.ndjson +++ b/tests/data/bundle/item-classes.ndjson @@ -1,20 +1,20 @@ -{"id":"Ring","itemClass":"Rings","tradeCategory":"accessory.ring"} -{"id":"Boots","itemClass":"Boots","tradeCategory":"armour.boots"} -{"id":"Gloves","itemClass":"Gloves","tradeCategory":"armour.gloves"} -{"id":"Body Armour","itemClass":"Body Armours","tradeCategory":"armour.chest"} -{"id":"StackableCurrency","itemClass":"Stackable Currency","tradeCategory":"currency"} -{"id":"DivinationCard","itemClass":"Divination Cards","tradeCategory":"card"} +{"domain":1,"id":"Ring","itemClass":"Rings","tradeCategory":"accessory.ring"} +{"domain":1,"id":"Boots","itemClass":"Boots","tradeCategory":"armour.boots"} +{"domain":1,"id":"Gloves","itemClass":"Gloves","tradeCategory":"armour.gloves"} +{"domain":1,"id":"Body Armour","itemClass":"Body Armours","tradeCategory":"armour.chest"} +{"domain":43,"id":"StackableCurrency","itemClass":"Stackable Currency","tradeCategory":"currency"} +{"domain":43,"id":"DivinationCard","itemClass":"Divination Cards","tradeCategory":"card"} {"id":"Jewel","itemClass":"Jewels","tradeCategory":"jewel.base"} -{"id":"UtilityFlask","itemClass":"Utility Flasks","tradeCategory":"flask"} -{"id":"MapKey","itemClass":"Maps","tradeCategory":"map"} -{"id":"Active Skill Gem","itemClass":"Skill Gems","tradeCategory":"gem.activegem"} -{"id":"Support Skill Gem","itemClass":"Support Gems","tradeCategory":"gem.supportgem"} -{"id":"DeepwaterChart","itemClass":"Chart","tradeCategory":"chart"} +{"domain":2,"id":"UtilityFlask","itemClass":"Utility Flasks","tradeCategory":"flask"} +{"domain":5,"id":"MapKey","itemClass":"Maps","tradeCategory":"map"} +{"domain":43,"id":"Active Skill Gem","itemClass":"Skill Gems","tradeCategory":"gem.activegem"} +{"domain":43,"id":"Support Skill Gem","itemClass":"Support Gems","tradeCategory":"gem.supportgem"} +{"domain":39,"id":"DeepwaterChart","itemClass":"Chart","tradeCategory":"chart"} {"id":"MiscMapItem","itemClass":"Misc Map Items","tradeCategory":"map.fragment"} {"id":"HeistContract","itemClass":"Contracts","tradeCategory":"heistmission.contract"} -{"id":"HeistBlueprint","itemClass":"Blueprints","tradeCategory":"heistmission.blueprint"} -{"id":"ItemisedSanctum","itemClass":"Sanctum Research","tradeCategory":"sanctum.research"} -{"id":"ExpeditionLogbook","itemClass":"Expedition Logbooks","tradeCategory":"logbook"} -{"id":"Helmet","itemClass":"Helmets","tradeCategory":"armour.helmet"} -{"id":"Amulet","itemClass":"Amulets","tradeCategory":"accessory.amulet"} -{"id":"Belt","itemClass":"Belts","tradeCategory":"accessory.belt"} +{"domain":22,"id":"HeistBlueprint","itemClass":"Blueprints","tradeCategory":"heistmission.blueprint"} +{"domain":43,"id":"ItemisedSanctum","itemClass":"Sanctum Research","tradeCategory":"sanctum.research"} +{"domain":5,"id":"ExpeditionLogbook","itemClass":"Expedition Logbooks","tradeCategory":"logbook"} +{"domain":1,"id":"Helmet","itemClass":"Helmets","tradeCategory":"armour.helmet"} +{"domain":1,"id":"Amulet","itemClass":"Amulets","tradeCategory":"accessory.amulet"} +{"domain":1,"id":"Belt","itemClass":"Belts","tradeCategory":"accessory.belt"} diff --git a/tests/data/bundle/manifest.json b/tests/data/bundle/manifest.json index 045ce81..09d96d8 100644 --- a/tests/data/bundle/manifest.json +++ b/tests/data/bundle/manifest.json @@ -7,7 +7,8 @@ ], "source": { "unique_mods_attribution": "poewiki.net, CC BY-NC 3.0", - "exchange_items": 1185 + "exchange_items": 1185, + "mod_pools": 270 }, "files": [] } diff --git a/tests/game_data_test.cpp b/tests/game_data_test.cpp index e927f8b..b2d1fa6 100644 --- a/tests/game_data_test.cpp +++ b/tests/game_data_test.cpp @@ -216,6 +216,106 @@ TEST_CASE("Item Class maps to a trade category") { CHECK(gd->trade_category_for("Nonexistent Class").empty()); } +TEST_CASE("a pool answers for a whole mod domain, not for an item") { + auto gd = fixture(); + CHECK(gd->has_mod_pools()); + const std::span maps = gd->mod_pool(5); + REQUIRE(maps.size() == 3); + // File order, which is the order the game's own table holds the modifiers in. + CHECK(maps.front()->name == "Ceremonial"); + CHECK(maps.front()->gen == 1); + // Every row behind the wording, tiers and side-area twin alike: it is provenance. + CHECK(maps.front()->tiers == 4); + CHECK(maps.front()->mods.front() == "MapTotems"); + // A domain the bundle publishes no pool for is empty, which is not the same answer as a + // bundle that has no pools at all — `has_mod_pools()` is what tells those apart. + CHECK(gd->mod_pool(1).empty()); +} + +TEST_CASE("a pooled modifier carries the span of its tiers, or no bounds at all") { + auto gd = fixture(); + const std::vector hinder = + gd->find_pool_mods(5, "Monsters have #% chance to Hinder on Hit with Spells"); + REQUIRE(hinder.size() == 1); + REQUIRE(hinder.front()->stats.size() == 1); + const PoolStat& s = hinder.front()->stats.front(); + CHECK(s.trade_id == "explicit.stat_962720646"); + CHECK(s.min == doctest::Approx(100)); + CHECK(s.max == doctest::Approx(100)); + + // A wording that prints no number has no bounds, which the reader must not confuse with + // bounds it failed to read. + const std::vector totems = gd->find_pool_mods(5, "Area contains many Totems"); + REQUIRE(totems.size() == 1); + CHECK_FALSE(totems.front()->stats.front().min.has_value()); +} + +TEST_CASE("the domain is part of what a pool lookup asks for") { + auto gd = fixture(); + // A map and a chart word this modifier identically and are separate pools. Answering with + // both would offer a chart's affix for a map, which is what the domain keeps apart. + const std::string_view wording = "Monsters have #% chance to Hinder on Hit with Spells"; + REQUIRE(gd->find_pool_mods(5, wording).size() == 1); + REQUIRE(gd->find_pool_mods(39, wording).size() == 1); + CHECK(gd->find_pool_mods(5, wording).front()->domain == 5); + CHECK(gd->find_pool_mods(39, wording).front()->domain == 39); + // A wording no entry in that domain prints. Normal, never an error: the pool describes + // what spawns naturally and an item can print more than that. + CHECK(gd->find_pool_mods(5, "# to maximum Life").empty()); +} + +TEST_CASE("a pooled modifier printing two wordings carries one entry per wording") { + auto gd = fixture(); + const std::vector found = gd->find_pool_mods(39, "Monsters cannot be Stunned"); + REQUIRE(found.size() == 1); + REQUIRE(found.front()->stats.size() == 2); + // Only one of the two prints a number, and only one is searchable: "#% more Monster Life" + // is a wording trade indexes under two hashes, so the build refuses to pick one rather + // than filtering on the wrong stat. + CHECK(found.front()->stats[0].trade_id == "explicit.stat_1041951480"); + CHECK(found.front()->stats[1].ref == "#% more Monster Life"); + CHECK(found.front()->stats[1].trade_id.empty()); + CHECK(found.front()->stats[1].min == doctest::Approx(10)); + // It is still an entry: a pool is rated, not searched. + CHECK(gd->find_pool_mods(39, "#% more Monster Life").size() == 1); +} + +TEST_CASE("a corruption implicit is filed in the implicit namespace") { + auto gd = fixture(); + const std::vector iiq = gd->find_pool_mods(5, "#% Item Quantity"); + REQUIRE(iiq.size() == 1); + CHECK(iiq.front()->gen == 5); + CHECK(iiq.front()->stats.front().trade_id == "implicit.stat_2023217031"); + // Nothing names this one: only the affixes carry an affix name. + CHECK(iiq.front()->name.empty()); +} + +TEST_CASE("which pool an item rolls from is the base's answer, then its class's") { + auto gd = fixture(); + const std::vector rings = gd->find_bases(Namespace::Item, "Two-Stone Ring"); + REQUIRE_FALSE(rings.empty()); + CHECK(rings.front()->mod_domain == 1); + CHECK(gd->mod_domain_for(rings.front(), "Rings") == 1); + + // The case the fallback exists for: trade lists all 491 maps under one entry whose game + // row is a stand-in sitting with the stackable currency, so the record states no domain + // and the class is what knows a map rolls from 5. + const std::vector maps = gd->find_bases(Namespace::Item, "Map"); + REQUIRE_FALSE(maps.empty()); + CHECK(maps.front()->mod_domain == 0); + CHECK(gd->mod_domain_for(maps.front(), "Maps") == 5); + + // A chart's own record answers, and its class agrees. + const std::vector chart = + gd->find_bases(Namespace::Item, "Coral Reef Chart"); + REQUIRE_FALSE(chart.empty()); + CHECK(gd->mod_domain_for(chart.front(), "Chart") == 39); + + // Neither says: a unique carries no domain and Jewels holds two, so nothing is claimed. + CHECK(gd->mod_domain_for(nullptr, "Jewels") == 0); + CHECK(gd->mod_domain_for(nullptr, "Nonexistent Class") == 0); +} + TEST_CASE("a base can be named by its reference name") { auto gd = fixture(); // How the app names a record the clipboard did not print — the blighted-map redirect is diff --git a/tests/hotkey_test.cpp b/tests/hotkey_test.cpp new file mode 100644 index 0000000..d0f6f5b --- /dev/null +++ b/tests/hotkey_test.cpp @@ -0,0 +1,52 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include "platform/input.hpp" + +using namespace ppc; + +// Price check is Ctrl+D and map check is Ctrl+Shift+D, so one chord is the other with a +// modifier added. Everything that decides which of them fired compares the **whole** modifier +// set, and these are the cases that would break if any of it ever became a subset test. +// +// The comparison the two OS backends make is theirs and cannot be exercised here — both are +// exact by construction: an X11 passive grab activates on the modifier mask it was registered +// with (which is why the lock combinations are enumerated one by one in `hotkeys_x11`), and +// `RegisterHotKey` is exact on its flags. `X11Hotkeys::dispatch` then re-checks `g.mods == st` +// against the event's own state, which is the equality this file is about. + +TEST_CASE("a chord is a set of modifiers, and two chords sharing a key are not equal") { + const Hotkey price = parse_hotkey("Ctrl+D"); + const Hotkey map = parse_hotkey("Ctrl+Shift+D"); + + CHECK(price.key == "D"); + CHECK(map.key == "D"); + CHECK(price.mods == Mod::Ctrl); + CHECK(map.mods == (Mod::Ctrl | Mod::Shift)); + // The whole point: the letter is shared and the chords are not the same thing. + CHECK(price.mods != map.mods); + // A subset test is what would fire the price check on the map check's chord. + CHECK(has(map.mods, Mod::Ctrl)); + CHECK_FALSE(has(price.mods, Mod::Shift)); +} + +TEST_CASE("a chord of several modifiers round-trips through the config file") { + for (const char* s : {"Ctrl+D", "Ctrl+Shift+D", "Ctrl+Shift+Alt+F5", "Alt+V", "Shift+Space", + "Ctrl+Alt+Super+Home", "F12"}) + CHECK(to_string(parse_hotkey(s)) == s); +} + +TEST_CASE("modifiers are written in one order however they were typed") { + // The file holds what `to_string` wrote, so a hand-edited "Shift+Ctrl+D" has to mean the + // same binding as the one Settings would have saved. + CHECK(parse_hotkey("Shift+Ctrl+D").mods == parse_hotkey("Ctrl+Shift+D").mods); + CHECK(to_string(parse_hotkey("Shift+Ctrl+D")) == "Ctrl+Shift+D"); +} + +TEST_CASE("an unbound hotkey is one nothing is registered for") { + CHECK_FALSE(Hotkey{}.valid()); + CHECK_FALSE(parse_hotkey("").valid()); + // Modifiers with no key are not a binding either — there is nothing to grab. + CHECK_FALSE(parse_hotkey("Ctrl+Shift+").valid()); + CHECK(parse_hotkey("Ctrl+Shift+D").valid()); +} diff --git a/tests/mapcheck_test.cpp b/tests/mapcheck_test.cpp new file mode 100644 index 0000000..607a937 --- /dev/null +++ b/tests/mapcheck_test.cpp @@ -0,0 +1,681 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include +#include + +#include +#include + +#include "item/resolve.hpp" +#include "mapcheck/filter.hpp" +#include "mapcheck/rate.hpp" +#include "mapcheck/store.hpp" +#include "mapcheck/verdict.hpp" +#include "parse_en.hpp" + +using namespace ppc::mapcheck; +namespace fs = std::filesystem; + +namespace { + +std::vector lines(std::initializer_list l) { + return std::vector(l.begin(), l.end()); +} + +/// The committed bundle slice, which carries five pool entries — one of them the two-wording +/// `Unwavering`, which is the whole reason the affix key is a set. +std::shared_ptr fixture() { + std::string err; + auto gd = ppc::data::GameData::open(fs::path(PPC_TEST_DATA_DIR) / "bundle", "en", &err); + REQUIRE_MESSAGE(gd != nullptr, "opening the fixture bundle failed: " << err); + return gd; +} + +ppc::item::Item resolved(const ppc::data::GameData& gd, const char* file) { + std::ifstream in(fs::path(PPC_TEST_DATA_DIR) / "items" / file); + REQUIRE(in.good()); + std::ostringstream ss; + ss << in.rdbuf(); + std::optional it = ppc::item::parse_item_en(ss.str()); + REQUIRE(it.has_value()); + ppc::item::resolve_item(gd, *it); + return *it; +} + +/// A directory of its own per test, removed with it. +struct TempDir { + fs::path path; + explicit TempDir(const char* tag) + : path(fs::temp_directory_path() / ("ppc-mapcheck-" + std::string(tag))) { + fs::remove_all(path); + } + ~TempDir() { fs::remove_all(path); } +}; + +} // namespace + +TEST_CASE("the worst verdict is the one that leads") { + CHECK(worse_of(Verdict::Safe, Verdict::Deadly) == Verdict::Deadly); + CHECK(worse_of(Verdict::Dangerous, Verdict::Safe) == Verdict::Dangerous); + // Unrated is the absence of a decision, so anything at all outranks it. + CHECK(worse_of(Verdict::Unrated, Verdict::Safe) == Verdict::Safe); + CHECK(worse_of(Verdict::Unrated, Verdict::Unrated) == Verdict::Unrated); +} + +TEST_CASE("a click walks the four states and comes back round") { + Verdict v = Verdict::Unrated; + v = next_verdict(v); + CHECK(v == Verdict::Safe); + v = next_verdict(v); + CHECK(v == Verdict::Dangerous); + v = next_verdict(v); + CHECK(v == Verdict::Deadly); + CHECK(next_verdict(v) == Verdict::Unrated); +} + +TEST_CASE("one deadly modifier decides the map on its own") { + // Whatever else is on it, and whether or not it is outnumbered. + CHECK(assess(Tally{7, 0, 1, 0}) == Outlook::Fatal); + CHECK(assess(Tally{0, 0, 1, 0}) == Outlook::Fatal); +} + +TEST_CASE("the map's outlook is the strongest thing true of its modifiers") { + CHECK(assess(Tally{}) == Outlook::NoMods); + // More than half safe. + CHECK(assess(Tally{4, 1, 0, 2}) == Outlook::Safe); + CHECK(assess(Tally{3, 3, 0, 0}) == Outlook::Careful); // exactly half is not more than half + // Half or more unrated, and nothing worse than safe under it. + CHECK(assess(Tally{2, 0, 0, 4}) == Outlook::Unrated); + CHECK(assess(Tally{1, 0, 0, 1}) == Outlook::Unrated); + // The same map with one dangerous modifier is no longer just unread. + CHECK(assess(Tally{2, 1, 0, 4}) == Outlook::Likely); + // More safe than dangerous, but not a majority of the map. + CHECK(assess(Tally{2, 1, 0, 3}) == Outlook::Likely); + // As many dangerous as safe, or more. + CHECK(assess(Tally{2, 2, 0, 2}) == Outlook::Careful); + CHECK(assess(Tally{1, 4, 0, 2}) == Outlook::Careful); +} + +TEST_CASE("setting a stat back to unrated takes the row out of the table") { + Profile p("Softcore"); + CHECK(p.set({"#% increased Monster Damage"}, Verdict::Deadly)); + CHECK(p.rated() == 1); + CHECK(p.verdict_of({"#% increased Monster Damage"}) == Verdict::Deadly); + // Nothing changed, so nothing to write. + CHECK_FALSE(p.set({"#% increased Monster Damage"}, Verdict::Deadly)); + CHECK(p.set({"#% increased Monster Damage"}, Verdict::Unrated)); + CHECK(p.rated() == 0); + CHECK_FALSE(p.set({"#% increased Monster Damage"}, Verdict::Unrated)); + // A stat nobody has said anything about, which is most of the pool. + CHECK(p.verdict_of({"Players are Cursed with Enfeeble"}) == Verdict::Unrated); +} + +TEST_CASE("a verdict is about an affix, so sharing one wording is not sharing a decision") { + const std::vector unwavering{"Monsters cannot be Stunned", "#% more Monster Life"}; + const std::vector juggernaut{"Monsters cannot be Stunned", + "Monsters' Action Speed cannot be modified to below Base Value", + "Monsters' Movement Speed cannot be modified to below Base Value"}; + Profile p("Hardcore"); + p.set(unwavering, Verdict::Deadly); + // The other affix grants that same wording and is a different decision, so it is untouched. + CHECK(p.exact(juggernaut) == Verdict::Unrated); + CHECK(p.verdict_of(juggernaut) == Verdict::Unrated); + // A three-line affix is not spoken for by a one-line one that happens to be inside it. + CHECK(p.exact({"Monsters cannot be Stunned"}) == Verdict::Unrated); + + // The order the wordings arrive in is not part of the key. + CHECK(p.verdict_of({"#% more Monster Life", "Monsters cannot be Stunned"}) == Verdict::Deadly); + + // A one-wording affix does speak for the affixes that contain it — until they are rated in + // their own right, when the longer key wins for being the more particular statement. + p.set({"Monsters cannot be Stunned"}, Verdict::Safe); + CHECK(p.verdict_of(juggernaut) == Verdict::Safe); + p.set(juggernaut, Verdict::Deadly); + CHECK(p.verdict_of(juggernaut) == Verdict::Deadly); + CHECK(p.verdict_of({"Monsters cannot be Stunned"}) == Verdict::Safe); + + // And it survives the file, which now writes a set per row. + const Profile back = profile_from_json("Hardcore", profile_to_json(p)); + CHECK(back.verdict_of(juggernaut) == Verdict::Deadly); + CHECK(back.verdict_of(unwavering) == Verdict::Deadly); +} + +TEST_CASE("a table round-trips through its file, bounds and all") { + Profile p("Hardcore"); + p.set({"Monsters reflect #% of Physical Damage"}, Verdict::Deadly); + p.set({"#% increased Quantity of Items found in this Area"}, Verdict::Safe); + // Written by hand or by a later version: no UI produces this today and the reader still + // has to keep it, or one visit to Settings would throw it away. + p.put(affix_key({"#% increased Monster Damage"}), Rating{Verdict::Dangerous, 40.0, std::nullopt}); + + const Profile back = profile_from_json("Hardcore", profile_to_json(p)); + CHECK(back.name() == "Hardcore"); + CHECK(back.rated() == 3); + CHECK(back.verdict_of({"Monsters reflect #% of Physical Damage"}) == Verdict::Deadly); + CHECK(back.verdict_of({"#% increased Quantity of Items found in this Area"}) == Verdict::Safe); + const Rating* r = back.rating_of({"#% increased Monster Damage"}); + REQUIRE(r != nullptr); + CHECK(r->verdict == Verdict::Dangerous); + REQUIRE(r->min.has_value()); + CHECK(*r->min == doctest::Approx(40.0)); + CHECK_FALSE(r->max.has_value()); +} + +TEST_CASE("a profile file the user broke costs the ratings, never the run") { + CHECK(profile_from_json("Boom", "{\"verdicts\": {").rated() == 0); + CHECK(profile_from_json("Boom", "").rated() == 0); + // A verdict word this version does not know reads as unrated, so the row is simply absent + // rather than becoming a wrong answer. + CHECK(profile_from_json("Boom", R"({"verdicts":{"a":"catastrophic"}})").rated() == 0); +} + +TEST_CASE("a profile name is made into something that can be a file") { + CHECK(sanitize_profile_name("Hardcore SSF") == "Hardcore SSF"); + CHECK(sanitize_profile_name("juice/rush") == "juice_rush"); + CHECK(sanitize_profile_name("who?*") == "who__"); + // Windows drops these silently, which would make two names that do not look alike open one + // file. + CHECK(sanitize_profile_name(" trailing. ") == "trailing"); + // Still a device name on Windows however it is spelled. + CHECK(sanitize_profile_name("con") == "_con"); + CHECK(sanitize_profile_name("NUL.json") == "_NUL.json"); + CHECK(sanitize_profile_name("").empty()); + CHECK(sanitize_profile_name(" . ").empty()); // nothing left once the ends are trimmed + // Substituted rather than dropped: these are two profiles and must stay two files. + CHECK(sanitize_profile_name("a/b") != sanitize_profile_name("ab")); + CHECK(sanitize_profile_name(std::string(200, 'x')).size() == kMaxProfileName); +} + +TEST_CASE("a search string is terms, not one regex") { + // The shape the roadmap fixed, and what poe.re writes. + const std::vector t = parse_search(R"("!\d+ e|te of|ents$" pte)"); + REQUIRE(t.size() == 2); + CHECK(t[0].negated); + CHECK(t[0].text == R"(\d+ e|te of|ents$)"); + CHECK_FALSE(t[1].negated); + CHECK(t[1].text == "pte"); +} + +TEST_CASE("the negation is read inside the quotes or outside them") { + const std::vector a = parse_search(R"(!"no reflect")"); + REQUIRE(a.size() == 1); + CHECK(a[0].negated); + CHECK(a[0].text == "no reflect"); + const std::vector b = parse_search(R"("!no reflect")"); + REQUIRE(b.size() == 1); + CHECK(b[0].negated); + CHECK(b[0].text == "no reflect"); +} + +TEST_CASE("a quote left open runs to the end, because the string is still being typed") { + const std::vector t = parse_search(R"(pte "m resist)"); + REQUIRE(t.size() == 2); + CHECK(t[0].text == "pte"); + CHECK(t[1].text == "m resist"); +} + +TEST_CASE("a search says wanted, unwanted or nothing at all") { + const SearchFilter f(R"("!ll damage$|reflect" quantity)"); + // A negated term hit: this is the modifier the string exists to refuse. + CHECK(f.classify(lines({"Monsters reflect 18% of Physical Damage"})) == + SearchFilter::Hit::Unwanted); + CHECK(f.classify(lines({"#% increased Quantity of Items found in this Area"})) == + SearchFilter::Hit::Wanted); + CHECK(f.classify(lines({"Area contains many Totems"})) == SearchFilter::Hit::None); + // Both sides hit, and the refusal is the stronger statement. + CHECK(f.classify(lines({"Monsters reflect 18% of Physical Damage", + "20% increased Quantity of Items found in this Area"})) == + SearchFilter::Hit::Unwanted); +} + +TEST_CASE("every term is tested against a line on its own, so an anchor means what it says") { + const SearchFilter f("damage$"); + // The second line ends in the word; joined into one string it would not, and this is a + // modifier that prints two lines. + CHECK(f.classify(lines({"Monsters have +40% Chaos Resistance", "18% increased Damage"})) == + SearchFilter::Hit::Wanted); +} + +TEST_CASE("a stat that only rolls below zero is matched on the wording the game prints") { + // The real record: one canonical wording and its inverse, and a range that can never make + // the canonical one true. + ppc::data::Stat rec; + rec.ref = "Players have #% more Defences"; + rec.matchers.push_back({"Players have #% more Defences", false, {}}); + rec.matchers.push_back({"Players have #% less Defences", true, {}}); + + ppc::data::PoolMod m; + m.name = "of Miring"; + m.stats.push_back({"Players have #% more Defences", {}, -30.0, -25.0}); + + const ppc::data::Stat* recs[]{&rec}; + const std::vector l = matchable_lines(m, recs); + // Both ends of the range, said the way the game says them. + CHECK(l[0] == "Players have 25% less Defences"); + CHECK(l[1] == "Players have 30% less Defences"); + CHECK(l[2] == "of Miring"); + + // The symptom this was found by: a term written against the printed line. + CHECK(SearchFilter("\"s def\"").classify(l) == SearchFilter::Hit::Wanted); + // And what the list has to show, since a placeholder has no sign to read. + CHECK(display_wording(&rec, m.stats[0]) == "Players have #% less Defences"); + + // A range that can produce either wording keeps the canonical one: both are lines the game + // may print, and that one is the record's identity. + ppc::data::PoolStat spans{"Players have #% more Defences", {}, -10.0, 10.0}; + CHECK(display_wording(&rec, spans) == "Players have #% more Defences"); + CHECK(printed_wording(&rec, spans.ref, -10.0) == "Players have 10% less Defences"); + CHECK(printed_wording(&rec, spans.ref, 10.0) == "Players have 10% more Defences"); + // No record to consult is the old behaviour, not a crash. + CHECK(printed_wording(nullptr, spans.ref, -10.0) == "Players have -10% more Defences"); +} + +TEST_CASE("a term hitting one wording is about the affix printing it, and about nothing else") { + ppc::data::PoolMod m; + m.name = "Protected"; + m.stats.push_back({"+#% Monster Elemental Resistances", {}, 55.0, 55.0}); + m.stats.push_back({"#% more Maps found in Area", {}, 35.0, 35.0}); + + // The term names one of the two lines, and what it decides is the modifier that prints it. + const SearchFilter f("\"ter e\""); + CHECK(f.classify(matchable_lines(m, nullptr)) == SearchFilter::Hit::Wanted); + // A term naming the affix is about all of it, which is why the name is in scope too. + CHECK(SearchFilter("protected").classify(matchable_lines(m, nullptr)) == + SearchFilter::Hit::Wanted); + + // And the containment that matters: the proposal keys on the affix's whole set, so accepting + // it says nothing about the *other* affixes granting one of these wordings. Asking per stat + // instead wrote `#% more Maps found in Area` on its own, and a key that short is one the + // propagation rule then reads onto every affix containing it. + Profile p("Softcore"); + p.set(pool_key_refs(m), Verdict::Safe); + CHECK(p.verdict_of(pool_key_refs(m)) == Verdict::Safe); + CHECK(p.verdict_of({"#% more Maps found in Area"}) == Verdict::Unrated); + CHECK(p.verdict_of({"#% more Maps found in Area", "#% increased Monster Damage"}) == + Verdict::Unrated); +} + +TEST_CASE("a term inside a long alternation is still the pattern it looks like") { + // The whole of a real string, with `\d+ e` as one alternative of a negated term. The + // alternation is regex, not text: quoting groups the spaces and escapes nothing. + const SearchFilter f( + R"("!\d+ e|te of|m resistances$|ents$|r, f|ter e|ll damage$|from$|t reg|s def|h tem" pte)"); + REQUIRE(f.size() == 2); + CHECK(f.classify(lines({"Rare Monsters have Elemental Thorns reflecting 1500 Elemental " + "Damage"})) == SearchFilter::Hit::Unwanted); + CHECK(f.classify(lines({"Monsters cannot be Leeched from"})) == SearchFilter::Hit::Unwanted); + CHECK(f.classify(lines({"Area is inhabited by Skeletons"})) == SearchFilter::Hit::None); +} + +TEST_CASE("a term is a real regex, whatever the string around it is") { + // The syntax holds patterns apart; it does not replace them. Both of these are lifted from a + // string a player actually keeps, and both mean exactly what they look like. + // Quoted, because the space inside it is part of the pattern and an unquoted one would be + // the AND — which is exactly why the string this came from quotes it. + const SearchFilter d(R"("\d+ e")"); + REQUIRE(d.size() == 1); + // Both wordings are the published pool's own, rendered at the top of their range. + CHECK(d.classify(lines({"Rare Monsters have Elemental Thorns reflecting 1500 Elemental Damage"})) == + SearchFilter::Hit::Wanted); + CHECK(d.classify(lines({"Monsters gain 3 Endurance Charge every 20 seconds"})) == + SearchFilter::Hit::Wanted); + // A number that is not followed by a space and an `e` is not this pattern, whatever else it + // has in common with it. + CHECK(d.classify(lines({"20% increased Monster Damage"})) == SearchFilter::Hit::None); + CHECK(d.classify(lines({"Monsters are Hexproof"})) == SearchFilter::Hit::None); + + const SearchFilter a(R"("ll damage$")"); + CHECK(a.classify(lines({"Monsters have 100% chance to Suppress Spell Damage"})) == + SearchFilter::Hit::Wanted); + // Anchored, and the anchor is the point of writing it this way: the same words with anything + // after them are a different modifier, and this is how a search string tells them apart. + CHECK(a.classify(lines({"Monsters have 100% chance to Suppress Spell Damage from Hits"})) == + SearchFilter::Hit::None); +} + +TEST_CASE("a term that is not a valid regex hits nothing rather than becoming its own text") { + // An unterminated group, which is what a pattern looks like halfway through being typed. + // Read as literal text it would find the first line here, and this box would then be two + // search languages with nothing on screen saying which one a term had got. + const SearchFilter f(R"("Damage (")"); + REQUIRE(f.size() == 1); + CHECK(f.classify(lines({"Monsters deal 30% extra Damage (Fire)"})) == SearchFilter::Hit::None); + CHECK(f.classify(lines({"Monsters deal 30% extra Damage"})) == SearchFilter::Hit::None); + // It still counts as a term, so filtering it shows nothing rather than showing everything. + CHECK_FALSE(f.matches(lines({"Monsters deal 30% extra Damage (Fire)"}))); + // Escaped, it is the pattern the player meant, and the same string works again. + CHECK(SearchFilter(R"("Damage \(")").classify(lines({"Monsters deal 30% extra Damage (Fire)"})) == + SearchFilter::Hit::Wanted); +} + +TEST_CASE("filtering ANDs the terms, the way the game's own search box does") { + const SearchFilter f("monster damage"); + CHECK(f.matches(lines({"#% increased Monster Damage"}))); + // Both words, not either: this box is also where somebody types two plain words. + CHECK_FALSE(f.matches(lines({"#% increased Monster Life"}))); + CHECK_FALSE(f.matches(lines({"#% increased Damage taken"}))); + // A negated term hides rather than marks, which is what it means in a search box. + const SearchFilter g("damage !monster"); + CHECK(g.matches(lines({"#% increased Damage taken"}))); + CHECK_FALSE(g.matches(lines({"#% increased Monster Damage"}))); +} + +TEST_CASE("proposing asks each term on its own, because one modifier cannot satisfy two") { + // Two wanted terms naming two different modifiers, which is how these strings are written. + const SearchFilter f("quantity pack"); + CHECK(f.classify(lines({"#% increased Quantity of Items found in this Area"})) == + SearchFilter::Hit::Wanted); + CHECK(f.classify(lines({"#% increased Monster pack size"})) == SearchFilter::Hit::Wanted); + // ANDed, as filtering does it, neither of them would be proposed at all. + CHECK_FALSE(f.matches(lines({"#% increased Quantity of Items found in this Area"}))); +} + +TEST_CASE("a term asking about the item is set aside instead of emptying the list") { + // What a real map string carries besides its modifier terms. Left in, the AND would make + // the whole list vanish and never say why. + const SearchFilter f("ilvl:84 monster"); + CHECK(f.size() == 1); + CHECK(f.set_aside() == 1); + CHECK(f.matches(lines({"#% increased Monster Damage"}))); + + // A search that is nothing but those reads as an empty box, not as one matching nothing. + const SearchFilter g("\"rarity: rare\" ts:.+"); + CHECK(g.empty()); + CHECK(g.set_aside() == 2); +} + +TEST_CASE("a keyword is only recognised by its colon, never by the word") { + // Every one of these is real modifier text in the published pool, and a keyword list would + // have swallowed the searches most worth typing. + CHECK_FALSE(asks_about_item("currency")); + CHECK_FALSE(asks_about_item("corrupted")); + CHECK_FALSE(asks_about_item("rarity")); + CHECK(asks_about_item("rarity:")); + CHECK(asks_about_item("item level: 78")); + // A colon that is part of a pattern is not a keyword. + CHECK_FALSE(asks_about_item("(?:a|b)")); + CHECK_FALSE(asks_about_item("[a-z]:")); + CHECK_FALSE(asks_about_item(":nope")); + CHECK(SearchFilter("currency").matches(lines({"#% more Currency found in Area"}))); +} + +TEST_CASE("an empty search mentions nothing") { + const SearchFilter f(" "); + CHECK(f.empty()); + CHECK(f.classify(lines({"anything at all"})) == SearchFilter::Hit::None); +} + +TEST_CASE("a wording is rendered before a term written against printed text meets it") { + CHECK(render_wording("#% increased Monster Damage", 40.0) == "40% increased Monster Damage"); + CHECK(render_wording("Adds # to # Fire Damage", 12.0) == "Adds 12 to 12 Fire Damage"); + CHECK(render_wording("+#% Monster Chaos Resistance", 3.5) == "+3.50% Monster Chaos Resistance"); + // No bound is the wordings that print no number, and they are left exactly as they are. + CHECK(render_wording("Area contains many Totems", std::nullopt) == "Area contains many Totems"); +} + +TEST_CASE("a pool entry is matched on its rendered wordings and on its affix name") { + ppc::data::PoolMod m; + m.domain = 5; + m.gen = 2; + m.name = "of Impedance"; + m.stats.push_back({"Monsters have #% chance to Hinder on Hit with Spells", "explicit.stat_1", + 100.0, 100.0}); + const std::vector l = matchable_lines(m, nullptr); + REQUIRE(l.size() == 2); + CHECK(l[0] == "Monsters have 100% chance to Hinder on Hit with Spells"); + CHECK(l[1] == "of Impedance"); + // The name is a line of the tooltip the game's own search reads, so a term naming one has + // to be able to hit. + CHECK(SearchFilter("te of").classify(l) == SearchFilter::Hit::Wanted); + // And the number is there to be matched, which a placeholder could never be. + CHECK(SearchFilter(R"(\d+% chance)").classify(l) == SearchFilter::Hit::Wanted); +} + +TEST_CASE("what a map printed is keyed on the pool entry covering it") { + const auto gd = fixture(); + // The one-wording affix keys as itself, since the domain-5 entry grants nothing else. + CHECK(pool_refs_for({"Monsters have #% chance to Hinder on Hit with Spells"}, kMapDomain, + gd.get()) == + std::vector{"Monsters have #% chance to Hinder on Hit with Spells"}); + + // The case the expansion exists for: an affix grants two wordings and the item printed one + // of them, so the printed line alone is not the key a rating in Settings was written under. + const std::vector unwavering = + pool_refs_for({"Monsters cannot be Stunned"}, kChartDomain, gd.get()); + CHECK(unwavering == + std::vector{"#% more Monster Life", "Monsters cannot be Stunned"}); + + // Nothing in the pool covers it, which is normal and never a gate: the wording stands as its + // own key and is rated on the spot like anything else. + CHECK(pool_refs_for({"Players are Cursed with Enfeeble"}, kMapDomain, gd.get()) == + std::vector{"Players are Cursed with Enfeeble"}); + // And with no pool to ask, the printed wordings are the answer rather than an empty one. + CHECK(pool_refs_for({"Monsters cannot be Stunned"}, kChartDomain, nullptr) == + std::vector{"Monsters cannot be Stunned"}); +} + +TEST_CASE("a map's rolled affixes are rated, its implicits are printed and left alone") { + const auto gd = fixture(); + const ppc::item::Item map = resolved(*gd, "map-magic-t16.txt"); + REQUIRE(is_map_device_item(map, gd.get())); + + const TempDir tmp("rate"); + Store store; + store.open(tmp.path, {}, ""); + + std::vector rows = rate(map, store, gd.get()); + REQUIRE(rows.size() == 1); + REQUIRE(rows[0].rateable()); + // The record's `ref`, not the `Monsters Hinder on Hit with Spells` the item printed: a + // wording is language-dependent and a key is not. + CHECK(rows[0].refs == + std::vector{"Monsters have #% chance to Hinder on Hit with Spells"}); + CHECK(rows[0].verdict == Verdict::Unrated); + CHECK(assess(tally(rows)) == Outlook::Unrated); + + // Rated in Settings, off the pool entry, and read back off the map. + const std::vector pool = [&] { + std::vector out; + for (const ppc::data::PoolMod* m : gd->mod_pool(kMapDomain)) + if (m->name == "of Impedance") out.push_back(m); + return out; + }(); + REQUIRE(pool.size() == 1); + store.set(pool_key_refs(*pool[0]), Verdict::Deadly); + + rows = rate(map, store, gd.get()); + REQUIRE(rows.size() == 1); + CHECK(rows[0].verdict == Verdict::Deadly); + CHECK(assess(tally(rows)) == Outlook::Fatal); +} + +TEST_CASE("an affix two pools both grant is one row, because it is one decision") { + const auto gd = fixture(); + const std::vector groups = pool_groups(*gd); + + // The slice holds five entries and `of Impedance` is in it twice — the map's and the + // chart's, identically worded. The store keys on the ref set with no domain in it, so the + // two can never hold different verdicts and drawing them apart is drawing one decision twice. + CHECK(groups.size() == 4); + const auto imp = std::find_if(groups.begin(), groups.end(), [](const PoolGroup& g) { + return g.mod->name == "of Impedance"; + }); + REQUIRE(imp != groups.end()); + CHECK(imp->all.size() == 2); + // The map's entry leads, because `kDomains` is asked in order and a map is what the reader + // is nearly always deciding about. + CHECK(imp->mod->domain == kMapDomain); + CHECK(imp->all[1]->domain == kChartDomain); + + // Rating the row writes the one key both entries share. + Profile p("Softcore"); + p.set(imp->refs, Verdict::Deadly); + CHECK(p.verdict_of(pool_key_refs(*imp->all[0])) == Verdict::Deadly); + CHECK(p.verdict_of(pool_key_refs(*imp->all[1])) == Verdict::Deadly); + + // And a term is asked about every entry's rendering, not only the one on show: the two + // pools roll different ranges and a number a term names may be in either. + const std::vector lines = group_lines(*imp, gd.get()); + CHECK(SearchFilter("hinder").matches(lines)); + CHECK(std::count(lines.begin(), lines.end(), "of Impedance") == 1); // unioned, not repeated +} + +TEST_CASE("an implicit is a row like any other, because some of them roll") { + const auto gd = fixture(); + const ppc::item::Item map = resolved(*gd, "map-rare-t16-corrupted.txt"); + + const TempDir tmp("implicit"); + Store store; + store.open(tmp.path, {}, ""); + + std::vector rows = rate(map, store, gd.get()); + REQUIRE(!rows.empty()); + // First, because the item prints it first, and rateable, which is the change: an implicit + // that can be rated in Settings and not here is the worse half of both rules. + REQUIRE(rows[0].mod() != nullptr); + CHECK(rows[0].mod()->type == ppc::data::ModType::Implicit); + REQUIRE(rows[0].rateable()); + + const std::vector refs = rows[0].refs; + store.set(refs, Verdict::Safe); + rows = rate(map, store, gd.get()); + CHECK(rows[0].verdict == Verdict::Safe); +} + +TEST_CASE("a first run is given a profile, so a map check always has somewhere to rate into") { + const TempDir tmp("seed"); + Store s; + s.open(tmp.path, {}, ""); + REQUIRE(s.names().size() == 1); + CHECK(s.names()[0] == kDefaultProfile); + CHECK(s.current() == kDefaultProfile); + // Written, not just held: the popup's first click has to land somewhere on disk. + CHECK(fs::exists(tmp.path / "Default.json")); +} + +TEST_CASE("a new profile reaches the disk before anything else can be lost") { + const TempDir tmp("create"); + Store s; + s.open(tmp.path, {}, ""); + + REQUIRE(s.create("Hardcore")); + CHECK(s.current() == "Hardcore"); + // No flush, no tick: the file is there the moment the dialog closes. + CHECK(fs::exists(tmp.path / "Hardcore.json")); + // A name already taken, and one that sanitises to nothing. + CHECK_FALSE(s.create("Hardcore")); + CHECK_FALSE(s.create(" ")); +} + +TEST_CASE("a rating waits for the throttle and lands on a flush") { + const TempDir tmp("throttle"); + Store s; + s.open(tmp.path, {}, ""); + REQUIRE(s.create("Softcore")); + + s.set({"#% increased Monster Damage"}, Verdict::Deadly); + CHECK(s.dirty()); + s.tick(); // nothing like long enough to have expired + CHECK(s.dirty()); + s.flush(); + CHECK_FALSE(s.dirty()); + + Store back; + back.open(tmp.path, {"Softcore"}, "Softcore"); + CHECK(back.verdict_of({"#% increased Monster Damage"}) == Verdict::Deadly); +} + +TEST_CASE("a store nothing has been opened on takes no rating and says nothing") { + // Not a state the application can reach — `open` seeds a profile — but the guard is what + // makes that true rather than assumed, and a rating with nowhere to go must not be counted + // as something to write. + Store s; + s.set({"#% increased Monster Damage"}, Verdict::Deadly); + CHECK_FALSE(s.dirty()); + CHECK(s.verdict_of({"#% increased Monster Damage"}) == Verdict::Unrated); +} + +TEST_CASE("a new profile can start as a copy of one that already has opinions") { + const TempDir tmp("copy"); + Store s; + s.open(tmp.path, {}, ""); + REQUIRE(s.create("Base")); + s.set({"Monsters reflect #% of Physical Damage"}, Verdict::Deadly); + s.flush(); + + REQUIRE(s.create("Derived", "Base")); + CHECK(s.verdict_of({"Monsters reflect #% of Physical Damage"}) == Verdict::Deadly); + // Copies, not shares: the two tables part company from here. + s.set({"Monsters reflect #% of Physical Damage"}, Verdict::Dangerous); + s.flush(); + s.select("Base"); + CHECK(s.verdict_of({"Monsters reflect #% of Physical Damage"}) == Verdict::Deadly); +} + +TEST_CASE("the directory is the authority and the config list is the order") { + const TempDir tmp("reconcile"); + Store seed; + seed.open(tmp.path, {}, ""); + REQUIRE(seed.create("Alpha")); + REQUIRE(seed.create("Beta")); + // Dropped in by hand, which is how a table gets shared between two machines. + { std::ofstream(tmp.path / "Zulu.json") << R"({"verdicts":{"a":"safe"}})"; } + + Store s; + // The config remembers an order and one profile whose file has since gone. "Default" is + // there because the seed run above started on an empty directory. + s.open(tmp.path, {"Beta", "Alpha", "Ghost"}, "Beta"); + REQUIRE(s.names().size() == 4); + CHECK(s.names()[0] == "Beta"); // the config's order, for the ones it still has + CHECK(s.names()[1] == "Alpha"); + CHECK(s.names()[2] == "Default"); // found on disk, appended in name order + CHECK(s.names()[3] == "Zulu"); + CHECK(s.current() == "Beta"); + s.select("Zulu"); + CHECK(s.verdict_of({"a"}) == Verdict::Safe); +} + +TEST_CASE("a selection the config no longer has falls back rather than rating nothing") { + const TempDir tmp("select"); + Store seed; + seed.open(tmp.path, {}, ""); + REQUIRE(seed.create("Only")); + + Store s; + s.open(tmp.path, {"Only"}, "Deleted"); + CHECK(s.current() == "Only"); // the config's first, since what it asked for is gone + // And a selection of something that is not there leaves the current one alone. + s.select("Deleted"); + CHECK(s.current() == "Only"); +} + +TEST_CASE("deleting a profile takes its file with it and lands the selection nearby") { + const TempDir tmp("remove"); + Store s; + s.open(tmp.path, {}, ""); + REQUIRE(s.create("One")); + REQUIRE(s.create("Two")); + REQUIRE(s.create("Three")); + s.select("Two"); + s.set({"#% increased Monster Damage"}, Verdict::Deadly); + + CHECK(s.remove("Two")); + CHECK(fs::exists(tmp.path / "One.json")); + CHECK_FALSE(fs::exists(tmp.path / "Two.json")); + // The neighbour, so deleting down a list leaves the selection under the hand. + CHECK(s.current() == "Three"); + // And the rating that was still buffered must not write the file back out. + s.flush(); + CHECK_FALSE(fs::exists(tmp.path / "Two.json")); + CHECK_FALSE(s.remove("Two")); + + CHECK(s.remove("Three")); + CHECK(s.remove("One")); + CHECK(s.remove(kDefaultProfile)); + // Deleting the last one puts the default back rather than leaving the feature with nowhere + // to write — the same reason `open` seeds it. + REQUIRE(s.names().size() == 1); + CHECK(s.names()[0] == kDefaultProfile); + CHECK(s.current() == kDefaultProfile); +} From 8f998162b407d48c7c863f3011966adfda123506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Wed, 12 Aug 2026 13:00:38 +0200 Subject: [PATCH 2/3] The client log was asked which character is playing and had no answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REMOVED: the per-character auto-load rule, in all of it — `App::auto_profile`, `apply_auto_profile` and the `set_screen` hook that called it on the way into either screen that rates. - The game never writes the selected character's name to `logs/LatestClient.txt`. A full session on 3.29.2, from `***** LOG FILE OPENING *****` through login, character select and two zones, does not contain it once; the only three line shapes in the 48 MB history that carry a name (a level-up, a death, a channel message) name party members as readily as the local character. There is nothing to key a profile on, so this is closed rather than deferred. REMOVED: `Config::map_profile_by_character` and its `by_character` object, on the read side as well as the write. A file still carrying one loads, keeps everything else, and drops the dead key on the next write. REMOVED: the disabled **Auto-load** checkbox, its `kClientLogSupported` switch and both tooltips it could have shown, plus the two strings behind them. CHANGED: `select_map_profile` persists unconditionally — the profile last picked, in the popup or in Settings, is the one the next launch opens on. CHANGED: ROADMAP marks 0.7 shipped and its **Might** for the search-string import as built. VERSION is untouched; the release workflow owns it. CHANGED: docs/roadmap.md gains a **Decided against** section, and map-check.md the evidence behind this one — reopen it with a capture, not with reasoning. --- CLAUDE.md | 6 ++-- ROADMAP.md | 26 ++++++++++------- docs/map-check.md | 52 ++++++++++++++++++++++++++------- docs/roadmap.md | 17 ++++++++++- src/app.cpp | 29 ++---------------- src/app.hpp | 13 --------- src/config.cpp | 7 ----- src/config.hpp | 10 ++----- src/screens/settings_screen.cpp | 29 ++++-------------- src/ui/strings.cpp | 4 --- src/ui/strings.hpp | 3 -- 11 files changed, 87 insertions(+), 109 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 473d5bc..bb70431 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,10 @@ the relay it posts to), the binary updater (with the Windows installer it depend **map check** — the modifier pool, the per-profile verdict tables, the popup and the search-string import — are all **built and tested**. What is not built is [docs/roadmap.md](docs/roadmap.md) — including the fact that a language other -than English cannot yet be selected, because the data build emits only English, and map check's -"switch profile by watching `Client.txt`", whose Settings checkbox is drawn disabled and says so. +than English cannot yet be selected, because the data build emits only English. Map check's second +"might", switching profile by watching the client log, is **not** on that list: the log never names +the character you selected, so it was closed rather than deferred — see that doc's **Decided +against**, and bring a capture if you reopen it. Sections of any doc describing an unbuilt layer say so explicitly. Keep them honest. diff --git a/ROADMAP.md b/ROADMAP.md index b1d079f..5aa9cc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,32 +97,36 @@ search, a whisper you send twenty times an evening. **Might** - grow that keystroke as an option later if it turns out to be wanted. -## 0.7 - Map check +## 0.7 - Map check - **shipped** A hotkey that reads a map's rolled modifiers and tells you which ones you decided you cannot take. -**Will:** +**Does:** - Mark each modifier **safe**, **dangerous** or **deadly**, and remember it. - Lead with the worst verdict on the map. - Draw unrated modifiers as unrated, with the rating control on the spot. The table fills in by being used. -- Keep a table per character profile, picked in the popup and sticky until you change it. A new - profile can start as a copy of an existing one. -- Rate a modifier as a modifier, at every roll - the way a map regex does. **Will not** ask you +- Keep a table per profile, picked in the popup or in Settings and remembered until you change + it. A new profile can start as a copy of an existing one. +- Rate a modifier as a modifier, at every roll - the way a map regex does. **Does not** ask you for a threshold on each of a few hundred mods. +- Rate a whole modifier, however many lines it prints, and apply that to every modifier carrying + the same lines. Implicits included. -**Might - seed the table from a map regex you already use.** Paste +**Also does - seed the table from a map regex you already use.** Paste `"!\d+ e|te of|m resistances$|ents$|r, f|ter e|ll damage$|from$|t reg|s def|h tem" pte` and every -modifier an excluding term hits is *proposed* dangerous, every one a wanted term hits *proposed* +modifier an excluding term hits is *proposed* deadly, every one a wanted term hits *proposed* safe. You confirm; from then on the table is what the tool believes. It imports imperfectly on purpose - the game's search reads a whole item where this reads modifier wordings, and a term written against a printed number is being matched against a placeholder - so it is a head start, -not an answer. The regexes come from the paste list in 0.6. +not an answer. The regexes come from the paste list in 0.6. The same syntax narrows the modifier +list in Settings, and a **?** beside the box says what it takes. -**Might - switch profile automatically** by watching `LatestClient.log`. Outside the 1.0 promise: -it ships if it is cheap and is dropped without argument if it is not. Either way it goes in -[PRIVACY.md](PRIVACY.md), as does the verdict table. +**Dropped - switch profile automatically** by watching the client log. The log never names the +character you selected, and the three lines that do carry a name (a level-up, a death, a chat +message) name your party as readily as you, so there is nothing to switch on. The profile you +pick is remembered instead, and the verdict table goes in [PRIVACY.md](PRIVACY.md) as promised. ## 0.8 - Every language the client speaks diff --git a/docs/map-check.md b/docs/map-check.md index 958125f..1a44472 100644 --- a/docs/map-check.md +++ b/docs/map-check.md @@ -86,14 +86,9 @@ being typed. So `persist_map_profile` re-reads the file, lays the two map-check already there, and writes that. Creating and deleting go through it too, which is what makes the config's ordering keep up with the directory rather than waiting for a Save. -**Under an auto-load rule, a switch by hand is temporary instead.** When the profile is decided by -the character being played, picking another is a look at a second table rather than a new -preference: it stands until the next time a screen that rates opens, and `apply_auto_profile` — -called on the way into the popup and into Settings, both through `set_screen` — puts the -character's own back. `auto_profile()` is what decides, and it returns nothing today, so every -selection is the user's own to keep. Reading `Client.txt` is 0.7's "might" and is not built, which -is what the checkbox is disabled for; the rest of the rule is written now so that landing it is a -function body rather than a design. +**Picking a profile is the only way one is picked**, and that is not a gap left for later — see +"The client log cannot say which character is playing" below. There is no auto-load rule, no +per-character mapping in `config.json` and no rule under which a hand-picked profile is temporary. There is **always at least one profile**. An empty directory is given `Default` at startup, and deleting the last one puts it back: a verdict is only ever put into a table, so a popup opening @@ -350,6 +345,45 @@ hashCode and adler32 were each tested against `map_zana_influence`, over the raw to a printed line. This is a separate known issue, out of scope here, and named so it is not rediscovered as a symptom of this work. +## The client log cannot say which character is playing + +0.7's second "might" — switch profile by watching the client log — is **not deferred. It cannot be +built**, and the evidence is here so it is not proposed again next league. + +The log is `logs/LatestClient.txt` beside the game's executable, one file per launch, and +`logs/Client.txt` is the same lines appended forever. **Selecting a character writes nothing named +to either.** A launch that goes through character select into the game reads, whole: + +```text +[SCENE] Set Source [(null)] +Got Instance Details from login server +Connecting to instance server at :6112 +Client-Safe Instance ID = +Generating level area "" with seed +: You have entered . +``` + +Checked against a full 1h13m session on 3.29.2, from `***** LOG FILE OPENING *****` through login, +character select and two zones: the character's name appears in it **zero times**. + +Three line shapes in the whole 48 MB history do carry a character name, and each names other +players as readily as you: + +- ` () is now level ` — also fires for party members +- ` has been slain.` — same +- `#<>GUILD<> : text` — a channel line, identical whoever typed it + +Nothing distinguishes the local character from anyone else in the party or the channel, so there +is no signal to key a profile on. State outside the log is no better: the game's +`production_Config.ini` leaves `account_name` empty, and its minimap cache names files by hash. +The one deterministic hook is whispering yourself, which prints `@To :` — a name that +can be trusted, at the cost of the user typing a whisper after every character switch, which is +worse than the combo box they would otherwise use. + +So the profile in use is the one the user picked, and remembering it is the whole of the feature. +Asking GGG's account API instead is not an alternative worth having: it needs a session cookie, +and this program deliberately holds no account credentials — see [../PRIVACY.md](../PRIVACY.md). + ## What the app gains **`src/data` — built.** `en-mod-pools.ndjson` and its index load exactly as the optional datasets @@ -579,5 +613,3 @@ sits on. pool so they can be rated like anything else. This wants a **capture**, not reasoning — the same rule every number in this project is held to. - **The two import semantics above** — any-wording-hits, and whether the affix name is matchable. -- **What a profile is keyed on**, if `LatestClient.log` watching is ever built. It is outside the - 1.0 promise and goes in `PRIVACY.md` either way. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e1e2fa..aaa261b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -77,7 +77,8 @@ semantics it had left open, are in [map-check.md](map-check.md). right there; seeding from the full known-mod list needs the wording rendered with something in the placeholder, and a term that can only match a number is one this cannot honestly resolve. - The import proposes and the user confirms. Nothing writes a verdict the user has not seen. -- The store, the profiles and any `LatestClient.log` read are all `PRIVACY.md` entries. +- The store and the profiles are `PRIVACY.md` entries. The client log is not read at all — see + **Decided against** below. ### 0.8, client languages @@ -188,3 +189,17 @@ Known, argued, and unscheduled — except where a planned version claims one, wh and every unique-mod range for it is then emitted 100× too large (`40..40` for a roll of `0.4`). The app refuses such a range rather than believing it, so the damage is contained; the fix is upstream, and `examples/item_3` is the case to check it against. + +## Decided against + +Not backlog. Each of these was proposed, investigated against the real thing, and closed — the +reason is here so it is not proposed a second time. + +- **Switching the map-check profile by watching the client log** — 0.7's second "might", and it + cannot be built rather than merely being unbuilt. The game never writes the selected character's + name to `logs/LatestClient.txt`, and the three line shapes that do carry a name name your party + as readily as you. The full evidence, the shape of a login as it is actually logged, and why the + account API is not an alternative are in [map-check.md](map-check.md#the-client-log-cannot-say-which-character-is-playing). + What replaced it is the profile last picked being remembered, which is a `persist_map_profile` + call and no new file, host or log line. **If this is reopened, reopen it with a capture** — a + client log in which a character selection is actually named — and not with reasoning. diff --git a/src/app.cpp b/src/app.cpp index ef24068..9d76bcb 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -866,18 +866,6 @@ void App::rate_map_row(size_t index, std::optional v) { need_redraw_ = true; } -std::string App::auto_profile() const { - // Not built — see the header. This is the one body that changes the day `Client.txt` is - // watched, and everything reading it is already written for a name coming back. - return {}; -} - -void App::apply_auto_profile() { - const std::string want = auto_profile(); - if (want.empty()) return; - select_map_profile(want); -} - void App::persist_map_profile() { Config on_disk = Config::load(); on_disk.map_profiles = config_.map_profiles; @@ -895,16 +883,9 @@ void App::select_map_profile(std::string_view name) { for (mapcheck::Row& r : map_rows_) if (r.rateable()) r.verdict = map_store_.verdict_of(r.refs); debug::log("[map] profile -> '%s'", config_.map_profile.c_str()); - // **A selection is remembered only when it is the user's own to make.** With no auto-load - // rule in force — which is every case today — the profile last picked in Settings *or* in - // the popup is the one the next launch opens on, and it is written now rather than waiting - // on a Save the popup has no button for. - // - // Under a rule, the character decides and picking by hand is a look at another table rather - // than a new preference: it stands until the next time a rating screen opens, when - // `apply_auto_profile` puts the character's own back. Writing it would let the look outlive - // the session that took it. - if (auto_profile().empty()) persist_map_profile(); + // The profile last picked in Settings *or* in the popup is the one the next launch opens + // on, and it is written now rather than waiting on a Save the popup has no button for. + persist_map_profile(); need_redraw_ = true; } @@ -1640,10 +1621,6 @@ void App::set_screen(Screen s) { // them. `kWriteDelay` batches the clicks; this is what makes it safe to. if ((screen_ == Screen::MapCheck || screen_ == Screen::Settings) && s != screen_) map_store_.flush(); - // And on the way in, the other half of the same rule: a profile picked by hand under an - // auto-load rule lasts until a rating screen opens again, and this is that moment. A no-op - // while `auto_profile` has nothing to say, which is every case today. - if ((s == Screen::MapCheck || s == Screen::Settings) && s != screen_) apply_auto_profile(); // A fresh popup measures itself from scratch: the last map's height is not an estimate of // this one's, and starting from it would draw one frame at the wrong size. if (s == Screen::MapCheck && screen_ != Screen::MapCheck) mapcheck_h_ = 0; diff --git a/src/app.hpp b/src/app.hpp index 1dde7d7..0580698 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -321,15 +321,6 @@ class App { void create_map_profile(const std::string& name, const std::string& copy_from); void delete_map_profile(const std::string& name); - /// The profile the auto-load rule says this character should be rating into, or empty when - /// no rule applies — which is what makes a selection the user's own to keep. - /// - /// **Always empty today.** Knowing which character is logged in means reading `Client.txt`, - /// which is 0.7's "might" and is not built — it is what the Settings checkbox is disabled - /// for. With no character there is nothing to look up in `Config::map_profile_by_character`. - /// Everything either side of it is written against this one day returning a name, so that - /// day is a function body and not a design. - std::string auto_profile() const; /// How tall the popup actually drew. Reported back by the screen because the window has to /// be sized before there is a frame to measure in — see `place_overlay`. void set_mapcheck_height(float h) { mapcheck_h_ = h; } @@ -402,10 +393,6 @@ class App { void place_overlay(); ///< size + position the overlay for the current screen Side cursor_side() const; ///< which half of the game window the mouse is in void set_screen(Screen s); - /// Put the auto-loaded profile back, if there is one. Called on the way into either screen - /// that rates, which is what makes a hand-picked profile last exactly as long as the screen - /// it was picked on. - void apply_auto_profile(); /// Write the map-check half of the configuration, and nothing else. /// /// **Re-read from disk first, deliberately.** Settings edits `config_` in place and only its diff --git a/src/config.cpp b/src/config.cpp index e14d89d..d72f533 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -58,10 +58,6 @@ void read_into(Config& c, const json& j) { if (!name.empty()) c.map_profiles.push_back(std::move(name)); } c.map_profile = mapcheck::sanitize_profile_name(m.value("profile", std::string())); - if (m.contains("by_character") && m["by_character"].is_object()) - for (const auto& [character, profile] : m["by_character"].items()) - if (profile.is_string()) - c.map_profile_by_character.emplace_back(character, profile.get()); } if (j.contains("pastes") && j["pastes"].is_array()) { for (const auto& p : j["pastes"]) { @@ -131,9 +127,6 @@ bool Config::save() const { j["hotkeys"]["map_check"] = to_string(map_check); j["map_check"]["profiles"] = map_profiles; j["map_check"]["profile"] = map_profile; - j["map_check"]["by_character"] = json::object(); - for (const auto& [character, profile] : map_profile_by_character) - j["map_check"]["by_character"][character] = profile; // Written even when empty, so the file says the feature exists and where its entries go. j["pastes"] = json::array(); for (const Paste& p : pastes) diff --git a/src/config.hpp b/src/config.hpp index 02ac317..51cfaa9 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -2,8 +2,6 @@ #include #include -#include - #include #include "item/range_match.hpp" @@ -67,13 +65,9 @@ struct Config { /// A record rather than the authority: `MapCheckService` also reads the directory, so a /// file dropped in by hand appears and one listed here that has gone is dropped. std::vector map_profiles; - /// Which of them is in use. Empty until there is one. + /// Which of them is in use — the one last picked, in Settings or in the popup, and the one + /// the next launch opens on. Empty until there is one. std::string map_profile; - /// Character name → profile name, for the automatic switch that watching `Client.txt` - /// would drive. **Nothing reads it yet** — that watching is 0.7's "might" and is not built, - /// which is what the Settings checkbox is disabled for. Round-tripped so a hand-written - /// entry survives until it can be honoured. - std::vector> map_profile_by_character; /// Run the trade search as soon as the panel opens, rather than on the Search button. /// Off by default and deliberately so: a hotkey the user pressed to *read* an item diff --git a/src/screens/settings_screen.cpp b/src/screens/settings_screen.cpp index a2c1162..54d5d63 100644 --- a/src/screens/settings_screen.cpp +++ b/src/screens/settings_screen.cpp @@ -796,11 +796,6 @@ void quickpaste_tab(App& app, Config& c) { // already use. Everything here is written per **domain** rather than per map, so the same page // serves the flask, abyss jewel or idol pool the day one is published. -/// Reading `Client.txt` to know which character is logged in is 0.7's "might" and is **not -/// built**. Both tooltips the checkbox can carry are written; this is which one it shows, and -/// the switch that turns the row on the day the log watcher lands. -constexpr bool kClientLogSupported = false; - constexpr float kMapVerdictW = 26.0f; constexpr float kMapIconW = 30.0f; /// The syntax tooltip is a short reference and not a sentence, so it is given a width to wrap @@ -875,15 +870,13 @@ void map_pool_row(App& app, const mapcheck::PoolGroup& group, App::PoolRating sh ImGui::PopID(); } -/// The profile row: which table is in use, whether it should follow the character, and the two -/// buttons that make and unmake one. -void map_profile_row(App& app, Config& c) { +/// The profile row: which table is in use, and the two buttons that make and unmake one. +void map_profile_row(App& app) { mapcheck::Store& store = app.map_store(); MapCheckEdit& e = app.map_edit(); - // The combo gets a width it can show a name in and the checkbox takes what is left; the two - // buttons are right-aligned so neither of the other two can push them off the row. Sharing - // the leftovers three ways put the profile's name behind an ellipsis. + // The combo gets a width it can show a name in; the two buttons are right-aligned so it + // cannot push them off the row. constexpr float kProfileComboW = 260.0f; row_label(ui::text(ui::Msg::MapProfile)); ImGui::SetNextItemWidth(kProfileComboW); @@ -898,18 +891,6 @@ void map_profile_row(App& app, Config& c) { ImGui::EndCombo(); } - // Disabled either way today, and the tooltip is the difference: one says the feature does - // not exist, the other says what to turn on. Writing both now costs a line and means the - // day the log watcher lands, only `kClientLogSupported` moves. - ImGui::SameLine(); - bool follows = false; - ImGui::BeginDisabled(true); - ImGui::Checkbox(ui::text(ui::Msg::MapAutoLoad), &follows); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("%s", ui::text(kClientLogSupported ? ui::Msg::MapAutoLoadNeedsLog - : ui::Msg::MapAutoLoadUnbuilt)); - right_align(kMapIconW * 2.0f + ImGui::GetStyle().ItemSpacing.x); ImGui::BeginDisabled(store.current().empty()); if (icon_button(app, ui::kGlyphDelete, "X", ui::text(ui::Msg::MapProfileDelete), kMapIconW)) @@ -1080,7 +1061,7 @@ void map_check_tab(App& app, Config& c) { MapCheckEdit& e = app.map_edit(); section(app, ui::text(ui::Msg::SectionMapProfiles)); - map_profile_row(app, c); + map_profile_row(app); map_profile_dialog(app); map_delete_dialog(app); diff --git a/src/ui/strings.cpp b/src/ui/strings.cpp index 23133c4..6b2c5dd 100644 --- a/src/ui/strings.cpp +++ b/src/ui/strings.cpp @@ -119,10 +119,6 @@ constexpr const char* kEnglish[]{ "Delete profile", "Delete the profile \"%s\"?", "Its %zu ratings go with it, and nothing here can bring them back.", - "Auto-load", - "Load this profile automatically for the character you are playing. Not implemented yet.", - "Load this profile automatically for the character you are playing. Needs reading the " - "client log, which has to be turned on first.", "Search, or paste a map search string", "Plain words narrow the list. A search string you already use for the map device works " "too — quoted terms, ! for what you refuse.", diff --git a/src/ui/strings.hpp b/src/ui/strings.hpp index 5f89b66..e1d6dea 100644 --- a/src/ui/strings.hpp +++ b/src/ui/strings.hpp @@ -124,9 +124,6 @@ enum class Msg : uint16_t { MapProfileDelete, MapProfileDeleteAsk, ///< "%s" — the profile's name MapProfileDeleteWarn, ///< "%zu" — how many ratings go with it - MapAutoLoad, - MapAutoLoadUnbuilt, - MapAutoLoadNeedsLog, MapFilterHint, MapFilterHelp, MapSearchSyntax, ///< the `?` beside the search box: the game's own search rules From d375103b94c11c91e4cc9975c9f47f0aac45ca43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Wed, 12 Aug 2026 13:13:46 +0200 Subject: [PATCH 3/3] MSVC has no elvis operator, and the import paragraph is the maintainer's words CHANGED: `PPC_DEV_MAP`'s fallback to `PPC_DEV_ITEM` is spelled out instead of using `?:`. - A GNU extension, so the Windows job failed to compile `app.cpp` while Linux built it clean. It was the only one in the tree. CHANGED: ROADMAP's search-string import paragraph is rewrapped to the file's width, its footnote reads as a footnote rather than a bullet, and one typo is fixed. --- ROADMAP.md | 20 ++++++++++++-------- src/app.cpp | 6 ++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5aa9cc9..79123ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,14 +114,18 @@ A hotkey that reads a map's rolled modifiers and tells you which ones you decide - Rate a whole modifier, however many lines it prints, and apply that to every modifier carrying the same lines. Implicits included. -**Also does - seed the table from a map regex you already use.** Paste -`"!\d+ e|te of|m resistances$|ents$|r, f|ter e|ll damage$|from$|t reg|s def|h tem" pte` and every -modifier an excluding term hits is *proposed* deadly, every one a wanted term hits *proposed* -safe. You confirm; from then on the table is what the tool believes. It imports imperfectly on -purpose - the game's search reads a whole item where this reads modifier wordings, and a term -written against a printed number is being matched against a placeholder - so it is a head start, -not an answer. The regexes come from the paste list in 0.6. The same syntax narrows the modifier -list in Settings, and a **?** beside the box says what it takes. +**Also does - seed the table from a map regex you already use.** Paste a regex from a tool like +poe.re\* (for example +`"!\d+ e|te of|m resistances$|ents$|r, f|ter e|ll damage$|from$|t reg|s def|h tem"`) and every +modifier an excluding term hits is *proposed* deadly, every one a wanted term hits *proposed* safe. +You confirm; from then on the table is what the tool believes. It can import imperfectly - the +game's search reads a whole item where this reads modifier wordings, and a term written against a +printed number is being matched against a placeholder - think of it as a head start. The real +ratings will come from using the tool and fill up pretty quickly. The same syntax narrows the +modifier list in Settings, and a **?** beside the box says what it takes. + +\* When building the regex, don't add filters for Quantity&Yield, Map State or Quality. "I don't +want any of these mods" and "I want these mods" with match type "Any". **Dropped - switch profile automatically** by watching the client log. The log never names the character you selected, and the three lines that do carry a name (a level-up, a death, a chat diff --git a/src/app.cpp b/src/app.cpp index 9d76bcb..d4113e3 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -362,8 +362,10 @@ int App::run(bool relaunched_after_update) { cursor_x_ = static_cast(mx); cursor_y_ = static_cast(my); set_screen(Screen::QuickPaste); - } else if (const char* path = std::getenv("PPC_DEV_MAP") ?: std::getenv("PPC_DEV_ITEM")) { - const bool map = std::getenv("PPC_DEV_MAP") != nullptr; + // Spelled out rather than with `?:`, which is a GNU extension MSVC does not have. + } else if (const char* dev_map = std::getenv("PPC_DEV_MAP"); + const char* path = dev_map ? dev_map : std::getenv("PPC_DEV_ITEM")) { + const bool map = dev_map != nullptr; std::ifstream in(path, std::ios::binary); if (in) { std::ostringstream ss;