Skip to content

Commit e3f5de7

Browse files
Generate Foulborn uniques programmatically using ModFoulbornMap
Instead of duplicating every unique as separate entries in a new Data/Uniques/Foulborn/ directory (+5768 lines), generate Foulborn items in Generated.lua using the existing ModFoulbornMap data. Each Foulborn unique becomes a separate generated item where variants represent different possible Foulborn mutations. This follows the same pattern used for Paradoxica, Watcher's Eye, and other generated uniques. - Load data.foulbornMap in Data.lua before Generated.lua runs - Parse each original unique's current variant (base, requires, implicits, mods) and combine with Foulborn mutation mods - Generates 244 Foulborn items from the existing map data - Existing foulborn detection (Item.lua) and counting (CalcSetup.lua) work without changes
1 parent a51cdca commit e3f5de7

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

spec/System/TestFoulborn_spec.lua

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
describe("Foulborn generation", function()
2+
it("generates Foulborn items from foulbornMap", function()
3+
local foulbornCount = 0
4+
for _, raw in ipairs(data.uniques.generated) do
5+
if raw:match("^Foulborn ") then
6+
foulbornCount = foulbornCount + 1
7+
end
8+
end
9+
assert.is_true(foulbornCount > 200, "Expected 200+ Foulborn items, got " .. foulbornCount)
10+
print(" Foulborn items generated: " .. foulbornCount)
11+
end)
12+
13+
it("generates valid Foulborn Alpha's Howl", function()
14+
local raw
15+
for _, r in ipairs(data.uniques.generated) do
16+
if r:match("^Foulborn Alpha") then raw = r; break end
17+
end
18+
assert.is_truthy(raw, "Foulborn Alpha's Howl not found")
19+
assert.truthy(raw:match("Sinner Tricorne"), "Missing base type")
20+
assert.truthy(raw:match("Variant:"), "Missing variant declarations")
21+
assert.truthy(raw:match("{variant:1}"), "Missing variant-tagged mod")
22+
assert.truthy(raw:match("Requires Level"), "Missing requires line")
23+
end)
24+
25+
it("parses Foulborn item with foulborn flag", function()
26+
newBuild()
27+
local raw
28+
for _, r in ipairs(data.uniques.generated) do
29+
if r:match("^Foulborn Alpha") then raw = r; break end
30+
end
31+
local item = new("Item", raw)
32+
assert.is_true(item.foulborn, "foulborn flag not set")
33+
assert.are.equals("Sinner Tricorne", item.baseName)
34+
assert.are.equals("Foulborn Alpha's Howl", item.title)
35+
assert.is_true(#item.variantList > 0, "No variants parsed")
36+
print(" Variants: " .. #item.variantList)
37+
end)
38+
39+
it("generates Foulborn item with existing variants correctly", function()
40+
local raw
41+
for _, r in ipairs(data.uniques.generated) do
42+
if r:match("^Foulborn The Formless Flame") then raw = r; break end
43+
end
44+
assert.is_truthy(raw, "Foulborn The Formless Flame not found")
45+
-- Should use Current variant base (Royal Burgonet, not Siege Helmet)
46+
assert.truthy(raw:match("Royal Burgonet"), "Wrong base type - should be Royal Burgonet (Current variant)")
47+
assert.is_falsy(raw:match("Siege Helmet"), "Should not contain old variant base")
48+
end)
49+
50+
it("generates Foulborn item with implicits", function()
51+
local raw
52+
for _, r in ipairs(data.uniques.generated) do
53+
if r:match("^Foulborn Call of the Brotherhood") then raw = r; break end
54+
end
55+
assert.is_truthy(raw, "Foulborn Call of the Brotherhood not found")
56+
assert.truthy(raw:match("Implicits: 1"), "Missing implicits declaration")
57+
assert.truthy(raw:match("Cold and Lightning Resistances"), "Missing implicit mod")
58+
end)
59+
60+
it("parses Foulborn ring with implicit", function()
61+
newBuild()
62+
local raw
63+
for _, r in ipairs(data.uniques.generated) do
64+
if r:match("^Foulborn Call of the Brotherhood") then raw = r; break end
65+
end
66+
assert.is_truthy(raw, "Foulborn Call of the Brotherhood not found")
67+
local item = new("Item", raw)
68+
assert.is_true(item.foulborn)
69+
assert.are.equals("Two-Stone Ring", item.baseName)
70+
print(" Ring base: " .. tostring(item.baseName))
71+
end)
72+
73+
it("no Foulborn item has empty base type", function()
74+
for _, raw in ipairs(data.uniques.generated) do
75+
if raw:match("^Foulborn ") then
76+
local name = raw:match("^(.-)\n")
77+
local base = raw:match("^.-\n(.-)\n")
78+
assert.is_truthy(base and base ~= "", name .. " has empty base type")
79+
end
80+
end
81+
end)
82+
end)

src/Data/Uniques/Special/Generated.lua

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,3 +1010,190 @@ table.insert(replicaDragonfangsFlight,
10101010
)
10111011

10121012
table.insert(data.uniques.generated, table.concat(replicaDragonfangsFlight, "\n"))
1013+
1014+
-- Foulborn Uniques Generation
1015+
-- Each Foulborn unique is a separate generated item with variants for each possible Foulborn mutation mod.
1016+
-- This uses data.foulbornMap (loaded from ModFoulbornMap.lua) which maps unique name -> list of mutation mod descriptions.
1017+
if data.foulbornMap then
1018+
-- Build lookup: unique name -> raw item text from all loaded unique types
1019+
local uniqueLookup = {}
1020+
for _, itemType in ipairs({"axe","bow","claw","dagger","fishing","mace","staff","sword","wand",
1021+
"helmet","body","gloves","boots","shield","quiver","amulet","ring","belt","jewel",
1022+
"flask","tincture","graft"}) do
1023+
if data.uniques[itemType] then
1024+
for _, raw in ipairs(data.uniques[itemType]) do
1025+
local name = raw:match("^%s*(.-)%s*\n")
1026+
if name and not uniqueLookup[name] then
1027+
uniqueLookup[name] = raw
1028+
end
1029+
end
1030+
end
1031+
end
1032+
if data.uniques.race then
1033+
for _, raw in ipairs(data.uniques.race) do
1034+
local name = raw:match("^%s*(.-)%s*\n")
1035+
if name and not uniqueLookup[name] then
1036+
uniqueLookup[name] = raw
1037+
end
1038+
end
1039+
end
1040+
1041+
-- Sort names for deterministic output
1042+
local sortedFoulbornNames = {}
1043+
for name in pairs(data.foulbornMap) do
1044+
table.insert(sortedFoulbornNames, name)
1045+
end
1046+
table.sort(sortedFoulbornNames)
1047+
1048+
for _, uniqueName in ipairs(sortedFoulbornNames) do
1049+
local fbMods = data.foulbornMap[uniqueName]
1050+
local raw = uniqueLookup[uniqueName]
1051+
if raw then
1052+
-- Parse raw text into trimmed lines
1053+
local lines = {}
1054+
for line in (raw .. "\n"):gmatch("(.-)\n") do
1055+
line = line:match("^%s*(.-)%s*$")
1056+
table.insert(lines, line)
1057+
end
1058+
while #lines > 0 and lines[1] == "" do table.remove(lines, 1) end
1059+
while #lines > 0 and lines[#lines] == "" do table.remove(lines, #lines) end
1060+
1061+
-- Count Variant: declarations to determine "current" variant (last = highest index)
1062+
local variantCount = 0
1063+
for _, line in ipairs(lines) do
1064+
if line:match("^Variant:") then
1065+
variantCount = variantCount + 1
1066+
end
1067+
end
1068+
local curVar = variantCount
1069+
1070+
-- Check if a line's {variant:X,Y} tag includes the current variant
1071+
local function applies(line)
1072+
local tag = line:match("^{variant:([%d,]+)}")
1073+
if not tag then return true end
1074+
if curVar == 0 then return true end
1075+
for v in tag:gmatch("%d+") do
1076+
if tonumber(v) == curVar then return true end
1077+
end
1078+
return false
1079+
end
1080+
1081+
-- Strip {variant:...} tag from line start (preserves {tags:...} etc)
1082+
local function stripVar(line)
1083+
return (line:gsub("^{variant:[%d,]+}", ""))
1084+
end
1085+
1086+
-- Metadata lines to skip during generation
1087+
local function isMetadata(line)
1088+
return line:match("^Variant:") or line:match("^Selected Variant:")
1089+
or line:match("^Selected Alt Variant") or line:match("^Has Alt Variant")
1090+
or line:match("^League:") or line:match("^Source:")
1091+
or line:match("^Upgrade:") or line:match("^Limited to:")
1092+
or line:match("^Item Level:") or line:match("^Radius:")
1093+
or line:match("^Shaper Item") or line:match("^Elder Item")
1094+
or line:match("^Crusader Item") or line:match("^Redeemer Item")
1095+
or line:match("^Hunter Item") or line:match("^Warlord Item")
1096+
or line:match("^Corrupted$") or line:match("^Item Class:")
1097+
or line:match("^Rarity:") or line:match("^Has no Sockets")
1098+
end
1099+
1100+
-- Parse sections from the original unique
1101+
local baseName = nil
1102+
local requiresLine = nil
1103+
local implicitsCount = nil
1104+
local implicitLines = {}
1105+
local explicitLines = {}
1106+
1107+
local i = 1
1108+
-- Skip leading Item Class: / Rarity: lines (generated format)
1109+
while i <= #lines and (lines[i]:match("^Item Class:") or lines[i]:match("^Rarity:")) do
1110+
i = i + 1
1111+
end
1112+
-- lines[i] is the name; skip it
1113+
i = i + 1
1114+
1115+
-- Find base type (line after name, possibly multiple variant-tagged lines)
1116+
if i <= #lines and lines[i]:match("^{variant:") and not lines[i]:match("^Variant:") then
1117+
-- Variant-tagged base type(s)
1118+
while i <= #lines and lines[i]:match("^{variant:") and not lines[i]:match("^Variant:") do
1119+
if applies(lines[i]) then
1120+
baseName = stripVar(lines[i])
1121+
end
1122+
i = i + 1
1123+
end
1124+
elseif i <= #lines then
1125+
-- Plain base type
1126+
baseName = lines[i]
1127+
i = i + 1
1128+
end
1129+
1130+
-- Process remaining lines: metadata, requires, implicits, mods
1131+
local implicitsRemaining = 0
1132+
local inImplicits = false
1133+
while i <= #lines do
1134+
local line = lines[i]
1135+
if isMetadata(line) then
1136+
-- Skip
1137+
elseif line:match("^Requires Level") or line:match("^LevelReq:") then
1138+
if applies(line) then
1139+
requiresLine = stripVar(line)
1140+
end
1141+
elseif line:match("^Implicits: (%d+)") then
1142+
implicitsCount = tonumber(line:match("^Implicits: (%d+)"))
1143+
implicitsRemaining = implicitsCount
1144+
inImplicits = true
1145+
elseif inImplicits and implicitsRemaining > 0 then
1146+
if applies(line) then
1147+
table.insert(implicitLines, stripVar(line))
1148+
end
1149+
implicitsRemaining = implicitsRemaining - 1
1150+
if implicitsRemaining == 0 then
1151+
inImplicits = false
1152+
end
1153+
else
1154+
inImplicits = false
1155+
if applies(line) and line ~= "" then
1156+
table.insert(explicitLines, stripVar(line))
1157+
end
1158+
end
1159+
i = i + 1
1160+
end
1161+
1162+
-- Build the Foulborn item
1163+
if baseName then
1164+
local foulborn = {}
1165+
table.insert(foulborn, "Foulborn " .. uniqueName)
1166+
table.insert(foulborn, baseName)
1167+
1168+
-- Variant declarations (one per Foulborn mutation mod)
1169+
for _, modLine in ipairs(fbMods) do
1170+
table.insert(foulborn, "Variant: " .. modLine)
1171+
end
1172+
1173+
if requiresLine then
1174+
table.insert(foulborn, requiresLine)
1175+
end
1176+
1177+
-- Implicits (only if original specified them)
1178+
if implicitsCount ~= nil then
1179+
table.insert(foulborn, "Implicits: " .. #implicitLines)
1180+
for _, impl in ipairs(implicitLines) do
1181+
table.insert(foulborn, impl)
1182+
end
1183+
end
1184+
1185+
-- Base mods from original (current variant, tags stripped)
1186+
for _, modLine in ipairs(explicitLines) do
1187+
table.insert(foulborn, modLine)
1188+
end
1189+
1190+
-- Foulborn mutation mods (variant-tagged)
1191+
for idx, modLine in ipairs(fbMods) do
1192+
table.insert(foulborn, "{variant:" .. idx .. "}" .. modLine)
1193+
end
1194+
1195+
table.insert(data.uniques.generated, table.concat(foulborn, "\n"))
1196+
end
1197+
end
1198+
end
1199+
end

src/Modules/Data.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,7 @@ for _, modId in ipairs(sortedMods) do
11351135
mod = unsortedMods[modId],
11361136
})
11371137
end
1138+
data.foulbornMap = LoadModule("Data/ModFoulbornMap")
11381139
LoadModule("Data/Uniques/Special/Generated")
11391140
LoadModule("Data/Uniques/Special/New")
11401141

0 commit comments

Comments
 (0)