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..bb70431 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,10 +21,14 @@ 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. 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. @@ -47,7 +51,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 +103,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 22aaba0..ab2bde8 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 the status indicator is shown, 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 the status indicator is shown, 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/ROADMAP.md b/ROADMAP.md index b1d079f..79123ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,32 +97,40 @@ 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. - -**Might - 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* -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. - -**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. +- 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 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 +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/architecture.md b/docs/architecture.md index 62a7dd5..f8a053a 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, @@ -246,7 +251,7 @@ solved back through. `Config::status_marker` (Settings → General → Appearance, on by default) turns the marker off altogether; with it off the idle overlay is never mapped, and nothing else about a check changes. -**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 @@ -355,7 +360,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..1a44472 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,98 @@ 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. + +**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 +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 +173,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 +224,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 +243,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 @@ -209,74 +345,267 @@ 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`.** `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 @@ -284,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 b9d82ab..aaa261b 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,16 +63,22 @@ 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 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 @@ -180,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/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 11c8fc9..5499b64 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -13,6 +13,7 @@ #include "icon.hpp" #include "item/resolve.hpp" +#include "mapcheck/filter.hpp" #include "net/http.hpp" #include "paths.hpp" #include "platform/clipboard.hpp" @@ -22,6 +23,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" @@ -138,6 +140,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 @@ -320,6 +327,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. @@ -358,16 +369,25 @@ 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")) { + // 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; 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); @@ -391,6 +411,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(); @@ -414,12 +435,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 @@ -435,6 +469,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(); @@ -525,6 +560,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(); } @@ -533,6 +569,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); @@ -552,7 +589,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); } @@ -624,7 +663,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 @@ -783,6 +832,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", @@ -808,6 +858,183 @@ 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; +} + +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()); + // 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; +} + +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; } @@ -962,12 +1189,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; @@ -976,20 +1207,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(); @@ -1031,8 +1275,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); @@ -1141,7 +1385,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(); @@ -1184,26 +1428,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; } @@ -1375,6 +1632,14 @@ 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(); + // 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. @@ -1396,7 +1661,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 @@ -1466,7 +1736,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..0580698 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,61 @@ 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); + + /// 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 +393,13 @@ 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); + /// 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 +443,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 +465,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 5477871..af01eed 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,20 @@ 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 (j.contains("pastes") && j["pastes"].is_array()) { for (const auto& p : j["pastes"]) { @@ -110,6 +125,9 @@ 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; // 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 b32b645..397e09a 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -2,7 +2,6 @@ #include #include - #include #include "item/range_match.hpp" @@ -47,12 +46,29 @@ 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 — 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; + /// 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 48f8589..6c4fbd6 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); @@ -790,6 +793,331 @@ 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. + +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, 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; 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); + 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(); + } + + 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); + 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); @@ -847,6 +1175,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 2cee190..55f80bf 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", @@ -106,6 +108,57 @@ 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.", + "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 d3ef955..e6d84ae 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, @@ -92,8 +94,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, @@ -106,10 +108,51 @@ 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 + 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 e66ad6e..7ca5112 100644 Binary files a/tests/data/bundle/en-items-base.index.bin and b/tests/data/bundle/en-items-base.index.bin differ diff --git a/tests/data/bundle/en-items-name.index.bin b/tests/data/bundle/en-items-name.index.bin index 539b647..0958b0c 100644 Binary files a/tests/data/bundle/en-items-name.index.bin and b/tests/data/bundle/en-items-name.index.bin differ diff --git a/tests/data/bundle/en-items-ref.index.bin b/tests/data/bundle/en-items-ref.index.bin index 539b647..0958b0c 100644 Binary files a/tests/data/bundle/en-items-ref.index.bin and b/tests/data/bundle/en-items-ref.index.bin differ 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 0000000..ee38585 Binary files /dev/null and b/tests/data/bundle/en-mod-pools-ref.index.bin differ diff --git a/tests/data/bundle/en-mod-pools.ndjson b/tests/data/bundle/en-mod-pools.ndjson new file mode 100644 index 0000000..05b979b --- /dev/null +++ b/tests/data/bundle/en-mod-pools.ndjson @@ -0,0 +1,5 @@ +{"domain":5,"gen":1,"mods":["MapTotems","CorruptedSideAreaTotems","MapTotemsMap2Tier","MapTotemsMapWorlds"],"name":"Ceremonial","stats":[{"ref":"Area contains many Totems","trade":"explicit.stat_1000591322"}],"tiers":4} +{"domain":5,"gen":2,"mods":["MapMonstersHinderOnHitMapWorlds","MapMonstersHinderOnHitMapWorldsMaven","MapMonstersHinderOnHitMapWorldsExpedition"],"name":"of Impedance","stats":[{"max":100,"min":100,"ref":"Monsters have #% chance to Hinder on Hit with Spells","trade":"explicit.stat_962720646"}],"tiers":3} +{"domain":39,"gen":2,"mods":["MapDeepwaterChartMonstersHinderOnHit"],"name":"of Impedance","stats":[{"max":100,"min":100,"ref":"Monsters have #% chance to Hinder on Hit with Spells","trade":"explicit.stat_962720646"}],"tiers":1} +{"domain":39,"gen":1,"mods":["MapDeepwaterChartMonsterCannotBeStunned"],"name":"Unwavering","stats":[{"ref":"Monsters cannot be Stunned","trade":"explicit.stat_1041951480"},{"max":20,"min":10,"ref":"#% more Monster Life"}],"tiers":1} +{"domain":5,"gen":5,"mods":["MapCorruptionItemQuantity"],"stats":[{"max":20,"min":10,"ref":"#% Item Quantity","trade":"implicit.stat_2023217031"}],"tiers":1} diff --git a/tests/data/bundle/en-stats-matcher.index.bin b/tests/data/bundle/en-stats-matcher.index.bin index f891de9..b93ea5b 100644 Binary files a/tests/data/bundle/en-stats-matcher.index.bin and b/tests/data/bundle/en-stats-matcher.index.bin differ diff --git a/tests/data/bundle/en-stats-ref.index.bin b/tests/data/bundle/en-stats-ref.index.bin index b7fdf21..1eb1020 100644 Binary files a/tests/data/bundle/en-stats-ref.index.bin and b/tests/data/bundle/en-stats-ref.index.bin differ 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); +}