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
5 changes: 4 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ lists at the top of that script, never writing a record by hand. It copies every
rebuilds the indices from the offsets it just wrote, which is the point: the `.index.bin` files
address the ndjson by byte offset, so one extra byte per line silently shifts every record out from
under every lookup and fails as null lookups rather than as a diff. Keep the ndjson **LF and
byte-exact**; `.gitattributes` pins that down and those entries must stay.
byte-exact**; `.gitattributes` pins that down and those entries must stay. A key naming more than
one record is refused rather than resolved, so a unique that drops on two bases is asked for by
both — `"UNIQUE::Stormblood::Topaz Flask"` — and an item class two game classes print the name of
by its id, `"Maps::MapKey"`.

The slice has **no `(Local)` stat record**, so the local/global disambiguation in `item/resolve` is
not covered offline — it is verified against an installed bundle by hand. Adding one such record (and
Expand Down
60 changes: 48 additions & 12 deletions scripts/slice-test-bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,16 @@
# `en-items-base.index.bin`, so the pair is also what covers that index at all.
"ITEM::Goathide Gloves",
"UNIQUE::Hrimburn",
# A unique that drops on **two bases** under one name, and the second unique of one of
# them. Both halves matter: the name addresses two records, so the base is what tells them
# apart, and the Topaz Flask is what an unidentified one is read through — a bundle
# carrying only the Sapphire record leaves that base with a single candidate, which is
# taken as the name rather than asked about.
"ITEM::Topaz Flask",
"ITEM::Sapphire Flask",
"UNIQUE::Stormblood::Topaz Flask",
"UNIQUE::Stormblood::Sapphire Flask",
"UNIQUE::Vessel of Vinktar",
# A second card, because the capture that proves a card resolves at all is a real one.
"DIVINATION_CARD::The Blazing Fire",
# An essence, for the other half of that: both are traded in bulk on the in-game exchange,
Expand Down Expand Up @@ -364,8 +374,10 @@
"HeistContractNoGangCut1",
]

# "Maps" is qualified because two game classes print that name — the map itself and the
# stand-in row trade lists every map under.
ITEM_CLASSES = ["Rings", "Boots", "Gloves", "Body Armours", "Stackable Currency",
"Divination Cards", "Jewels", "Utility Flasks", "Maps", "Skill Gems",
"Divination Cards", "Jewels", "Utility Flasks", "Maps::MapKey", "Skill Gems",
"Support Gems", "Chart", "Misc Map Items", "Contracts", "Blueprints",
"Sanctum Research", "Expedition Logbooks", "Helmets", "Amulets", "Belts",
"Sceptres", "Heist Gear"]
Expand All @@ -389,12 +401,36 @@ def read_ndjson(path: Path) -> list[tuple[bytes, dict]]:


def pick(records: list[tuple[bytes, dict]], wanted: list[str], key) -> list[tuple[bytes, dict]]:
"""The wanted records, in the order listed above — the fixture's line order is ours."""
by_key = {key(r): (line, r) for line, r in records}
missing = [w for w in wanted if w not in by_key]
if missing:
"""The wanted records, in the order listed above — the fixture's line order is ours.

`key` returns every name a record answers to, because one name can address two records: a
unique is its name *and* its base, and Stormblood drops on both the Sapphire and the Topaz
Flask. A wanted key that names more than one record is refused rather than resolved —
taking either would put a record in the fixture that nobody asked for.
"""
by_key: dict[str, list[tuple[bytes, dict]]] = {}
for line, r in records:
for k in key(r):
by_key.setdefault(k, []).append((line, r))
if missing := [w for w in wanted if w not in by_key]:
sys.exit(f"not in the source bundle: {', '.join(missing)}")
return [by_key[w] for w in wanted]
if several := [w for w in wanted if len(by_key[w]) > 1]:
sys.exit("names more than one record, so qualify it with the base or the id "
f"(\"UNIQUE::Stormblood::Topaz Flask\"): {', '.join(several)}")
return [by_key[w][0] for w in wanted]


def item_keys(r: dict) -> list[str]:
"""`"{namespace}::{name}"`, plus `"::{base}"` again for a unique — see `pick`."""
key = f"{r['namespace']}::{r['name']}"
if base := r.get("unique", {}).get("base"):
return [key, f"{key}::{base}"]
return [key]


def class_keys(r: dict) -> list[str]:
"""The class name, plus `"::{id}"` — two game classes can print one name ("Maps")."""
return [r["itemClass"], f"{r['itemClass']}::{r['id']}"]


def write_ndjson(path: Path, chosen: list[tuple[bytes, dict]]) -> list[int]:
Expand Down Expand Up @@ -422,15 +458,14 @@ def main() -> int:
src, out = args.source, args.out
out.mkdir(parents=True, exist_ok=True)

