From bef56fb2f140b9fbd5cf331cc4bc2200f78bd819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Thu, 13 Aug 2026 13:19:48 +0200 Subject: [PATCH] A heist area is a pool like a map's, so map check reads one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: Domain 22 `HEIST_AREA` to `mapcheck::kDomains`, so the map-check hotkey opens on a contract or a blueprint and rates its affixes against the profile in use. - The claim that everything above `kDomains` was written per domain rather than per map is now tested rather than asserted: the store, the profiles, the popup's rows, the settings pool browser and the search-string import took no change at all. What it cost was one entry in that list and one `is_heist()` line in the fallback that reads the item when the bundle cannot. - Every heist affix also grants `#% more raising of Alert Level` and `#% increased time before Lockdown`, which no contract prints — the client sums them into two properties instead. So the `pool_refs_for` expansion is the ordinary case here rather than the exception: 88 of the pool's 90 entries need it. Pinned to a capture whose six affixes carry -7/-6/-6/-5/-6/-4 and which prints `Alert Level Reduction: +34%`. ADDED: The job requirements to the popup's numbers block, folded into one pair — `Requires: Brute Force 5, Demolition 5, …`, with any `(unmet)` the client annotated kept. - They were drawn by nothing before: the parser keeps that line whole and label-less, and the block skips a property with no label. Six of them written out as the game writes them are the longest thing on the panel, and a fully revealed blueprint has six. CHANGED: `is_map_device_item` is `is_rateable_item`, and asks whether the item rolls from a domain in `kDomains`. A contract goes to Adiyah rather than into the map device, so the old name had become a wrong statement about its own gate. CHANGED: A property value is wrapped at the panel edge instead of clipped. The block was written for pairs that were always a number and always fitted; the folded job list is wider than the panel. CHANGED: The outlook banner names nothing — "You can run this safely" rather than "this map", since the same sentence now sits over a contract and a chart. ADDED: A contract capture with Advanced Mod Descriptions on, four pool entries and two stats to the bundle slice, and tests for the gate, the expansion, and a wording a map's pool and a contract's both grant folding into one row. CHANGED: map-check.md gains the heist pool's numbers and says why 58 of its 90 entries word a modifier as a map does and still do not fold; roadmap.md closes Crucible maps (legacy content, one line of work, no items left) and delve areas (no base item behind the domain) so neither is proposed again. CHANGED: map-check.md said domain 14 `MAP_DEVICE` holds invitation implicits. It holds the fixed implicit each map *fragment* carries — 274 rows, all generation 3, not one of them an invitation, which are domain 5 as the table below it already said. Co-Authored-By: Claude Opus 5 (1M context) --- docs/data-layer.md | 7 +- docs/map-check.md | 93 ++++++++++++++++-- docs/roadmap.md | 15 +++ docs/testing.md | 24 +++-- scripts/slice-test-bundle.py | 17 ++++ src/app.cpp | 6 +- src/mapcheck/rate.cpp | 3 +- src/mapcheck/rate.hpp | 25 +++-- src/screens/mapcheck_screen.cpp | 69 +++++++++++-- src/ui/strings.cpp | 14 +-- src/ui/strings.hpp | 2 + tests/data/bundle/en-mod-pools-ref.index.bin | Bin 48 -> 112 bytes tests/data/bundle/en-mod-pools.ndjson | 4 + tests/data/bundle/en-stats-matcher.index.bin | Bin 816 -> 840 bytes tests/data/bundle/en-stats-ref.index.bin | Bin 640 -> 656 bytes tests/data/bundle/en-stats.ndjson | 2 + .../heist-contract-rare-mansion-advanced.txt | 34 +++++++ tests/game_data_test.cpp | 6 +- tests/mapcheck_test.cpp | 84 ++++++++++++++-- 19 files changed, 353 insertions(+), 52 deletions(-) create mode 100644 tests/data/items/heist-contract-rare-mansion-advanced.txt diff --git a/docs/data-layer.md b/docs/data-layer.md index f6178fa..db58244 100644 --- a/docs/data-layer.md +++ b/docs/data-layer.md @@ -55,9 +55,10 @@ downloaded at runtime from **[JIRPOS/PathOfPriceCheck-Data](https://github.com/J 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. + `"{domain}::{wording}"`. **The domain is part of the key**, because a map, a chart and a heist + area are separate pools — a map and a chart word 42 modifiers identically, and 58 of the heist + pool's 90 entries word their printed stat as a map's does — 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 diff --git a/docs/map-check.md b/docs/map-check.md index 1a44472..fc8e0b5 100644 --- a/docs/map-check.md +++ b/docs/map-check.md @@ -19,16 +19,33 @@ the spot. Nothing about the feature requires knowing what a map *could* have rol 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 +The **one gate** is `is_rateable_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 +rated into a map profile with nothing to stop them. It asks one thing: does this item roll from a +domain in `kDomains`? So a heist contract passes and a Crucible remnant does not, both of them for +the same reason and neither of them by name. + +**It passes an item whose pool the bundle does not carry**, deliberately: the gate is about the +item and the pool is a convenience, so an affix that resolves to a stat is rateable either way. +The cost is that `pool_refs_for` cannot expand — a verdict lands on the printed wording alone +where the pool would have keyed it on the affix's whole set — and those two keys are not each +other. Ratings made on a bundle predating a domain's pool are therefore not found again once that +pool arrives. It shows as an unrated row, never as a wrong verdict, and it is the price of not +having the hotkey do nothing. + +The popup, top to bottom: the **outlook** (below), the name plate and the item'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. +The numbers block is a **wrapping run of label-value pairs**, and a heist item is what it was +worth compacting for: a fully revealed blueprint prints fifteen property lines, six of which are +`Requires (Level N)` sentences. Those fold into one pair — `Requires: Brute Force 5, +Demolition 5, …`, keeping any `(unmet)` the client annotated, both wordings out of the lexicon — +and the whole block comes to five lines. The banner's sentence names nothing for the same reason: +"You can run this safely" is true of a map, a chart and a contract alike. + **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. @@ -120,6 +137,22 @@ they are still rollable and still carry modifiers a player wants a verdict on. * are out**: there is no copy of a side area to read, and reading one off the screen is not something this project does. +**And it means a heist contract or blueprint too.** A contract goes to Adiyah rather than into the +map device, so it is outside that sentence on the letter of it and inside on every other count: it +is an area you decide to open, it rolls prefixes and suffixes from a pool of its own, and the +decision a player makes about `Players have 60% reduced effect of Non-Curse Auras from Skills` is +the same decision they make about it on a map. The pool is `HEIST_AREA`, domain 22, and it costs +one entry in `kDomains` — which is the whole reason that list exists. What it does *not* cost is a +second feature: the store, the profiles, the popup, the settings page and the import were written +per domain, and none of them learned anything about heists. + +**Crucible maps — `Primeval Remnant` and `Primordial Remnant`, domain 33 `CRUCIBLE_MAP` — are +out**, and the reason is not the code. Their pool is real (49 prefixes, 51 suffixes) and the two +bases are `Misc Map Items`, the same class as the 26 that pass the gate on domain 5, so this is the +one place the gate splits an item class. But the league is over, the items cannot drop, and what is +left is a handful on Standard: not worth an entry. Note the shape if it is ever reopened — it is +one line in `kDomains` and one in the data repo's `POOL_GENERATIONS`. + Every layer built so far starts from the item in hand, so nothing in the bundle describes a pool except the per-unique dataset, which is built from poewiki rather than from the client. Map check is the first feature where the client's own data expresses one directly. @@ -151,9 +184,17 @@ Leaving roughly **155 rateable wordings**, which is small enough that the settin with a search box rather than a data-management problem. **The other map-adjacent domains.** Charts are **39** (49 prefixes, 32 suffixes), a domain -dat-schema does not name. **12** `LEAGUESTONE` holds legacy map mods that still collide with -domain-5 wordings. **14** `MAP_DEVICE` holds invitation implicits. **11** `ATLAS` holds -atlas-side mods. +dat-schema does not name. Heist areas are **22** `HEIST_AREA` — the section below. **12** +`LEAGUESTONE` holds legacy map mods that still collide with domain-5 wordings. **14** `MAP_DEVICE` +holds the fixed implicit each map fragment carries (`VaalFragmentImplicit1`, +`ShaperFragmentImplicit`; all 274 rows are generation 3 and not one of them names an invitation — +invitations are domain 5, as the table below says). **11** `ATLAS` holds atlas-side mods. + +**Only three domain names contain the word AREA**, which is the question worth asking once: **5** +`AREA`, **17** `DELVE_AREA` and **22** `HEIST_AREA`. Delve is closed rather than deferred — its +155 rows are all generation type 14 and **no base item in the game carries the domain**, because a +delve biome is an area nobody holds a copy of. That is the Vaal side area argument again, and it +is why heist is the only reachable area pool outside the map device's own. **Domain 5 is the map device.** Every kind of thing that opens in it shares the domain and carries a tag on its base saying which kind it is. The tags are recorded here because they are what proves @@ -210,6 +251,32 @@ discriminator is generation type 3, except that 276 of the mod rows the unique d are ordinary generation 1/2 anyway. The unique→mod link is a relation, not a property of the mod, which is what [UNIQUE-MODS.md](../UNIQUE-MODS.md) exists for. +**Domain 22, the heist pool.** 146 prefixes + 153 suffixes = 299 rows, **90 wording-sets**, and +nothing else: no implicits, no legacy generation types, nothing the hygiene rules drop. It is +behind 18 bases — 9 contracts and 9 blueprints, which share the one pool. The quest contracts +(`Vigilante Contract` and its kin) are domain **43** with the stackable currency and roll nothing, +which is why the `Contracts` item class publishes no domain of its own: its bases disagree, so a +contract is placed by its base and by `is_heist()` if the base did not resolve. + +**Every heist affix grants two stats no contract prints**, and this is the thing to know about the +pool. Alongside its own wording each one carries `#% more raising of Alert Level` and `#% increased +time before Lockdown`, which the client does not print as lines — it sums them into the properties +`Alert Level Reduction` and `Time Before Lockdown`. Measured on a real capture: six affixes +carrying -7, -6, -6, -5, -6 and -4, and the item printing `Alert Level Reduction: +34%`. So the +`pool_refs_for` expansion is not an optimisation here but the ordinary case — 88 of the 90 entries +need it — and it is the same mechanism the Nightmare map's unprinted `#% more Currency found in +Area` already needed. The two entries that are *only* the alert pair are affixes that print no line +at all: rateable in Settings, never on a popup, and harmless. + +**The wordings mostly do not collide with a map's, and that is data rather than design.** 58 of +the 90 entries word their printed stat exactly as a domain-5 entry does — heist `Chaining` and map +`Chaining` are the same sentence — but the alert pair makes the *sets* different, so `pool_groups` +folds only **one** group across the two domains (`Area has patches of Burning Ground`, where a +legacy heist row grants nothing else). Rating a map's `Chaining` therefore does not rate a +contract's. That is arguable either way and was left as the data has it: the two affixes genuinely +differ, and the alternative is dropping stats from a set to make keys collide, which is a lie about +what the affix is. If it is ever revisited, revisit it in `pool_groups`, not in the bundle. + ## The rule this whole design hangs on **The pool describes; it never gates.** It says what can spawn *naturally* on this kind of thing, @@ -404,7 +471,9 @@ Three entry points, and [data-layer.md](data-layer.md) owns them now: **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. +511 abyss jewel mods or 552 idol mods the day one is published. Heist was the first test of that +claim and it held: adding domain 22 was one entry in `kDomains`, one `is_heist()` line in the +fallback, and no change to this page at all. 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 @@ -428,10 +497,14 @@ file unreadable. 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**, +list was showing 39 decisions as 82 rows. `pool_groups` collapses them, **360 entries to 315 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. +naming a number hits if either pool's range would print it. Of the 40 groups holding more than one +entry, **35 are a map and a chart, 4 are two entries of one pool** (domain 5 words `Area has +patches of Burning Ground` as both `of Flames` and `of Fire`) **and 1 is a map and a contract** — +the heist pool barely folds in, for the data reason given above, and the mechanism did not have to +change to say so. 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, diff --git a/docs/roadmap.md b/docs/roadmap.md index aaa261b..f1c3133 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -203,3 +203,18 @@ reason is here so it is not proposed a second time. 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. + +- **Rating Crucible maps in map check** — the two `Misc Map Items` bases `Primeval Remnant` and + `Primordial Remnant`, domain 33 `CRUCIBLE_MAP`. Nothing technical is in the way: the pool is + real (49 prefixes, 51 suffixes), and the work is one entry in `mapcheck::kDomains` and one in + the data repo's `POOL_GENERATIONS`, exactly as heist was. It is closed because the league is + over and the items cannot drop — what is left is a handful on Standard, and the gate splitting + one item class in two is a smaller oddity than a pool nobody can obtain. **Reopen it only if + the content returns**, and note that it is the one place the domain gate disagrees with an item + class it otherwise accepts. → [map-check.md](map-check.md) + +- **Rating delve areas, and any other domain whose name says AREA.** There are exactly three — + 5 `AREA`, 17 `DELVE_AREA`, 22 `HEIST_AREA` — and 17 has no base item in the game behind it. A + delve biome's modifiers exist and are generated, but nobody holds a copy of one to copy, which + is the Vaal side area argument and closes it the same way. Nothing is deferred here: with heist + built, every area pool a player can put on the clipboard is read. diff --git a/docs/testing.md b/docs/testing.md index d78bf87..402b6d9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -111,14 +111,22 @@ 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. +The **nine mod-pool entries** are chosen for the reader rather than for any item, and cover all +three domains. Five are the original set: 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. + +The other four are the heist pool and are two wordings between them. `MapBurningGround` and +`HeistContractBurningGround` say the same thing and grant nothing else, so they fold into one row +across *different pools* — which is the whole of what a third domain had to be shown not to break. +`HeistContractBurningGround1` is that wording plus the two alert-level stats no contract prints, so +the fold leaves it alone, and `HeistContractMonsterPatrolAdditionalElite1` is that shape with +nothing to share: one printed wording, two unprinted, and the entry a printed line has to be +expanded to reach. Four of the nine entries' 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 diff --git a/scripts/slice-test-bundle.py b/scripts/slice-test-bundle.py index 3f1463e..cfbbb52 100755 --- a/scripts/slice-test-bundle.py +++ b/scripts/slice-test-bundle.py @@ -132,6 +132,12 @@ # Life" above is a hazard too — the same stat an ultimatum stakes on. "Heist Targets are always Enchanted Armaments", "Players are Cursed with Temporal Chains", + # The one wording a map's pool and a contract's both grant, which is what the heist half of + # the mod pool below is sliced for. It prints no number on either. + "Area has patches of Burning Ground", + # And a contract affix nothing else words: the pool entry behind it grants two more stats + # that no contract prints, so this is the wording an expansion has to grow from. + "Patrol Packs have #% increased chance to be replaced by an Elite Patrol Pack", # A sanctum's two shapes of modifier, both of them only searchable in the `sanctum` # namespace — which is what the parser has to type them as, and the reason they are here at # all. The first pair is the ordinary affix; the boons and afflictions after it are stats @@ -298,12 +304,23 @@ # 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. +# +# The last four are the heist pool. `MapBurningGround` and `HeistContractBurningGround` word one +# wording identically and grant nothing else, so they fold into one row the way a chart's twin +# does — the whole of what a third domain had to be shown not to break. `HeistContractBurningGround1` +# is the same wording plus the two alert-level stats no contract prints, which is why the fold +# leaves it alone. `HeistContractMonsterPatrolAdditionalElite1` is that shape with nothing to share: +# one printed wording, two unprinted, and the entry an expansion from the printed one has to reach. MOD_POOLS = [ "MapTotems", "MapMonstersHinderOnHitMapWorlds", "MapDeepwaterChartMonstersHinderOnHit", "MapDeepwaterChartMonsterCannotBeStunned", "MapCorruptionItemQuantity", + "MapBurningGround", + "HeistContractBurningGround", + "HeistContractBurningGround1", + "HeistContractMonsterPatrolAdditionalElite1", ] ITEM_CLASSES = ["Rings", "Boots", "Gloves", "Body Armours", "Stackable Currency", diff --git a/src/app.cpp b/src/app.cpp index 5499b64..0078b6e 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -667,8 +667,8 @@ void App::poll_pending_copy() { // 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", + !mapcheck::is_rateable_item(*item_, item_data_.get())) { + debug::log("[map] dropped: '%s' rolls from no pool map check reads", item_->item_class.c_str()); abandon_copy(); return; @@ -1225,7 +1225,7 @@ void App::handle_action(Action a) { if (!game_focused) { // dev mode: no game to copy from, just show what's already there accept_clipboard(read_clipboard("clipboard.dev")); if (item_ && (copy_target_ != Screen::MapCheck || - mapcheck::is_map_device_item(*item_, item_data_.get()))) + mapcheck::is_rateable_item(*item_, item_data_.get()))) set_screen(copy_target_); return; } diff --git a/src/mapcheck/rate.cpp b/src/mapcheck/rate.cpp index 96d92fd..f09edff 100644 --- a/src/mapcheck/rate.cpp +++ b/src/mapcheck/rate.cpp @@ -43,11 +43,12 @@ int map_domain_of(const item::Item& it, const data::GameData* gd) { // 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_heist()) return kHeistDomain; 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) { +bool is_rateable_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); } diff --git a/src/mapcheck/rate.hpp b/src/mapcheck/rate.hpp index 8d81db5..7f039ca 100644 --- a/src/mapcheck/rate.hpp +++ b/src/mapcheck/rate.hpp @@ -10,13 +10,18 @@ namespace ppc::mapcheck { -/// The two pools map check reads: the map device's, and the charts'. +/// The three pools map check reads: the map device's, the charts', and the heist areas'. /// /// 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. +/// domain — the settings page, the lookups, the store. A fourth pool costs an entry here. +/// +/// **Order is precedence**, and the map comes first: where two pools word one affix the same, the +/// row is drawn with the map's wording and its affix name. Heist is last for no reason but that +/// it arrived last — it shares nothing with the chart pool that the map pool does not also share. inline constexpr int kMapDomain = 5; inline constexpr int kChartDomain = 39; -inline constexpr int kDomains[]{kMapDomain, kChartDomain}; +inline constexpr int kHeistDomain = 22; +inline constexpr int kDomains[]{kMapDomain, kChartDomain, kHeistDomain}; /// Which pool this item rolls from, or 0 when nothing says. /// @@ -26,12 +31,20 @@ inline constexpr int kDomains[]{kMapDomain, kChartDomain}; /// 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. +/// True for an item that rolls from one of `kDomains`: everything that opens in the map device — +/// ordinary, nightmare and Originator maps, unique maps, expedition logbooks and invitations — +/// plus charts, and heist contracts and blueprints. /// /// 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); +/// +/// **A bundle with no pool for the domain still passes.** The gate is about the item, and the +/// pool is a convenience: an affix that resolves to a stat can be rated whether or not anything +/// describes the pool it came from. What such a bundle costs is the expansion in +/// `pool_refs_for` — a verdict set against a printed line alone, which the pool would have keyed +/// on the affix's whole wording set. Those two keys are not each other, so ratings made on a +/// bundle predating a domain's pool are not found again once it arrives. +bool is_rateable_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. diff --git a/src/screens/mapcheck_screen.cpp b/src/screens/mapcheck_screen.cpp index cf9744a..4e6ed54 100644 --- a/src/screens/mapcheck_screen.cpp +++ b/src/screens/mapcheck_screen.cpp @@ -1,6 +1,7 @@ #include "screens/mapcheck_screen.hpp" #include +#include #include #include @@ -152,22 +153,71 @@ void draw_plate(App& app, const item::Item& it) { ImGui::PopFont(); } +/// A lexicon term as a label: the game writes "Requires " with the space that joins it to what +/// follows, and a label is followed by its own colon instead. +std::string as_label(std::string_view term) { + while (!term.empty() && term.back() == ' ') term.remove_suffix(1); + return std::string(term); +} + +/// The job levels a heist item demands, as one pair: `Requires: Deception 5, Engineering 4`. +/// +/// The game writes one sentence per job and a fully revealed blueprint asks for several — six on +/// the Tunnels capture — so drawn as the game writes them they are the longest thing on the panel +/// and most of it is the word "Requires". Folded here into the label the rest of the block has, +/// keeping whatever the client annotated the level with — "(unmet)" is the reason to read the line +/// at all. Both wordings come out of the lexicon, so a translated client folds the same way. +std::string heist_jobs_value(const item::Item& it, const data::Lexicon& lex) { + const std::string_view prefix = lex.term(data::Term::HeistJobPrefix); + const std::string_view level = lex.term(data::Term::HeistJobLevel); + std::string out; + for (const item::Property& p : it.properties) { + if (p.key != data::PropertyKey::HeistJob) continue; + std::string_view line = p.value; + if (line.starts_with(prefix)) line.remove_prefix(prefix.size()); + if (!out.empty()) out += ", "; + // "Deception (Level 5 (unmet))" -> "Deception 5 (unmet)". A line the terms do not fit + // is kept whole rather than cut at a guess: it is still the job and the level. + const size_t at = line.find(level); + if (at == std::string_view::npos || line.back() != ')') { + out += line; + continue; + } + out += line.substr(0, at); + out += ' '; + out += line.substr(at + level.size(), line.size() - at - level.size() - 1); + } + return out; +} + /// 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) { +void draw_properties(const item::Item& it, const data::Lexicon& lex) { const float avail = ImGui::GetContentRegionAvail().x; const float gap = ImGui::GetStyle().ItemSpacing.x * 2.0f; + const std::string jobs = heist_jobs_value(it, lex); float x = 0.0f; bool first = true; + bool jobs_drawn = false; 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 + ": "; + std::string value = p.value; + std::string label = p.label; + if (p.key == data::PropertyKey::HeistJob) { + // All of them at once, where the first one was printed, so the block still reads in + // the order the game wrote it. + if (jobs_drawn || jobs.empty()) continue; + jobs_drawn = true; + label = as_label(lex.term(data::Term::HeistJobPrefix)); + value = jobs; + } + if (label.empty()) continue; // prose the game prints among the properties + label += ": "; const float w = - ImGui::CalcTextSize(label.c_str()).x + ImGui::CalcTextSize(p.value.c_str()).x; + ImGui::CalcTextSize(label.c_str()).x + ImGui::CalcTextSize(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. @@ -183,7 +233,14 @@ void draw_properties(const item::Item& it) { ImGui::PopStyleColor(); ImGui::SameLine(0.0f, 0.0f); ImGui::PushStyleColor(ImGuiCol_Text, p.augmented ? kColAugmented : kColValue); - ImGui::TextUnformatted(p.value.c_str()); + // Wrapped, not clipped. Every pair the block was first written for was a number and + // fitted; the folded job list on a fully revealed blueprint is longer than the panel is + // wide, and without a wrap position ImGui cuts it off at the edge instead. `x` is left + // holding the unwrapped width on purpose — it is then wider than the line can be, so the + // next pair starts a line of its own rather than being placed against a stale cursor. + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(value.c_str()); + ImGui::PopTextWrapPos(); ImGui::PopStyleColor(); } } @@ -395,7 +452,7 @@ void draw_mapcheck_screen(App& app) { draw_plate(app, *it); if (!it->properties.empty() || it->item_level) { draw_rule(); - draw_properties(*it); + draw_properties(*it, app.item_lexicon()); } ImGui::PopFont(); diff --git a/src/ui/strings.cpp b/src/ui/strings.cpp index 55f80bf..3e793f8 100644 --- a/src/ui/strings.cpp +++ b/src/ui/strings.cpp @@ -151,13 +151,13 @@ constexpr const char* kEnglish[]{ "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.", + "This has nothing rolled to rate.", + "You can probably run this but it has too many unrated modifiers.", + "You can run this safely.", + "You can run this safely but check the unrated modifiers.", + "You should be able to run this.", + "You should be able to run this but be careful.", + "You will most probably die in here.", "Bundle", "Downloading %.1f / %.1f MB", diff --git a/src/ui/strings.hpp b/src/ui/strings.hpp index e6d84ae..b258f76 100644 --- a/src/ui/strings.hpp +++ b/src/ui/strings.hpp @@ -145,6 +145,8 @@ enum class Msg : uint16_t { MapCheckTitle, MapRateHint, MapUnresolved, + /// The banner's sentence. **It never names the thing**: a heist contract and a chart are rated + /// by the same popup as a map, so "run this" is the word that is true of all of them. MapOutlookNoMods, MapOutlookUnrated, MapOutlookSafe, diff --git a/tests/data/bundle/en-mod-pools-ref.index.bin b/tests/data/bundle/en-mod-pools-ref.index.bin index ee38585b3ff0c5ffd874c1bba084c873dff363b0..40ee4fbbc7237a580b4d9a2e7b5cd067f6ffbcd0 100644 GIT binary patch delta 94 zcmXpg(Acolo0El|fdNEkaxgHgt^TJ8q9?uVZ32mdXb^vbgh956*1>i*1_lrflG@gG g@L)9y14C|k!?Vv!3=E1IY%dsqfM@^AZ+t*L05^jhhyVZp literal 48 zcmZ1)J)`Xo0|UbiRaOaz1w1BFPcpZQ{(EiWni$k-k=ZU)!AuMZg(Tu6mYjPZ;oRzaR14GA~s)j~G28QqnoXvk+85s2Sy4o#u z7#Lou%6D74GcahYnfDzCU|`Vw-7wMGgn{8>)y|1#zLU2xnmL);Gccs?UbR#}g@J*` z`PXXhU!pqs_pe^yt@)$KF77dI7t1tQZ(R>zD4GC&j?f#@?{+ zY2ai%Cb1}xtv3xzj_osGV3;!D`LT9Y28M03-yD+#`r+QOH77vEGQ||1RMKT&(9}vg zHBW|tp{lFn)O81-pF)nFx+TuQz}(+_1{4BH5q{?cjTso`CG?#$@|--6NlXf4d)r)% z>!5)1RPVkqQ;C6L$!*6w2ka-GW0Lc^3=D(Rr1!7w{TLX2pHcX9-;{x&-_reaI5Pvo zZow5_o+vUfST5B34hsL*?QB1HYBDe^w6Xa69T->sZ;Ss{dNDA(`=t3Vjg5f;0LzY_ AApigX delta 420 zcmX@Xwt;Pe4AVB3iE?I4uPi6VsWGuwPwcbPx?sk@u=rfA?R`@Q29aeaZL@?J7!JJL zVmDQtfuVHH3#Stf3=9%lQ(WiUPyD7PrC`Ir5b-cEMpu-9!FO|hjF}SygWo>xaKZ7vK9+G^%~ z)_x2Oy1yGHTAMI1e5~3zaiiDdV~l1_8-SLi?q0Q2K!t&U$NAUlwg3hOr=E3dez`I* z6rH-fQAeABLFv)29dVvOb$S82mYXv$eAX}BJ5P#%p^d#^UxNQ+J0`IxkgYcjOOEX` zU|^Us;rX$4RR)G_v)>$>Va>pB@7S6XAY++gicc!(GB9XrC7qfl!@yA0)p07w4(O+l zqo;0(GcYjsH=hB8fKr6tIYDCvhIt8n=hnJSUdJRR1+u+uuEupxzOV diff --git a/tests/data/bundle/en-stats-ref.index.bin b/tests/data/bundle/en-stats-ref.index.bin index 1eb10204ddd9709e8961b51c1ead2e3288d89200..ad910b86f7df78116be8b5f5959cfd178f5a8a57 100644 GIT binary patch delta 349 zcmV-j0iynZ1&{@h90EH>ksdAzZ#)11y|!E((KeAz76OSqk!CI;RXG3v6sE~NY7zhd z!0EI;l_vlIb)4uzO+x?x7%G%SBtnt4CK9DS002$1Z(O@X002(D3u7!gk?uAcc}f5P z9L;%n{SyEHY*?*){R992LkDAhLpzf`0TvR#Pyhfe`+$))DW}NK%pL#$ zRB!#!<4XVlhMg$aWHNoI v(>nkFWn%B^KTZGu`^+Hn-!%XLk2y#4R|5b5yAi1LU!1%L&R90ImPksdA@>Nx-aWpolDxhVhu{v}l`*Bbx;R4{Wb1Ur##E&|Xt zk)9R`$Up!97%G%SpFfelCK4b#002$1Z(KG)002(D3uC7@k@hwM^hc8}0TwF0H~;{M z?0SHKF#rHBE{caaEC2v2CO3{dP5=Nc`+$)`tjLB?00079bIBqt004T5h{{qv002%@#md?j001IbPR$*z-0FOCG^H&1^0J{;W^yDD`0HHiM{c<`006ipfRR910 diff --git a/tests/data/bundle/en-stats.ndjson b/tests/data/bundle/en-stats.ndjson index f89f1ff..ed48f98 100644 --- a/tests/data/bundle/en-stats.ndjson +++ b/tests/data/bundle/en-stats.ndjson @@ -51,6 +51,8 @@ {"better":1,"matchers":[{"string":"#% more Monster Life"},{"negate":true,"string":"#% less Monster Life"}],"ref":"#% more Monster Life","trade":{"ids":{"explicit":["explicit.stat_95249895","explicit.stat_2710898947"],"fractured":["fractured.stat_95249895"],"implicit":["implicit.stat_95249895"]}}} {"better":1,"matchers":[{"string":"Heist Targets are always Enchanted Armaments"}],"ref":"Heist Targets are always Enchanted Armaments","trade":{"ids":{"enchant":["enchant.stat_3709545805"]}}} {"better":1,"matchers":[{"string":"Players are Cursed with Temporal Chains"}],"ref":"Players are Cursed with Temporal Chains","trade":{"ids":{"explicit":["explicit.stat_2326202293"],"fractured":["fractured.stat_2326202293"]}}} +{"better":1,"matchers":[{"string":"Area has patches of Burning Ground"}],"ref":"Area has patches of Burning Ground","trade":{"ids":{"explicit":["explicit.stat_133340941","explicit.stat_3665534869"],"fractured":["fractured.stat_133340941"]}}} +{"better":1,"matchers":[{"string":"Patrol Packs have #% increased chance to be replaced by an Elite Patrol Pack"},{"negate":true,"string":"Patrol Packs have #% reduced chance to be replaced by an Elite Patrol Pack"}],"ref":"Patrol Packs have #% increased chance to be replaced by an Elite Patrol Pack","trade":{"ids":{"explicit":["explicit.stat_1982436039"],"fractured":["fractured.stat_1982436039"]}}} {"better":1,"matchers":[{"string":"The Merchant has an additional Choice","value":1.0},{"string":"The Merchant has # additional Choices"}],"ref":"The Merchant has # additional Choices","trade":{"ids":{"sanctum":["sanctum.stat_290775436"]}}} {"better":1,"matchers":[{"string":"#% increased Merchant Prices"},{"negate":true,"string":"#% reduced Merchant Prices"}],"ref":"#% increased Merchant Prices","trade":{"ids":{"sanctum":["sanctum.stat_3096446459"]}}} {"better":1,"matchers":[{"string":"An additional Room is revealed on the Sanctum Map","value":1.0},{"string":"# additional Rooms are revealed on the Sanctum Map"}],"ref":"# additional Rooms are revealed on the Sanctum Map","trade":{"ids":{"sanctum":["sanctum.stat_386901949"]}}} diff --git a/tests/data/items/heist-contract-rare-mansion-advanced.txt b/tests/data/items/heist-contract-rare-mansion-advanced.txt new file mode 100644 index 0000000..d7f5225 --- /dev/null +++ b/tests/data/items/heist-contract-rare-mansion-advanced.txt @@ -0,0 +1,34 @@ +Item Class: Contracts +Rarity: Rare +Rage Scripture +Contract: Mansion +-------- +Client: Kondor Larcius +Heist Target: The Goddess of Water (High Value) +Area Level: 83 +Requires Deception (Level 5 (unmet)) +Item Quantity: +90% (augmented) +Item Rarity: +52% (augmented) +Alert Level Reduction: +34% (augmented) +Time Before Lockdown: +34% (augmented) +Maximum Alive Reinforcements: +34% (augmented) +-------- +Item Level: 83 +-------- +{ Prefix Modifier "Elite" (Tier: 1) } +Patrol Packs have 29(26-30)% increased chance to be replaced by an Elite Patrol Pack +{ Prefix Modifier "Secure" (Tier: 1) — Damage } +Patrolling Monsters deal 29(27-30)% increased Damage +{ Prefix Modifier "Indifferent" (Tier: 1) — Damage } +Guards deal 29(27-30)% increased Damage +{ Suffix Modifier "of Doubt" (Tier: 1) } +Players have 60% reduced effect of Non-Curse Auras from Skills +{ Suffix Modifier "of Wavering Will" (Tier: 1) — Defences, Energy Shield } +Players have 16% less Energy Shield Recovery Rate per 25% Alert Level +{ Suffix Modifier "of Flames" (Tier: 1) — Damage, Elemental, Fire } +Area has patches of Burning Ground +-------- +"The relief depicts some sort of forgotten Maraketh goddess. I have no idea what +the context is. The important thing is that you steal it before Bazira does." +-------- +Give this Contract to Adiyah in the Rogue Harbour to embark on the Heist. diff --git a/tests/game_data_test.cpp b/tests/game_data_test.cpp index b2d1fa6..d2e38be 100644 --- a/tests/game_data_test.cpp +++ b/tests/game_data_test.cpp @@ -220,13 +220,17 @@ 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); + REQUIRE(maps.size() == 4); // 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"); + // Three pools, one file: the domain is what separates them, and asking for one of them + // never brings back another's entries even where the wording is the same string. + CHECK(gd->mod_pool(39).size() == 2); + CHECK(gd->mod_pool(22).size() == 3); // 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()); diff --git a/tests/mapcheck_test.cpp b/tests/mapcheck_test.cpp index 607a937..e859825 100644 --- a/tests/mapcheck_test.cpp +++ b/tests/mapcheck_test.cpp @@ -23,8 +23,9 @@ 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. +/// The committed bundle slice, which carries nine pool entries across all three domains — among +/// them the two-wording `Unwavering`, which is the whole reason the affix key is a set, and the +/// heist `Elite`, whose two unprinted wordings are why a printed line has to be expanded. std::shared_ptr fixture() { std::string err; auto gd = ppc::data::GameData::open(fs::path(PPC_TEST_DATA_DIR) / "bundle", "en", &err); @@ -458,7 +459,7 @@ TEST_CASE("what a map printed is keyed on the pool entry covering it") { 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())); + REQUIRE(is_rateable_item(map, gd.get())); const TempDir tmp("rate"); Store store; @@ -490,14 +491,83 @@ TEST_CASE("a map's rolled affixes are rated, its implicits are printed and left CHECK(assess(tally(rows)) == Outlook::Fatal); } +TEST_CASE("a heist contract is rated from the heist pool, not from the map's") { + const auto gd = fixture(); + const ppc::item::Item contract = resolved(*gd, "heist-contract-rare-mansion-advanced.txt"); + // "Contract: Mansion" is not a base in the slice, so this is also the fallback: the bundle + // could not say, the clipboard could, and a contract is a heist area either way. + REQUIRE(contract.base == nullptr); + CHECK(map_domain_of(contract, gd.get()) == kHeistDomain); + REQUIRE(is_rateable_item(contract, gd.get())); + + const TempDir tmp("heist"); + Store store; + store.open(tmp.path, {}, ""); + + // Advanced Mod Descriptions is on in this capture, so the six affixes are six rows. Only + // `Elite` and `of Flames` resolve against the slice; the rest are drawn and cannot be rated, + // which is what an unresolved wording is supposed to do rather than vanish. + const std::vector rows = rate(contract, store, gd.get()); + REQUIRE(rows.size() == 6); + CHECK(std::count_if(rows.begin(), rows.end(), [](const Row& r) { return r.rateable(); }) == 2); + + // The expansion, and the reason it exists here: the affix also raises alert level and delays + // lockdown, the contract prints neither — it folds them into `Alert Level Reduction: +34%` — + // and a verdict set in Settings is keyed on all three. + CHECK(rows[0].refs == + std::vector{ + "#% increased time before Lockdown", "#% more raising of Alert Level", + "Patrol Packs have #% increased chance to be replaced by an Elite Patrol Pack"}); + + const std::vector groups = pool_groups(*gd); + const auto elite = std::find_if(groups.begin(), groups.end(), [](const PoolGroup& g) { + return g.mod->domain == kHeistDomain && g.mod->name == "Elite"; + }); + REQUIRE(elite != groups.end()); + store.set(elite->refs, Verdict::Deadly); + CHECK(rate(contract, store, gd.get())[0].verdict == Verdict::Deadly); + CHECK(assess(tally(rate(contract, store, gd.get()))) == Outlook::Fatal); +} + +TEST_CASE("a heist item the bundle knows the base of is placed by the bundle") { + const auto gd = fixture(); + // "Contract: Tunnels" is in the slice and its record states domain 22, so nothing here + // depends on the item class at all. + const ppc::item::Item contract = resolved(*gd, "heist-contract-rare-tunnels.txt"); + REQUIRE(contract.base != nullptr); + CHECK(contract.base->mod_domain == kHeistDomain); + CHECK(map_domain_of(contract, gd.get()) == kHeistDomain); + CHECK(is_rateable_item(contract, gd.get())); + + const ppc::item::Item blueprint = resolved(*gd, "heist-blueprint-rare-tunnels-full.txt"); + CHECK(map_domain_of(blueprint, gd.get()) == kHeistDomain); + CHECK(is_rateable_item(blueprint, gd.get())); +} + 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); + // The slice holds nine entries in seven groups: `of Impedance` is in it twice — the map's and + // the chart's, identically worded — and `Area has patches of Burning Ground` three times, of + // which the map's and one contract's are the same set. The store keys on the ref set with no + // domain in it, so entries sharing one can never hold different verdicts and drawing them + // apart is drawing one decision twice. + CHECK(groups.size() == 7); + const auto flames = std::find_if(groups.begin(), groups.end(), [](const PoolGroup& g) { + return g.refs == std::vector{"Area has patches of Burning Ground"}; + }); + REQUIRE(flames != groups.end()); + // Across two *different* pools this time, and the map's still leads. + CHECK(flames->all.size() == 2); + CHECK(flames->mod->domain == kMapDomain); + CHECK(flames->all[1]->domain == kHeistDomain); + // The contract entry granting the same wording *plus* the alert-level pair is not folded in: + // it is a bigger affix, and one decision about it is not the same decision. + CHECK(std::count_if(groups.begin(), groups.end(), [](const PoolGroup& g) { + return g.mod->domain == kHeistDomain; + }) == 2); + const auto imp = std::find_if(groups.begin(), groups.end(), [](const PoolGroup& g) { return g.mod->name == "of Impedance"; });