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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions UNIQUE-MODS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ is built from, so the lookup is by the name the clipboard already gave us.
- **`unlisted[]`** — a pool stated in prose but not enumerated, e.g. the general Synthesis
implicit pool. Nothing to search; it exists so the app can say what it is leaving out instead
of implying the item has nothing more.
- **A pool of one modifier** is not a mistake. Some modifiers roll a **name** rather than a
number — The Dark Monarch doubles the limit of one of sixteen minion types, Replica
Dragonfang's Flight raises one of 287 skill gems, Forbidden Shako supports one of 164 support
gems in one of four slots. The source calls these fixed, and it is right that every copy has
the modifier; what varies is *which wording* it rendered as, which is the whole of what the
copy in hand is worth searching for. They arrive as a pool with `count: [1, 1]` whose `mods`
all share one `mod` id and differ by wording and `tradeId`. This is why a pool can be 656
entries long, and why a single filter would have been worse than none: it would have claimed
this copy rolled whichever option the game's own data happens to list first.
- **entry `mod`** — GGG's own mod id. Stable across patches, useful for debugging and for
deduplicating; not needed to build a query.
- **entry `implicit: true`** — the mod is an implicit, so its trade id is in the `implicit.`
Expand Down Expand Up @@ -112,18 +121,18 @@ a user. Never a wrong filter.

## Limitations, stated plainly

- **No `tradeId` on some filters.** 470 wordings resolve to two different trade ids (the known
ambiguous-wording problem) and 695 to none at all. Both are emitted with `ref` and `range` and
no id, so a pool list still matches the count its `hint` states. Display them; do not search
them.
- **393 mods are dropped entirely** because every stat they grant is unsearchable — cosmetic
- **No `tradeId` on some filters.** Of 11,327 filters, 793 carry no id: 434 wordings resolve to
two different trade ids (the known ambiguous-wording problem) and 359 to none at all. Both are
emitted with `ref` and `range` and no id, so a pool list still matches the count its `hint`
states. Display them; do not search them.
- **394 mods are dropped entirely** because every stat they grant is unsearchable — cosmetic
footprints, hidden behaviour. A pool list can therefore be shorter than the pool the game rolls.
- **Foulborn is not here.** It is Chayula currency, absent from both the client tables and the
wiki's pools. Identification already works off the clipboard's `{ Foulborn Unique Modifier }`
info line; only the roll's range stays unknown.
- **Forbidden Flame / Forbidden Flesh** get a record with no mods: their one mod grants a hidden
stat and trade searches them through an *option* stat this join does not reach. Needs the
option-stat work, same as pseudo mods.
- **Forbidden Flame / Forbidden Flesh** get a filter with a wording but no `tradeId`: trade
searches them through an option stat over ascendancy notables, which is not one of the two
name tables the expansion above reads. The same shape, a third table.
- **Counts can be missing or coarse.** Precursor's Emblem describes several sub-pools in one hint
and the source gives no way to tell which mod belongs to which, so it gets pool groups without
counts.
Expand Down
15 changes: 14 additions & 1 deletion docs/data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,22 @@ downloaded at runtime from **[JIRPOS/PathOfPriceCheck-Data](https://github.com/J
**`NORMALIZATION.md` in the data repo is normative** and this must reproduce it exactly — a
divergence does not crash, it silently mismatches a mod and returns a confident wrong price.
`normalize_test` replays the conformance vectors shipped with every data release.
A modifier can roll over a **list instead of an interval**, and the game prints that range the
same way — "Maximum number of Sentinels of Purity (Animated Weapons-Holy Armaments) is
Doubled" rolls over the minion skill gems. `scan_numbers` cannot see it: there is no number in
front of the parenthesis to carry the bounds. `strip_named_ranges` drops it, and the roll is
the name left in the wording, which is what trade indexes. It only ever **adds** candidates —
a wording whose parenthesis is genuinely part of it, `Unique Monsters (Blood-Filled Vessel)`,
is enumerated as printed first and never reaches the stripped form.
- **`data/stat_matcher`** joins clipboard lines into one modifier and resolves it to a stat and a
roll. Mod type is the primary disambiguator: explicit/implicit/fractured/crafted/enchant variants
share a wording and differ only by trade namespace. Two separate negation concepts —
share a wording and differ only by trade namespace.
`kMaxModLines` is **8**, not the two a hybrid needs: a modifier that enumerates its alternatives
is as long as its list — Bound Fate's "Every # seconds, gain one of the following" is seven
lines and the class-connection jewel is eight, which is the longest wording in the data. Below
the true maximum the join can never be built and *every line* of such a modifier is reported
unrecognised on its own. Reaching further is safe because a shorter join always wins: the loop
returns at the first join that resolves, so the cap only bounds a failed match. Two separate negation concepts —
`matcher.negate` (the *wording* is inverse; store the roll canonically) and `trade.inverted` (the
*trade site* indexes the opposite sign; applied at query-build time, not here).
An inverse wording's Advanced Mod Descriptions range is **printed high to low** —
Expand Down
51 changes: 48 additions & 3 deletions scripts/slice-test-bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,16 @@
"Area is influenced by The Shaper",
# A two-line implicit, which is one stat and therefore one filter — the map's affixes are
# printed in exactly the same shape and must still come out as none.
# The game hardcodes the 20 that trade placeholds, and the record's ref is the game's
# 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 #% of its value",
"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.
"Monsters have #% chance to Hinder on Hit with Spells",
# 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"
"Map's Item Quantity Modifiers also affect Blight Chest count at #% value\n"
"Map's Item Quantity Modifiers also affect Blight Chest count at 50% value\n"
"Can be Anointed up to # times",
"Natural inhabitants of this area have been removed",
# An Expedition Logbook's destinations. The faction and the area are `pseudo.*` stats and
Expand Down Expand Up @@ -143,6 +145,37 @@
# "Has Red Smoke" — the second affliction of the third capture — is deliberately **not**
# here, for the reason Porcupine Goliath and Dialla's Subjugation are not: an effect the
# bundle cannot name has to be left out of the search and said out loud.
# The Dark Monarch's four modifiers. The last of them is the only shape in the game whose
# roll is a *name*: one stat per minion skill gem, of which the item printed the range as
# "(Animated Weapons-Holy Armaments)" and the roll as the gem in the wording itself.
"# to maximum Energy Shield",
"# to maximum Energy Shield (Local)",
"# to Level of all Minion Skill Gems",
"#% increased Light Radius",
"Maximum number of Sentinels of Purity is Doubled\n"
"Cannot have Minions other than Sentinels of Purity",
# Replica Dragonfang's Flight, the other shape of a named range: the pool is every skill
# gem, so the wording carries the roll *and* a number, and the parenthesis follows a word
# with no space in front of it.
"#% increased Dexterity",
"#% increased Intelligence",
"# to Level of all Storm Burst Gems",
"#% to all Elemental Resistances",
"#% increased Reservation Efficiency of Skills",
"Items and Gems have #% increased Attribute Requirements",
"# to all Attributes",
# Bound Fate. A modifier that enumerates its alternatives is as long as the list — seven
# lines here — which is what the join has to be able to reach.
"Every 5 seconds, gain one of the following for 5 seconds:\n"
"Your Hits are always Critical Strikes\n"
"Hits against you are always Critical Strikes\n"
"Attacks cannot Hit you\n"
"Attacks against you always Hit\n"
"Your Damage with Hits is Lucky\n"
"Damage of Hits against you is Lucky",
"# to Dexterity",
"# to Intelligence",
"#% increased Stun and Block Recovery",
]