stats = pick(read_ndjson(src / f"{LANG}-stats.ndjson"), STATS, lambda r: r["ref"])
stats = pick(read_ndjson(src / f"{LANG}-stats.ndjson"), STATS, lambda r: [r["ref"]])
offsets = write_ndjson(out / f"{LANG}-stats.ndjson", stats)
write_index(out / f"{LANG}-stats-ref.index.bin",
[(r["ref"], off) for (_, r), off in zip(stats, offsets)])
write_index(out / f"{LANG}-stats-matcher.index.bin",
[(m["string"], off) for (_, r), off in zip(stats, offsets) for m in r["matchers"]])

items = pick(read_ndjson(src / f"{LANG}-items.ndjson"), ITEMS,
lambda r: f"{r['namespace']}::{r['name']}")
items = pick(read_ndjson(src / f"{LANG}-items.ndjson"), ITEMS, item_keys)
offsets = write_ndjson(out / f"{LANG}-items.ndjson", items)
write_index(out / f"{LANG}-items-name.index.bin",
[(f"{r['namespace']}::{r['name']}", off) for (_, r), off in zip(items, offsets)])
Expand All @@ -440,21 +475,22 @@ def main() -> int:
[(f"UNIQUE::{r['unique']['base']}", off) for (_, r), off in zip(items, offsets)
if r["namespace"] == "UNIQUE" and r.get("unique", {}).get("base")])

uniques = pick(read_ndjson(src / f"{LANG}-unique-mods.ndjson"), UNIQUE_MODS, lambda r: r["name"])
uniques = pick(read_ndjson(src / f"{LANG}-unique-mods.ndjson"), UNIQUE_MODS,
lambda r: [r["name"]])
offsets = write_ndjson(out / f"{LANG}-unique-mods.ndjson", uniques)
write_index(out / f"{LANG}-unique-mods-name.index.bin",
[(f"UNIQUE::{r['name']}", off) for (_, r), off in zip(uniques, offsets)])

pools = pick(read_ndjson(src / f"{LANG}-mod-pools.ndjson"), MOD_POOLS,
lambda r: r["mods"][0])
lambda r: [r["mods"][0]])
offsets = write_ndjson(out / f"{LANG}-mod-pools.ndjson", pools)
# One key per wording, qualified by domain: a map and a chart share wordings and are
# separate pools, so the domain is part of what is being asked for.
write_index(out / f"{LANG}-mod-pools-ref.index.bin",
[(f"{r['domain']}::{s['ref']}", off) for (_, r), off in zip(pools, offsets)
for s in r["stats"]])

classes = pick(read_ndjson(src / "item-classes.ndjson"), ITEM_CLASSES, lambda r: r["itemClass"])
classes = pick(read_ndjson(src / "item-classes.ndjson"), ITEM_CLASSES, class_keys)
write_ndjson(out / "item-classes.ndjson", classes)

