-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplan.cpp
More file actions
1946 lines (1835 loc) · 111 KB
/
Copy pathplan.cpp
File metadata and controls
1946 lines (1835 loc) · 111 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "item/plan.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <charconv>
#include <cmath>
#include <unordered_map>
#include <utility>
#include "data/stat_normalize.hpp"
#include "item/resolve.hpp"
namespace ppc::item {
namespace {
constexpr std::array<std::string_view, static_cast<size_t>(Strategy::Unsupported) + 1>
kStrategies{"Base item", "Modifiers", "Unique", "Currency", "Gem", "Map",
"Beast", "Ultimatum", "Heist", "Sanctum", "Logbook", "Unsupported"};
/// How many decimals `v` needs to survive being printed. Rolls are at most hundredths.
int decimals_needed(double v) {
if (v != std::floor(v * 10) / 10) return 2;
if (v != std::floor(v)) return 1;
return 0;
}
/// A mod's text on one line, for a note.
std::string one_line(const Modifier& m) {
std::string out;
for (const std::string& l : m.lines) {
if (!out.empty()) out += " / ";
out += l;
}
return out;
}
void add_numeric(SearchPlan& p, std::string key, std::string label,
std::optional<double> min, bool enabled, int dp = 0, std::string note = {},
std::optional<double> max = std::nullopt) {
if (!min && !max) return;
NumericFilter f;
f.key = std::move(key);
f.label = std::move(label);
f.min = min;
f.max = max;
f.enabled = enabled;
f.dp = dp;
f.note = std::move(note);
p.numerics.push_back(std::move(f));
}
void add_option(SearchPlan& p, std::string key, std::string label, std::string option,
std::string display, bool shown = false) {
OptionFilter f;
f.key = std::move(key);
f.label = std::move(label);
f.option = std::move(option);
f.display = std::move(display);
f.shown = shown;
p.options.push_back(std::move(f));
}
void add_flag(SearchPlan& p, std::string key, std::string label, bool value, bool shown) {
add_option(p, std::move(key), std::move(label), value ? "true" : "false", value ? "yes" : "no",
shown);
}
/// The name the *trade site* files a record under, which is not always the one the client
/// printed. Three names can differ and the order between them matters:
///
/// - `trade_name`, where the site files the item somewhere else entirely — a transfigured gem
/// goes under the skill it alters, and sending what the clipboard printed matches nothing;
/// - `ref_name`, the English name every localised bundle carries beside the printed one,
/// because the trade API's `name`/`type` terms are English whatever language the client is;
/// - `name`, the printed one, which is the same string as `ref_name` on an English bundle and
/// on every bundle published before `refName` existed.
std::string_view wire_name(const data::BaseType* b) {
if (!b) return {};
if (!b->trade_name.empty()) return b->trade_name;
return b->ref_name.empty() ? b->name : b->ref_name;
}
/// The base as trade knows it, or what the client printed when nothing resolved — in which
/// case the search is as good as the bundle allowed, which is what the plan's notes say.
std::string base_wire_name(const Item& it) {
const std::string_view n = wire_name(it.base);
return n.empty() ? it.base_name : std::string(n);
}
/// A property the game prints as `Label: value`, turned into the `misc_filters` interval trade
/// indexes it under. These are not rolls and there is no tier behind them — the number is
/// simply what this copy has — so the filter is one-sided, and which side it is open on is what
/// "better" means for that property.
const Property* property_of(const Item& it, data::PropertyKey key) {
for (const Property& p : it.properties)
if (p.key == key) return &p;
return nullptr;
}
/// The 20%-quality note that explains why a filter's number is not the one on the item.
/// Nothing to say at exactly 20%, and above it the item's own number is what is searched.
std::string quality_note(const Item& it) {
return it.quality.value_or(0) < 20 ? "normalised to 20% quality" : std::string();
}
/// Whether the game printed this property in the augmented blue, i.e. a modifier on *this
/// copy* raised it above what the base gives. It is the only statement the clipboard makes
/// about a property being better than default, and the bundle carries no base crit chance or
/// attack speed to compare against.
bool property_augmented(const Item& it, data::PropertyKey key) {
return std::any_of(it.properties.begin(), it.properties.end(),
[key](const Property& p) { return p.key == key && p.augmented; });
}
void add_defences(SearchPlan& p, const Item& it, const Derived& d, bool enabled) {
const std::string note = quality_note(it);
struct Entry {
const char* key;
const char* label;
const std::optional<int>& value;
};
for (const Entry& e : std::initializer_list<Entry>{
{"ar", "Armour", d.search_armour},
{"ev", "Evasion", d.search_evasion},
{"es", "Energy Shield", d.search_energy_shield},
{"ward", "Ward", d.search_ward}}) {
if (!e.value) continue;
add_numeric(p, e.key, e.label, static_cast<double>(*e.value), enabled, 0, note);
}
// Where the base's own roll sits in its range — `armour_filters.base_defence_percentile`
// on the trade site, so it is a filter and not a remark under one. It is the base's single
// roll spread over every defence, which is why there is one of these and not one per
// defence.
//
// Ticked only on a base-item search, where that roll *is* what is being bought. On a
// modifier search the defence totals above already carry it, and asking the same question
// twice only drops the listings that answer it once. **Floored, never rounded**: the filter
// is a minimum, and a 78.6th-percentile item asked for at 79 does not match itself.
if (d.base_pct)
add_numeric(p, "base_defence_percentile", "Base Percentile",
std::floor(*d.base_pct * 100), p.strategy == Strategy::BaseItem);
}
/// At or below this, sockets and links are the ordinary case and asking about them only drops
/// listings; at or above it they are most of what the item is worth. Five is where the game puts
/// the line too — five-linking is the step that costs, and the market prices 4-link and 3-link the
/// same as unlinked.
constexpr int kSocketsWorthAsking = 5;
/// The two numbers a linked item is bought for.
///
/// **Both are always offered and neither is always asked.** A six-socket, six-linked chest priced
/// without them is priced as the wrong item — that was the bug — but imposing them on a three-link
/// rare is the mirror of it, since every listing that would have answered has whatever sockets it
/// happens to have. So the count decides: at five or six it is a row, ticked; below that it goes
/// under the expandable section, where a buyer who *does* mean "and four-linked" can still say so.
///
/// They are separate filters because they are separate questions. Six sockets unlinked and six
/// linked are different items at very different prices, and the trade site asks about them in two
/// fields for the same reason.
void add_sockets(SearchPlan& p, const Item& it) {
struct Entry {
const char* key;
const char* label;
int count;
};
for (const Entry& e : {Entry{"sockets", "Sockets", it.socket_count},
Entry{"links", "Links", it.link_count}}) {
if (e.count <= 0) continue;
const bool worth = e.count >= kSocketsWorthAsking;
// A floor and no ceiling, like every other numeric: someone shopping for a five-link
// takes a six-link, and the same buyer would not thank a filter that ruled it out.
add_numeric(p, e.key, e.label, static_cast<double>(e.count), worth);
p.numerics.back().hidden = !worth;
}
}
void add_weapon(SearchPlan& p, const Item& it, const Derived& d, bool enabled) {
if (!it.is_weapon()) return;
const std::string note = quality_note(it);
add_numeric(p, "dps", "Total DPS", d.search_dps, enabled, 1, note);
add_numeric(p, "pdps", "Physical DPS", d.search_pdps, enabled, 1, note);
add_numeric(p, "edps", "Elemental DPS", d.search_edps, enabled, 1);
// Every weapon has these two and on most of them they are the base's own numbers, which
// asking for would only rule out the same weapon in someone else's stash. What makes one
// worth searching is a modifier having raised it — and the game says exactly that by
// printing the value augmented.
add_numeric(p, "aps", "Attacks per Second", it.attacks_per_second,
enabled && property_augmented(it, data::PropertyKey::AttacksPerSecond), 2);
add_numeric(p, "crit", "Critical Strike Chance", it.crit_chance,
enabled && property_augmented(it, data::PropertyKey::CriticalStrikeChance), 2);
}
/// The roll a trade filter for this mod is compared against, and the tier's range for it.
///
/// Trade indexes an added-damage mod as the average of its two numbers, which is what the
/// matcher's own `value` is. Every other multi-number wording is indexed on its *first*
/// number — "15% chance to Unnerve Enemies for 4 seconds on Hit" is searched on the 15, and
/// averaging it with the duration asks for a 10% chance.
struct Roll {
std::optional<double> value, min, max;
};
/// True when trade indexes this mod as the average of its numbers rather than on the first.
bool averaged_roll(const data::StatMatch& m) {
return m.matcher && m.matcher->string.starts_with("Adds ") && m.rolls.size() > 1;
}
Roll roll_for(const data::StatMatch& m) {
Roll r;
if (m.rolls.empty()) return r;
if (averaged_roll(m)) {
r.value = m.value;
if (m.roll_bounds.size() == m.rolls.size()) {
double lo = 0, hi = 0;
for (const auto& [blo, bhi] : m.roll_bounds) {
lo += blo;
hi += bhi;
}
const auto n = static_cast<double>(m.roll_bounds.size());
r.min = lo / n;
r.max = hi / n;
}
return r;
}
r.value = m.rolls.front();
if (!m.roll_bounds.empty()) {
r.min = m.roll_bounds.front().first;
r.max = m.roll_bounds.front().second;
}
return r;
}
/// A modifier the player put on *this copy* rather than one the item came with. It costs
/// currency and not every copy has it, so it is worth searching on even for a unique — an
/// instilled "Used when Charges reach full" is most of what a Rumi's Concoction sells for.
/// It is also why the per-unique modifier data never mentions it: that describes the unique,
/// not what was crafted onto one.
bool added_to_copy(data::ModType t) {
return t == data::ModType::Enchant || t == data::ModType::Crafted ||
t == data::ModType::Fractured || t == data::ModType::Scourge ||
t == data::ModType::Veiled || t == data::ModType::Crucible;
}
/// Turn one modifier into a filter. Bounds follow the strategy: a rolled item is searched
/// inside the tier it rolled, everything else is searched at "no worse than this".
///
/// `ranges_printed` is whether *this item* printed a roll range anywhere, i.e. whether the
/// owner has Advanced Mod Descriptions on — which is what makes the absence of one on a given
/// modifier mean something. See the bound rule below.
std::optional<StatFilter> to_filter(const Item& it, size_t index, Strategy s, bool ranges_printed,
const RangeMatch& rm) {
const Modifier& m = it.mods[index];
if (!m.match || !m.match->stat) return std::nullopt;
const data::Stat& stat = *m.match->stat;
const std::vector<std::string>& ids = stat.trade_ids(m.match->mod_type);
if (ids.empty()) return std::nullopt;
StatFilter f;
f.mod_index = index;
f.id = ids.front();
f.text = m.text();
f.type = m.match->mod_type;
f.inverted = stat.inverted;
const Roll roll = roll_for(*m.match);
// The bundle does not carry a decimal count for every stat, and rounding a roll away is
// how "0.4% of Physical Attack Damage Leeched as Mana" comes out as a filter for 0.
f.dp = std::max(stat.dp, decimals_needed(roll.value.value_or(0)));
const bool has_bounds = roll.min && roll.max;
// What the affix could have rolled, as against what the search will ask for. The same two
// numbers on a `Modifiers` plan today, and the reason they are two fields is that they stop
// being the same the moment the asking is editable.
f.roll_min = roll.min;
f.roll_max = roll.max;
// Advanced Mod Descriptions printed a range wider than a point, so this roll is one of
// several the affix could have had — which is what "variable" means for a unique.
const bool variable = has_bounds && *roll.min != *roll.max;
// Which side an open bound goes on. "No worse than what it rolled" is a *minimum* only
// when higher is better, and for a mod the game prints negative it is not: an exposure
// implicit applying -11% to Cold Resistance is better at -13, and a minimum of -11 asks
// for the weakest copies of it. The bundle's `better` says so for the ten stats that are
// bad at any sign ("#% increased Damage taken"), and the roll's own sign covers the rest,
// because the canonical wording already carries the direction — "#% reduced Mana Cost" is
// stored as a negative increase. The one case this reads wrong is a negative roll of a
// stat that also rolls positive, i.e. a resistance penalty, where less negative is better;
// those are drawbacks on uniques and corrupted implicits rather than what a buyer searches.
const bool lower_is_better = stat.better < 0 || roll.value.value_or(0) < 0;
// **A number that is not a roll is not a bound.** A fixed modifier says the same thing on
// every copy of itself, and asking the trade site to compare its number asks it to compare
// a value it does not index the stat on. Measured, not inferred: the Baran map implicit
// ("…drops by 20% of its value") returned 0 listings with `min: 20` against 1705 without
// it, and "Area is influenced by The Elder" — whose number is not even in the clipboard,
// but a constant the matcher substitutes for the influence — 0 against 10000. The filter
// stays and only its number goes, so the search asks for the modifier being *present*,
// which is the only thing a fixed modifier can be asked about.
//
// What says a number is fixed is that the game printed **no range beside it** — and that
// only means anything on an item that printed ranges at all. With Advanced Mod Descriptions
// off nothing carries one, so reading their absence as "fixed" would strip the bound off
// every real roll on the item and search a rare for "has a life modifier". A map is the
// exception and needs no such evidence: none of the numbers a map's implicits and enchants
// carry is ever a roll.
//
// **A tier or a rank is itself a range**, whether or not the modifier rolls one inside it:
// a different tier is a different number, so "no worse than what this one gave" is a real
// question. It is also the only thing an eldritch implicit has to say so with — its
// magnitude comes from the tier of the currency that put it there, so the clipboard prints
// no range and states the rank instead: `{ Eater of Worlds Implicit Modifier (Lesser) }`.
const bool ranked = m.tier > 0 || m.rank > 0 || !m.qualifier.empty();
const bool fixed = !variable && !ranked && (s == Strategy::Map || ranges_printed);
// How wide the asking is around that roll is the user's setting, not this layer's: see
// `seed_bounds`. All this decides is whether there is a roll to seed from at all, and what
// the tier gate is when the item printed one.
f.tiered = has_bounds;
if (fixed) {
// presence only
} else if (roll.value) {
const Bounds b = seed_bounds(rm, *roll.value, roll.min, roll.max, f.dp, lower_is_better);
f.min = b.min;
f.max = b.max;
}
switch (s) {
case Strategy::Modifiers:
f.enabled = true;
break;
case Strategy::BaseItem:
// The point of a base-item search is that the rolls do *not* matter — except a
// fractured one, which the buyer keeps, and an implicit that is not the base's own.
f.enabled = m.type == data::ModType::Fractured ||
(m.type == data::ModType::Implicit && (variable || it.synthesised));
break;
case Strategy::Unique:
// A unique's fixed mods are the same on every copy of it, so filtering on them
// only costs results. What does matter: a roll a range proves is variable, a mod
// something *added* to the item ("Foulborn Unique Modifier"), anything the player
// crafted onto this copy, and an implicit corruption or synthesis could have put
// there — there is no way to tell an added implicit from the unique's own without
// per-unique mod data.
f.enabled = variable || m.added_unique || added_to_copy(m.type) ||
(m.type == data::ModType::Implicit && (it.corrupted || it.synthesised));
break;
case Strategy::Map:
// Only the modifiers that are a property of *this* map rather than of the roll a
// Chaos Orb could redo: what the area itself is (the implicit, which names the
// boss, the influence or the memory) and what somebody paid to enchant onto it.
// The affixes are handled entirely by their count — see `map_affix_count`.
f.enabled = true;
break;
case Strategy::Heist:
// The map argument with the tick left off instead of the whole row. A blueprint's
// **enchant** is what the run is for and somebody paid to put it there; its other
// modifiers are the danger it will hold, which is rolled and re-rollable, so they
// are offered and not imposed — seven ticked hazards ask for one copy in the world.
f.enabled = m.type == data::ModType::Enchant;
break;
case Strategy::Sanctum:
// A sanctum's affixes are not a roll somebody could redo — the run is already under
// way and nothing can be applied to it again, which is what "Unmodifiable" on the
// item means. They are as much a part of what is being bought as its resolve is.
f.enabled = true;
break;
default:
f.enabled = false;
break;
}
return f;
}
/// What this modifier can roll on this unique, in the terms its filter is compared on: the
/// average of both numbers for an added-damage mod and the first number otherwise, scaled by
/// a catalyst exactly as the clipboard's own roll already was.
Roll unique_range(const data::UniqueModFilter& uf, const Modifier& m, bool averaged) {
Roll r;
if (uf.ranges.empty()) return r;
double lo = uf.ranges.front().first, hi = uf.ranges.front().second;
if (averaged && uf.ranges.size() > 1) {
lo = hi = 0;
for (const auto& [a, b] : uf.ranges) {
lo += a;
hi += b;
}
const auto n = static_cast<double>(uf.ranges.size());
lo /= n;
hi /= n;
}
if (m.roll_incr != 0) {
const int dp = m.match && m.match->stat ? m.match->stat->dp : 0;
lo = data::incr_roll(lo, m.roll_incr, dp);
hi = data::incr_roll(hi, m.roll_incr, dp);
}
r.min = lo;
r.max = hi;
return r;
}
/// The filter whose modifier printed `line`, or null. The join is the game's own wording,
/// which is what both sides have: the per-unique data's unlisted pools are stated as the lines
/// the client prints, and so are the modifiers the parser read off the clipboard.
StatFilter* filter_saying(SearchPlan& p, const Item& it, std::string_view line) {
for (StatFilter& f : p.stats) {
if (!f.mod_index) continue;
for (const std::string& l : it.mods[*f.mod_index].lines)
if (l == line) return &f;
}
return nullptr;
}
/// Whether `line` is one of the item's **property** lines, as the game prints it — either
/// "Label: value" or, for the ones the game writes as a sentence, the value alone.
///
/// The other half of `filter_saying`: both answer "is this already on screen", and the per-unique
/// data does not distinguish a property from a modifier. A unique heist contract is the case —
/// its client, area level, heist target and job requirement are listed there as modifiers and
/// printed by the game as properties.
bool printed_as_property(const Item& it, std::string_view line) {
for (const Property& p : it.properties) {
if (p.label.empty() ? p.value == line : line == p.label + ": " + p.value) return true;
}
return false;
}
/// Fold the bundle's per-unique modifier data into the plan.
///
/// Without it `Strategy::Unique` can only enable a roll whose printed range proves it is
/// variable, which leaves out the case the dataset exists for: a modifier the unique picks
/// from a pool prints exactly like one every copy has — each of Ralakesh's three charge
/// modifiers rolls 1..1 — and it is the difference between a chaos and a hundred divines. It
/// also supplies the range whatever the user's Advanced Mod Descriptions setting is.
///
/// The join is on the trade id and never on the wording: wordings are shared by two stat
/// records often enough that the ids are the only thing telling those apart. Nothing here
/// ever *disables* a filter — the item's own printed range outranks a record about the
/// unique in general.
void apply_unique_mods(const data::GameData& gd, const Item& it, SearchPlan& p,
const RangeMatch& rm) {
// Unidentified, or a name the bundle does not know; both already have their own note.
if (p.name.empty()) return;
const data::UniqueMods* um = gd.find_unique_mods(p.name);
if (!um) {
const bool anything_left_out =
std::any_of(p.stats.begin(), p.stats.end(), [](const StatFilter& f) {
return !f.enabled && f.type == data::ModType::Explicit;
});
if (anything_left_out)
p.notes.push_back(
gd.has_unique_mods()
? "no modifier data for \"" + p.name +
"\" in this bundle, so a modifier that is one of a pool of "
"possibilities cannot be told from a fixed one"
: "this data bundle carries no per-unique modifier data, so a modifier "
"that is one of a pool of possibilities cannot be told from a fixed one");
return;
}
struct Entry {
const data::UniqueModFilter* filter;
const data::UniqueModPool* pool; ///< null for a modifier every copy has
};
std::unordered_map<std::string_view, Entry> by_id;
for (const data::UniqueMod& m : um->fixed)
for (const data::UniqueModFilter& f : m.filters)
if (!f.trade_id.empty()) by_id.emplace(f.trade_id, Entry{&f, nullptr});
// A pool wins over a fixed entry of the same stat: what is being searched for is the copy
// that rolled it, and the fixed half of the pair is on every copy either way.
for (const data::UniqueModPool& pool : um->pools)
for (const data::UniqueMod& m : pool.mods)
for (const data::UniqueModFilter& f : m.filters)
if (!f.trade_id.empty()) by_id[f.trade_id] = Entry{&f, &pool};
for (StatFilter& f : p.stats) {
if (!f.mod_index) continue; // a pseudo total, which no unique's record is about
const auto e = by_id.find(f.id);
if (e == by_id.end()) {
// Either something added to this copy of the item, or a modifier the source has
// not caught up with, or one it cannot search. Nothing says it is fixed, so it is
// said rather than silently left out of the search — but only for a modifier the
// record is *about*. A crafted one is absent from it by definition, and saying so
// reads as a failure to recognise a modifier that is right there in the list.
//
// On the **row** and not in a note underneath: the row is already the statement —
// it names the modifier and its box is not ticked — and a paragraph repeating that
// wording costs three lines of panel to say it a second time. This is why.
if (!f.enabled && !added_to_copy(f.type))
f.caveat = "not in the modifier data for \"" + um->name +
"\", so nothing can tell it from a modifier every copy has";
continue;
}
const Modifier& m = it.mods[*f.mod_index];
Roll r = unique_range(*e->second.filter, m, m.match && averaged_roll(*m.match));
// Only trust a range that describes the roll in front of us. The bundle carries no
// decimal count for every stat, so a range can arrive a hundred times the roll it
// bounds ("0.4% of Physical Attack Damage Leeched as Mana" against 40..40) — and a
// legacy roll genuinely sits outside its own. Either way the bounds are not this
// item's, and calling a modifier variable on them would be a guess. Pool membership
// is a fact about the item rather than a number, so it stands regardless.
const Roll printed = m.match ? roll_for(*m.match) : Roll{};
if (r.min && r.max && printed.value &&
(*printed.value < *r.min - 1e-6 || *printed.value > *r.max + 1e-6))
r = Roll{};
// The item's own printed range outranks a record about the unique in general, so this
// only fills a gap the clipboard left.
if (!f.roll_min && !f.roll_max) {
f.roll_min = r.min;
f.roll_max = r.max;
}
if (e->second.pool) {
f.pooled = true;
f.pool_hint = e->second.pool->hint;
f.enabled = true;
} else if (r.min && r.max && *r.min != *r.max) {
// Fixed for the item, variable in its roll: the same judgement a printed range
// drives, now made whether or not the game printed one.
f.enabled = true;
}
// And a range is a range whichever source stated it. `to_filter` leaves a modifier
// unbounded when the clipboard printed no range for it, because it cannot tell a fixed
// one from an owner with Advanced Mod Descriptions off — but here the record says
// outright that this one rolls, so the roll is a bound after all.
if (!f.min && !f.max && printed.value && r.min && r.max && *r.min != *r.max) {
const bool lower_is_better =
(m.match->stat && m.match->stat->better < 0) || *printed.value < 0;
const Bounds b = seed_bounds(rm, *printed.value, r.min, r.max, f.dp, lower_is_better);
f.min = b.min;
f.max = b.max;
f.tiered = true; // the record stated a range, which is what the tiered modes gate on
}
}
// An unlisted pool is prose the source never turned into modifiers — "One to three random
// Synthesis implicit modifiers". Where it names something the item in hand actually has, it
// is **already a row** in the filter list and the note would be the same wording a second
// time: Triad Grip's four conversion modifiers are unlisted *and* printed on the item, so
// between this and the loop above they cost twelve lines of panel to say what four unticked
// boxes said. Only prose with nothing on screen behind it is worth a note of its own.
for (const std::string& u : um->unlisted) {
// On screen as a **property** rather than as a filter, which is the same argument one
// step over: the source lists a unique heist contract's client, area level, target and
// job requirement as modifiers, and the game prints all four in the property block. Four
// notes saying they are not searched, beside four lines already saying what they are.
if (printed_as_property(it, u)) continue;
if (StatFilter* f = filter_saying(p, it, u)) {
f->caveat = "the modifier data states this but does not enumerate it, so nothing "
"here knows what it can roll";
continue;
}
p.notes.push_back(
"the modifier data states but does not enumerate this, so it is not "
"searched: " +
u);
}
}
/// Fold filters that share a trade id into one, summing their bounds.
///
/// An item with "+28 to maximum Life" and "+89 to maximum Life" is indexed by trade as one
/// stat worth 117, so two separate filters would each be compared against that total and the
/// smaller of the two would decide the search on its own. Summing is what the site actually
/// searches on: 104 to 117 for that pair.
void merge_same_stat(std::vector<StatFilter>& stats) {
for (size_t i = 0; i < stats.size(); ++i) {
for (size_t j = i + 1; j < stats.size();) {
// Never across the divide: a hidden filter folded into a shown one would put a
// modifier the strategy left out into the total of one it did not, and the row's
// tick would then be sending both.
// Never across a choice either, and for a sharper version of the same reason: two
// of a logbook's destinations can grant one stat, or belong to one faction, and
// summing those gives a number no single destination has — while the whole point of
// the group is that only one destination is ever being asked about.
if (stats[j].id != stats[i].id || stats[j].hidden != stats[i].hidden ||
stats[j].choice != stats[i].choice) {
++j;
continue;
}
StatFilter& into = stats[i];
const StatFilter& from = stats[j];
const auto add = [](std::optional<double>& a, const std::optional<double>& b) {
if (a && b)
*a += *b;
else
a.reset(); // one side unbounded makes the total unbounded
};
add(into.min, from.min);
add(into.max, from.max);
add(into.roll_min, from.roll_min);
add(into.roll_max, from.roll_max);
into.tiered = into.tiered && from.tiered;
into.enabled = into.enabled || from.enabled;
if (from.pooled && !into.pooled) {
into.pooled = true;
into.pool_hint = from.pool_hint;
}
into.text += "\n" + from.text;
if (from.mod_index) into.merged.push_back(*from.mod_index);
into.merged.insert(into.merged.end(), from.merged.begin(), from.merged.end());
stats.erase(stats.begin() + static_cast<ptrdiff_t>(j));
}
}
}
/// A modifier a map is *searched* on. A map's prefixes and suffixes are deliberately not among
/// them and are not even offered: they are re-rollable with one Chaos Orb, the buyer is choosing
/// how dangerous a map they want rather than which affix it has, and a query naming them would
/// return the one copy in the league that rolled that set. What is left is what a currency
/// cannot change — the implicit, which says whose area this is — and what somebody paid to put
/// there. How many affixes it has still matters, and that goes in as a total; see below.
bool map_searched_mod(const Modifier& m) {
return m.type == data::ModType::Implicit || m.type == data::ModType::Enchant;
}
/// How many affixes the map rolled, or nothing when the clipboard does not say.
///
/// A rare map takes six, and only corruption can push it to eight — which is most of what an
/// eight-mod map is worth, and the reason trade indexes the count as a pseudo stat at all. One
/// affix can print several lines (a hybrid "Players have 30% less Armour / 40% reduced Chance to
/// Block"), so the continuation lines are what is counted out; and the side of the pool is only
/// ever printed by Advanced Mod Descriptions, so with that off there is no count to give rather
/// than a count of zero.
std::optional<int> map_affix_count(const Item& it) {
int n = 0;
bool any_explicit = false;
for (const Modifier& m : it.mods) {
if (m.type != data::ModType::Explicit) continue;
any_explicit = true;
if (m.continuation) continue;
if (m.affix == Affix::Prefix || m.affix == Affix::Suffix) ++n;
}
if (any_explicit && n == 0) return std::nullopt;
return n;
}
/// Trade's `pseudo.*` totals for a map, which is everything about one that is not a modifier.
///
/// The four drop bonuses are printed by the game as **properties** — "More Maps: +70%", what a
/// Maven's chisel adds — and the site has no `map_filters` entry for any of them, so a pseudo
/// stat is the only way to ask. The affix count is the same shape: a fact about the whole item
/// with no single modifier behind it, which is why `StatFilter::mod_index` is optional.
void add_map_pseudo(const Item& it, SearchPlan& p) {
struct Drop {
data::PropertyKey key; ///< the property the game prints
const char* id;
const char* text; ///< the trade site's own wording, so the two read alike
};
// Every "More" the chisels grant; there is no fifth pseudo stat in /api/trade/data/stats.
static constexpr Drop kDrops[]{
{data::PropertyKey::MoreMaps, "pseudo.pseudo_map_more_map_drops", "More Maps: #%"},
{data::PropertyKey::MoreScarabs, "pseudo.pseudo_map_more_scarab_drops",
"More Scarabs: #%"},
{data::PropertyKey::MoreCurrency, "pseudo.pseudo_map_more_currency_drops",
"More Currency: #%"},
{data::PropertyKey::MoreDivinationCards, "pseudo.pseudo_map_more_card_drops",
"More Divination Cards: #%"},
};
const auto pseudo = [&p](const char* id, const char* text, double min, bool enabled) {
StatFilter f;
f.id = id;
f.text = text;
f.type = data::ModType::Pseudo;
f.min = min;
f.enabled = enabled;
p.stats.push_back(std::move(f));
};
for (const Drop& d : kDrops)
if (const Property* prop = property_of(it, d.key); prop && prop->num)
pseudo(d.id, d.text, *prop->num, true);
// Only on a corrupted map: below eight the count is what every rare map of its rarity has,
// and filtering on it would drop the six-mod maps that are the same item.
if (!it.corrupted) return;
const std::optional<int> n = map_affix_count(it);
if (!n)
p.notes.emplace_back(
"how many affixes this map has needs Advanced Mod Descriptions, "
"so the search does not ask for the count");
else if (*n > 0) // a corrupted white map has none, which is not something to ask for
pseudo("pseudo.pseudo_number_of_affix_mods", "# Modifiers", *n, true);
}
/// Stop searching for a modifier the search is already asking about **by its result**.
///
/// A local roll is not something the item has beside its armour — it is part of the armour the
/// item displays, and the same is true of a weapon's damage rolls and of what a flat energy
/// shield prefix does to `es`. Filtering on both the number and the modifier behind it asks one
/// question twice, and the second asking is the brittle half: a flat roll and a local increase
/// reach the same armour by different routes, so naming *this* item's route rules out every
/// other way of arriving at the number the buyer actually wants.
///
/// So the derived value is what is imposed and the modifier behind it is only offered — left in
/// the list, not removed, since it can still be the thing the buyer wants. Conditional on the
/// derived filter being enabled: with nothing asking for the armour (a unique, where the
/// defences are offered rather than imposed) the modifier is all there is to ask about.
///
/// **A fractured roll is the exception and keeps its filter.** It cannot be re-rolled, it is
/// what survives every craft the buyer will do to the item afterwards, and trade indexes it in
/// a namespace of its own (`fractured.stat_…`, which is what `to_filter` already sends) — so
/// unlike every other route to the same armour, *which* modifier it is is the point of buying
/// the item at all.
void unimpose_derived_mods(const Item& it, SearchPlan& p) {
const auto imposed = [&p](std::string_view key) {
return std::any_of(p.numerics.begin(), p.numerics.end(),
[key](const NumericFilter& n) { return n.enabled && n.key == key; });
};
for (StatFilter& f : p.stats) {
if (!f.enabled || !f.mod_index) continue;
if (f.type == data::ModType::Fractured) continue;
for (const std::string_view k : derived_filter_keys(it, it.mods[*f.mod_index]))
if (imposed(k)) {
f.enabled = false;
break;
}
}
}
/// A gem is a name, a level and a quality — and deliberately nothing else.
///
/// The lines a gem prints are what the skill does, identical on every copy, so there is nothing
/// to filter on and the name plus those two numbers are the whole search. Both numbers are
/// matched **exactly**, the same reasoning as a map's tier: a level 21 gem is not a better
/// level 20 one, it is what the gem sells as, and the same goes for the quality bracket. A
/// floor would put 21/23 corrupted gems in the results for a 20/20 and price the wrong item.
/// Corruption is already matched exactly for every strategy, and on a gem it is the hard split
/// between the two markets: it is what allows level 21 and quality 23 at all.
///
/// The name is the record's, never the printed one. A Vaal gem prints the base skill and a
/// transfigured gem prints a name trade does not file it under — see `Item::gem_name` and
/// `BaseType::trade_name` — and a `type` term trade does not know matches nothing, which reads
/// as a gem nobody is selling rather than as a search that could not be built. So an unresolved
/// gem gets no search at all and says why; poe.ninja still prices it.
void plan_gem(const Item& it, SearchPlan& p) {
if (!it.base) {
const std::string name = "\"" + it.gem_name() + "\" is not in this data bundle";
p.notes.push_back(it.transfigured
? name + ": trade files a transfigured gem under the skill it "
"alters, and only a data build carrying its printed "
"name can say which one this is"
: name);
return;
}
p.type = std::string(wire_name(it.base));
p.discriminator = it.base->trade_disc;
const auto exact = [&p](const char* key, const char* label, std::optional<int> v) {
if (!v) return;
add_numeric(p, key, label, static_cast<double>(*v), true, 0, {},
static_cast<double>(*v));
};
exact("gem_level", "Gem Level", it.gem_level);
// Always, and at zero as readily as at twenty: an unquality gem is a different thing from a
// 20% one, and leaving the filter off would price it as whatever the cheapest quality is.
exact("quality", "Quality", it.quality.value_or(0));
}
/// An itemised beast: the species and the item level, and nothing else the item prints.
///
/// A beast is bought to be released into the menagerie and spent on a beastcrafting recipe, and
/// a recipe names the **species** — a Wild Hellion Alpha — so that is the whole of what one
/// copy has in common with another. The two lines above it are a rare title the game generated
/// for this capture ("Banebite the Malignant"), which no two copies share and no buyer asks
/// for, so the `name` term is deliberately left empty and the species goes in `type`.
///
/// The **monster modifiers** are skipped for the same reason a map's affixes are (`build_plan`):
/// they are the captured monster's own abilities rather than rolls on a base, the bundle has no
/// stat for "Crushing Claws" to match, and a recipe cares about none of them. Left out silently
/// — with no unrecognised-modifier note — because leaving them out is the decision, not a
/// failure to read them.
///
/// The item level is a floor rather than a window: the recipes that care about it want a beast
/// at least that high, and a higher one still answers.
void plan_beast(const Item& it, SearchPlan& p) {
// The one place a category is not the bundle's answer for the item class. A beast's class is
// "Stackable Currency", which maps to `currency` and is right for every orb that prints it —
// but trade files beasts in a category of their own. Measured: `category: currency` returned
// **0 matches** for a Wild Hellion Alpha and `monster.beast` returned **1602**, with the same
// type and the same item level. The site accepts either, so the wrong one reads as nobody
// selling one rather than as an error, which is what made this worth measuring rather than
// reasoning about.
p.category = "monster.beast";
p.type = base_wire_name(it);
if (it.base && !it.base->trade_disc.empty()) p.discriminator = it.base->trade_disc;
else if (!it.base)
p.notes.push_back("\"" + it.base_name +
"\" is not a beast in this data bundle, so the search asks for the "
"species as the clipboard spelled it");
add_numeric(p, "ilvl", "Item Level",
it.item_level ? std::optional<double>(*it.item_level) : std::nullopt, true);
}
/// The `ultimatum_challenge` option ids, in the order `TermList::UltimatumChallenges` lists the
/// wordings the game prints for them. Same shape as the chart shapes and for the same reason:
/// a closed vocabulary from `/api/trade/data/filters`, joined to the client's text, and the id
/// is never derived from the words.
constexpr std::string_view kUltimatumChallengeIds[]{"Exterminate", "Survival", "Defense",
"Conquer"};
/// The `ultimatum_reward` ids for the three rewards the game states as a wording. The fourth,
/// below, has none: an ultimatum that pays out a unique prints that unique's name on the line.
constexpr std::string_view kUltimatumRewardIds[]{"DoubleCurrency", "DoubleDivCards", "MirrorRare"};
constexpr std::string_view kUltimatumUniqueRewardId = "ExchangeUnique";
/// `Lexicon::index_of`, ignoring case. Only the challenge list needs it: the trade site titles
/// its option "Defeat Waves of Enemies" and the client prints "Defeat waves of enemies", so the
/// English entries are the site's own text and the case is the one thing that differs.
int index_of_ci(const data::Lexicon& lex, data::TermList l, std::string_view s) {
const std::vector<std::string>& v = lex.list(l);
for (size_t i = 0; i < v.size(); ++i) {
if (v[i].size() != s.size() || v[i].empty()) continue;
if (std::equal(v[i].begin(), v[i].end(), s.begin(), [](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) ==
std::tolower(static_cast<unsigned char>(b));
}))
return static_cast<int>(i);
}
return -1;
}
/// The item an ultimatum's stake names, without the count the game prints after it: "Divine Orb
/// x8" is a search for Divine Orbs, and how many of them is not something trade indexes.
std::string_view strip_stack_count(std::string_view v) {
const size_t x = v.rfind(" x");
if (x == std::string_view::npos || x + 2 >= v.size()) return v;
for (size_t i = x + 2; i < v.size(); ++i)
if (!std::isdigit(static_cast<unsigned char>(v[i]))) return v;
return v.substr(0, x);
}
/// The name trade files an ultimatum's stake under, or "" when this bundle does not know it.
///
/// The site takes a **known item** here, across the three namespaces its own filter names —
/// uniques, divination cards and currency — and a name it does not know fails the whole search
/// rather than widening it, exactly as a Valdo map's reward does. So nothing unconfirmed is sent.
std::string find_sacrifice(const data::GameData& gd, std::string_view printed) {
static constexpr data::Namespace kNs[]{data::Namespace::Unique, data::Namespace::DivinationCard,
data::Namespace::Item};
for (const data::Namespace ns : kNs)
for (const data::BaseType* b : gd.find_bases(ns, printed))
return std::string(wire_name(b));
return {};
}
/// The two modifiers an ultimatum is searched on, and the only two: they are what the trial's
/// difficulty *is*, and on a currency or divination-card ultimatum they are also what says how
/// much is at stake — the sacrificed stack grows with them. Every other line is the shape of the
/// danger rather than a term of the deal, which is what the user is choosing to run or not.
bool ultimatum_stake_mod(const Modifier& m) {
if (!m.match || !m.match->stat) return false;
const std::string& ref = m.match->stat->ref;
return ref == "#% increased Monster Damage" || ref == "#% more Monster Life";
}
/// An Inscribed Ultimatum is a contract, and a search for one asks for the same contract: the
/// trial, the stake, the payout, and the two numbers that say how large the stake is.
///
/// - **The challenge and the reward type** are what the trial is and what it pays, and trade has
/// an option for each. The reward that is a unique has no wording of its own — the line is the
/// unique's name — so that name goes into `ultimatum_output` and the type is `ExchangeUnique`.
/// - **The sacrificed item**, which is the price of entry. Not its count: trade indexes no such
/// number, and the count is already implied by the two modifiers below. The one reward with no
/// nameable stake is the mirror, whose line reads "Mirrorable, Rare Item" — a class of items
/// rather than one, and already fully said by the reward type.
/// - **The area level**, exact rather than a floor, for the same reason a chart's is: an 83 is a
/// different trial from a 78, not a better one.
/// - **Increased Monster Damage and more Monster Life**, exact rather than windowed *whatever the
/// range-match setting says*, because these two are the scale of the deal and not a roll to be
/// beaten: 200% more Monster Life is the ultimatum that stakes eight Divine Orbs, and asking
/// for "at least 120%" prices four of them alongside it.
///
/// Everything else it prints — Choking Miasma, Drought, Shattered Shield — is left out, and
/// silently: they are the trial's hazards, they sit on the item beside the panel, and a note per
/// line would charge the check with failing at something it deliberately did not attempt.
void plan_ultimatum(const data::GameData& gd, const Item& it, SearchPlan& p) {
const data::Lexicon& lex = gd.lexicon();
// **No category at all**, which is the second place the bundle's answer for the item class is
// overridden and the first where the override is to send nothing. "Misc Map Items" maps to
// `map.fragment`, and that is right for the invitations and splinters that share the class but
// not for an ultimatum: measured, the same query returned **0 matches** with it and **443**
// without, everything else identical. Nothing is lost by dropping it — an ultimatum is one
// base type, so the type term below already says everything a category could.
p.category.clear();
p.type = base_wire_name(it);
if (it.base && !it.base->trade_disc.empty()) p.discriminator = it.base->trade_disc;
if (const Property* c = property_of(it, data::PropertyKey::Challenge); c && !c->value.empty()) {
const int i = index_of_ci(lex, data::TermList::UltimatumChallenges, c->value);
if (i >= 0 && static_cast<size_t>(i) < std::size(kUltimatumChallengeIds))
add_option(p, "ultimatum_challenge", c->label,
std::string(kUltimatumChallengeIds[i]), c->value, true);
else
p.notes.push_back("\"" + c->value +
"\" is not a challenge the trade site knows, so the search does not "
"ask which trial this is");
}
const Property* reward = property_of(it, data::PropertyKey::Reward);
bool mirror_reward = false;
if (reward && !reward->value.empty()) {
const int i = lex.index_of(data::TermList::UltimatumRewards, reward->value);
if (i >= 0 && static_cast<size_t>(i) < std::size(kUltimatumRewardIds)) {
mirror_reward = kUltimatumRewardIds[i] == "MirrorRare";
add_option(p, "ultimatum_reward", reward->label,
std::string(kUltimatumRewardIds[i]), reward->value, true);
} else if (const std::string named = find_unique_in(gd, reward->value); !named.empty()) {
add_option(p, "ultimatum_reward", reward->label,
std::string(kUltimatumUniqueRewardId), reward->value, true);
add_option(p, "ultimatum_output", "Reward Unique", named, named, true);
} else {
p.notes.push_back("\"" + reward->value +
"\" is neither a reward wording nor a unique in this data bundle, "
"so the search does not ask what this ultimatum pays out");
}
}
if (const Property* s = property_of(it, data::PropertyKey::RequiresSacrifice);
s && !s->value.empty() && !mirror_reward) {
const std::string_view stake = strip_stack_count(s->value);
if (const std::string named = find_sacrifice(gd, stake); !named.empty())
add_option(p, "ultimatum_input", s->label, named, std::string(stake), true);
else
p.notes.push_back("\"" + std::string(stake) +
"\" is not an item in this data bundle, and the trade site rejects a "
"required item it does not know, so the search does not ask what "
"this ultimatum costs to run");
}
if (const Property* lvl = property_of(it, data::PropertyKey::AreaLevel); lvl && lvl->num)
add_numeric(p, "area_level", lvl->label, *lvl->num, true, 0, {}, *lvl->num);
}
/// The `heist_*` filter for each rogue job, in the order `TermList::HeistJobs` names them.
constexpr std::string_view kHeistJobKeys[]{
"heist_lockpicking", "heist_brute_force", "heist_perception",
"heist_demolition", "heist_counter_thaumaturgy", "heist_trap_disarmament",
"heist_agility", "heist_deception", "heist_engineering"};
/// The `heist_objective_value` option ids, in the order `TermList::HeistObjectiveValues` lists
/// the words the game prints for them.
constexpr std::string_view kHeistObjectiveIds[]{"moderate", "high", "precious", "priceless"};
/// The two numbers of a "3/21" value — what there is now and what there is in all. A blueprint
/// states each of its reveal counts this way and a sanctum states its resolve. Absent when the
/// value is not that shape, which is how a client that words it differently degrades: no filter
/// rather than a filter for a number that is not there.
std::optional<std::pair<double, double>> slashed_pair(const Property& p) {
const size_t slash = p.value.find('/');
if (slash == std::string::npos || !p.num) return std::nullopt;
const std::string_view rest = std::string_view(p.value).substr(slash + 1);
double total = 0;
// `std::from_chars` and not the C locale, which would read "21" against a decimal comma.
const auto [end, ec] = std::from_chars(rest.data(), rest.data() + rest.size(), total);
if (ec != std::errc{} || end == rest.data()) return std::nullopt;
return std::pair{*p.num, total};
}
/// The parenthetical a heist objective's name ends with — "Ancient Seal (Precious)" — or "".
/// The value is the whole of what trade indexes about a target; the target's own name is not a
/// term the site takes at all.
std::string_view objective_value_of(const Property& p) {
if (p.value.empty() || p.value.back() != ')') return {};
const size_t open = p.value.rfind(" (");
if (open == std::string::npos) return {};
return std::string_view(p.value).substr(open + 2, p.value.size() - open - 3);
}
/// A heist contract or blueprint: **which run this is**, and what it will cost to make.
///
/// The first iteration of a market nobody here has traded, so it errs towards offering rather
/// than towards deciding — every heist filter the site has is a row, and what separates the
/// ticked from the untitcked is one question: is this a fact about *which item this is*, or is
/// it the variation between two copies of the same one?
///
/// Imposed, because they say which run it is:
/// - **The area**, which is the base type and is what the game names the item after ("Blueprint:
/// Underbelly"). A rare heist item's own name — "Cataclysm Vow" — is generated per copy and is
/// no more searchable than a rare bow's.
/// - **The area level**, exact, on the same reading a chart's and an ultimatum's get: pricing is
/// like for like, and a level 83 run is a different product from a level 77 one.
/// - **What is revealed**, on a blueprint, as a floor: more of the map uncovered is strictly more
/// of what a buyer is paying for. The total beside each count is exact where the site indexes
/// one — a blueprint's wing count varies per copy, so it is part of which item this is rather
/// than an amount of anything — and Total Escape Routes is left out entirely. See below.
/// - **The job levels**, as a floor, at the level the item demands. A requirement is what the run
/// costs to open: a rogue short of it cannot run this copy at all, and a copy asking for less
/// is a cheaper product rather than a better copy of the same one.
/// - **The enchant**, on a blueprint that has one: "Heist Targets are always Enchanted
/// Armaments" is what the whole run is for, and somebody paid to put it there.
///
/// Offered and left unticked, because they are the roll rather than the item:
/// - **The objective's value** on a contract, the parenthetical after the target. It follows from
/// the target the copy happens to have rolled, and a buyer opening the area for its level and
/// its jobs is not picking what sits at the end of it.
/// - **The heist modifiers.** They are the danger the run will hold: rolled, re-rollable, and
/// the map argument exactly, except that the row stays and only the tick goes. A contract
/// carries seven of them and ticking all seven asks for one particular copy in the world.
///