ITEMS = [
Expand Down Expand Up @@ -235,18 +268,30 @@
# nothing a logbook's category does not — and why a magic one arriving as "Buffered
# Expedition Logbook" has to resolve back to this record before anything is sent.
"ITEM::Expedition Logbook",
# A unique whose per-unique record states one modifier as a *pool of names* — the minion
# skill gems — rather than as a numeric range, which is the case for a printed range the
# normalizer has to drop before the wording resolves at all.
"UNIQUE::The Dark Monarch",
"ITEM::Lich's Circlet",
"UNIQUE::Replica Dragonfang's Flight",
"ITEM::Onyx Amulet",
"UNIQUE::Bound Fate",
"ITEM::Cloth Belt",
]

UNIQUE_MODS = [
"Ralakesh's Impatience",
"That Which Was Taken",
"Rumi's Concoction",
"The Dark Monarch",
"Replica Dragonfang's Flight",
"Bound Fate",
]

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",
"Sanctum Research", "Expedition Logbooks"]
"Sanctum Research", "Expedition Logbooks", "Helmets", "Amulets", "Belts"]

LANG = "en"

Expand Down
12 changes: 9 additions & 3 deletions src/data/stat_matcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@
namespace ppc::data {
namespace {

/// How many clipboard lines one modifier may span. Hybrids are two; the cap only bounds how
/// far a failed match will keep reaching forward.
constexpr size_t kMaxModLines = 4;
/// How many clipboard lines one modifier may span. Hybrids are two, but a modifier that
/// enumerates its alternatives is as long as the list: Bound Fate's "Every # seconds, gain
/// one of the following" is seven lines and the class-connection jewel is eight, which is
/// the longest wording in the data. Below that the join can never be built and every line of
/// the modifier comes back unrecognised on its own.
///
/// The cap only bounds how far a *failed* match keeps reaching forward — a shorter join
/// always wins, because the loop returns at the first `end` that resolves.
constexpr size_t kMaxModLines = 8;

/// The game appends this to a roll that item level cannot scale.
constexpr std::string_view kUnscalableSuffix = " (unscalable value)";
Expand Down
50 changes: 43 additions & 7 deletions src/data/stat_normalize.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,35 @@ std::string strip_empty_parens(std::string_view line) {
return out;
}

std::string strip_named_ranges(std::string_view line) {
std::string out;
out.reserve(line.size());
for (size_t i = 0; i < line.size(); ++i) {
if (line[i] != '(') {
out.push_back(line[i]);
continue;
}
const size_t close = line.find(')', i);
// A range that follows a number is that number's own, numeric bounds or not.
const char prev = out.empty() ? '\0' : out.back();
if (close == std::string_view::npos || is_digit(prev) || prev == ')') {
out.push_back(line[i]);
continue;
}
const std::string_view inner = line.substr(i + 1, close - i - 1);
// The same split as a numeric range: one arbitrary character, then the separator.
const size_t sep = inner.size() > 1 ? inner.find('-', 1) : std::string_view::npos;
if (sep == std::string_view::npos || sep + 1 >= inner.size() ||
to_double(inner.substr(0, sep)) || to_double(inner.substr(sep + 1))) {
out.push_back(line[i]);
continue;
}
if (prev == ' ') out.pop_back();
i = close;
}
return out;
}

std::vector<NumberToken> scan_numbers(std::string_view s) {
std::vector<NumberToken> out;
const size_t n = s.size();
Expand Down Expand Up @@ -167,17 +196,24 @@ std::string apply_candidate(std::string_view line, std::span<const NumberToken>
}

std::vector<std::string> candidates(std::string_view line) {
const std::string text = strip_empty_parens(line);
std::vector<NumberToken> tokens = scan_numbers(text);
if (tokens.size() > kMaxTokens) tokens.resize(kMaxTokens);

std::vector<std::string> out;
const auto push_unique = [&out](std::string c) {
if (std::find(out.begin(), out.end(), c) == out.end()) out.push_back(std::move(c));
};
for (uint32_t keep : masks_for(tokens.size()))
push_unique(apply_candidate(text, tokens, keep));
push_unique(text); // last resort, so a wording with no numbers still resolves
const auto enumerate = [&](const std::string& text) {
std::vector<NumberToken> tokens = scan_numbers(text);
if (tokens.size() > kMaxTokens) tokens.resize(kMaxTokens);
for (uint32_t keep : masks_for(tokens.size()))
push_unique(apply_candidate(text, tokens, keep));
push_unique(text); // last resort, so a wording with no numbers still resolves
};

const std::string text = strip_empty_parens(line);
enumerate(text);
// Only then the named-range form, so a wording whose parenthesis is part of it —
// "Unique Monsters (Blood-Filled Vessel): #" — resolves as printed.
const std::string named = strip_named_ranges(text);
if (named != text) enumerate(named);
return out;
}

Expand Down
17 changes: 16 additions & 1 deletion src/data/stat_normalize.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ struct NumberToken {
/// Remove every literal "()" — GGG emits them occasionally. Must run before scanning.
std::string strip_empty_parens(std::string_view line);

/// Remove every Advanced Mod Descriptions range whose roll is a *name*.
///
/// A modifier can roll over a list instead of over an interval — "Maximum number of
/// Sentinels of Purity (Animated Weapons-Holy Armaments) is Doubled" rolls over the minion
/// skill gems, and the parenthesis is the first and last of that list exactly as "(50-100)"
/// is the first and last of an interval. `scan_numbers` cannot see it: there is no numeric
/// token in front of it to carry the bounds. The name itself is the roll and the trade
/// wording spells it out, so the range is dropped rather than placeheld.
///
/// Only a group whose two halves are both non-numeric, and that does not follow a number,
/// qualifies — a numeric token's own range belongs to that token.
std::string strip_named_ranges(std::string_view line);

/// Every numeric token, in order.
///
/// A token starts at a '+', '-' or digit that is *not* preceded by a digit or ')'. That
Expand All @@ -43,7 +56,9 @@ std::string apply_candidate(std::string_view line, std::span<const NumberToken>
/// Lookup candidates, most generic first, duplicates removed, raw line last.
///
/// The order is load-bearing: the generic form wins wherever the data has it, and the
/// literal forms exist only for wordings that do not generalise.
/// literal forms exist only for wordings that do not generalise. A line carrying a named
/// range is enumerated twice, as printed and then stripped, so a wording that really does
/// contain a parenthesis still wins as itself.
std::vector<std::string> candidates(std::string_view line);

/// The fully generic candidate — every number replaced by '#'. This is the join key the
Expand Down
Binary file modified tests/data/bundle/en-items-base.index.bin
Binary file not shown.
Binary file modified tests/data/bundle/en-items-name.index.bin
Binary file not shown.
Binary file modified tests/data/bundle/en-items-ref.index.bin
Binary file not shown.
6 changes: 6 additions & 0 deletions tests/data/bundle/en-items.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@
{"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}
{"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}
{"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}
{"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}
Binary file modified tests/data/bundle/en-stats-matcher.index.bin
Binary file not shown.
Binary file modified tests/data/bundle/en-stats-ref.index.bin
Binary file not shown.
Loading