# Not the source's manifest: the fixture is not a release, and nothing may mistake it for
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.
5 changes: 5 additions & 0 deletions tests/data/bundle/en-items.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
{"art":"Art/2DItems/Armours/Gloves/Hrimsorrow.png","name":"Hrimsorrow","namespace":"UNIQUE","refName":"Hrimsorrow","unique":{"base":"Goathide Gloves"}}
{"armour":{"ev":[32,42]},"craftable":{"category":"Gloves"},"domain":1,"dropLevel":9,"h":2,"metadataId":"Metadata/Items/Armours/Gloves/GlovesDex2","name":"Goathide Gloves","namespace":"ITEM","refName":"Goathide Gloves","w":2}
{"art":"Art/2DItems/Armours/Gloves/Hrimsorrow.png","name":"Hrimburn","namespace":"UNIQUE","refName":"Hrimburn","unique":{"base":"Goathide Gloves"}}
{"craftable":{"category":"Utility Flasks"},"domain":2,"dropLevel":18,"h":2,"metadataId":"Metadata/Items/Flasks/FlaskUtility4","name":"Topaz Flask","namespace":"ITEM","refName":"Topaz Flask","w":1}
{"craftable":{"category":"Utility Flasks"},"domain":2,"dropLevel":18,"h":2,"metadataId":"Metadata/Items/Flasks/FlaskUtility3","name":"Sapphire Flask","namespace":"ITEM","refName":"Sapphire Flask","w":1}
{"art":"Art/2DItems/Flasks/StormbloodSapphire.png","name":"Stormblood","namespace":"UNIQUE","refName":"Stormblood","unique":{"base":"Topaz Flask"}}
{"art":"Art/2DItems/Flasks/StormbloodSapphire.png","name":"Stormblood","namespace":"UNIQUE","refName":"Stormblood","unique":{"base":"Sapphire Flask"}}
{"art":"Art/2DItems/Flasks/VinktarFlask.png","name":"Vessel of Vinktar","namespace":"UNIQUE","refName":"Vessel of Vinktar","unique":{"base":"Topaz Flask"}}
{"craftable":{"category":"Divination Cards"},"domain":43,"dropLevel":62,"exchange":true,"h":1,"metadataId":"Metadata/Items/DivinationCards/DivinationCardTheBlazingFire","name":"The Blazing Fire","namespace":"DIVINATION_CARD","refName":"The Blazing Fire","w":1}
{"craftable":{"category":"Stackable Currency"},"domain":43,"dropLevel":26,"exchange":true,"h":1,"metadataId":"Metadata/Items/Currency/CurrencyEssenceHatred3","name":"Weeping Essence of Hatred","namespace":"ITEM","refName":"Weeping Essence of Hatred","w":1}
{"craftable":{"category":"Support Gems"},"domain":43,"dropLevel":38,"h":1,"metadataId":"Metadata/Items/Gems/SupportGemAdditionalLevel","name":"Empower Support","namespace":"GEM","refName":"Empower Support","w":1}
Expand Down
30 changes: 30 additions & 0 deletions tests/data/items/unique-flask-stormblood-topaz.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Item Class: Utility Flasks
Rarity: Unique
Stormblood
Topaz Flask
--------
Lasts 4 (augmented) Seconds
Consumes 20 of 50 Charges on use
Currently has 0 Charges
+5% to maximum Lightning Resistance
(Maximum Resistances cannot be raised above 90%)
+40% to Lightning Resistance
--------
Requirements:
Level: 36
--------
Item Level: 85
--------
{ Unique Modifier — Critical }
31(20-40)% chance to gain a Flask Charge when you deal a Critical Strike
{ Unique Modifier — Elemental, Lightning, Critical, Ailment }
All Damage from Critical Strikes can apply Lightning Ailments during effect
(Lightning Ailments are Shocked and Sapped. Critical Strikes inherently have 100% chance to inflict Shock)
{ Unique Modifier }
50% reduced Duration
--------
On that fateful day, beset by tempest, Captain Brinehook Vex
came face to face with the blustering Karui god of thunder,
Valako himself. A wager was had, and a wager was won.
--------
Right click to drink. Can only hold charges while in belt. Refills as you kill monsters.
49 changes: 48 additions & 1 deletion tests/item_pricing_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,52 @@ TEST_CASE("an unidentified unique on a base with several is a question, not a se
}
}

TEST_CASE("a unique that drops on two bases is two candidates, and two records") {
auto gd = fixture();
// Stormblood drops on both the Sapphire and the Topaz Flask under one name. The bundle
// used to carry one record per name, so the Topaz Flask answered with Vessel of Vinktar
// alone — one candidate, which is taken as the name rather than asked about, and an
// unidentified Topaz Flask was priced as somebody else's unique.
const Item it = resolved(*gd, R"(Item Class: Utility Flasks
Rarity: Unique
Topaz Flask
--------
Lasts 4.00 Seconds
Consumes 20 of 50 Charges on use
Currently has 0 Charges
--------
Item Level: 85
--------
Unidentified
)");
REQUIRE(it.unique_candidates.size() == 2);
CHECK(it.needs_unique_choice());
CHECK(build_plan(*gd, it, derive(gd.get(), it)).name.empty());

SUBCASE("the identified one resolves to the record for the base it is on") {
// Both records answer to "Stormblood", so the base is what tells them apart — and the
// search is sent for this flask rather than for the cold one of the same name.
const Item id = resolved(*gd, capture("unique-flask-stormblood-topaz.txt"));
REQUIRE(id.unique_entry != nullptr);
CHECK(id.unique_entry->unique_base == "Topaz Flask");
const SearchPlan p = build_plan(*gd, id, derive(gd.get(), id));
CHECK(p.name == "Stormblood");
CHECK(p.type == "Topaz Flask");
}

SUBCASE("and the other base's copy is the other record") {
const Item other = resolved(*gd, R"(Item Class: Utility Flasks
Rarity: Unique
Stormblood
Sapphire Flask
--------
Item Level: 85
)");
REQUIRE(other.unique_entry != nullptr);
CHECK(other.unique_entry->unique_base == "Sapphire Flask");
}
}

TEST_CASE("an identified unique is not read off its base") {
auto gd = fixture();
// The candidate list is only ever about the gap an unidentified item leaves: an identified
Expand Down Expand Up @@ -1113,7 +1159,8 @@ TEST_CASE("a magic item's base is found under its affixes") {
CHECK(strip_magic_affixes(*gd, "Surgeon's Two-Stone Ring of the Cheetah", "Rings") ==
"Two-Stone Ring");
// Nothing in the bundle matches, and inventing a base is worse than admitting it.
CHECK(strip_magic_affixes(*gd, "Surgeon's Sapphire Flask of Heat", "Utility Flasks").empty());
CHECK(strip_magic_affixes(*gd, "Surgeon's Quicksilver Flask of Heat", "Utility Flasks")
.empty());

const Item it = resolved(*gd, R"(Item Class: Rings
Rarity: Magic
Expand Down