public LocationData FindItems(GameData game) { SortedDictionary <int, List <EntityId> > usedItemLots = FindItemLots(game); PARAM shops = game.Param("ShopLineupParam"); PARAM itemLots = game.Param("ItemLotParam"); PARAM materials = game.Param("EquipMtrlSetParam"); LocationData data = new LocationData(); // Run through all item lots in the game in order, extract all the data. LocationKey prevLocation = null; foreach (KeyValuePair <int, List <EntityId> > entry in usedItemLots) { int itemLot = entry.Key; if (prevLocation != null) { if (!crowLots.Contains(prevLocation.ID)) { prevLocation.MaxSlots = itemLot - prevLocation.ID - 1; if (prevLocation.MaxSlots < 1) { Warn($"Overlapping slots at {itemLot}"); } } prevLocation = null; } List <EntityId> entities = entry.Value; string locs = String.Join(", ", entities.Select(e => game.EntityName(e) + $" {e}")); if (itemLots[itemLot] == null) { string text = game.LotName(itemLot); // These are fine - no-ops to game // Console.WriteLine($"MISSING connected item lot!! {itemLot}: {text} @ {locs}"); continue; } LocationKey baseLocation = null; while (itemLots[itemLot] != null) { bool isBase = itemLot == entry.Key; string text = game.LotName(itemLot); PARAM.Row row = itemLots[itemLot]; int eventFlag = (int)row["getItemFlagId"].Value; int totalPoints = 0; for (int i = 1; i <= 8; i++) { totalPoints += (short)row[$"LotItemBasePoint0{i}"].Value; } for (int i = 1; i <= 8; i++) { int id = (int)row[$"ItemLotId{i}"].Value; if (id != 0) { uint type = (uint)row[$"LotItemCategory0{i}"].Value; int points = (short)row[$"LotItemBasePoint0{i}"].Value; int quantity = (byte)row[$"LotItemNum{i}"].Value; ItemKey item = new ItemKey(LocationData.LotTypes[type], id); string itemText = $"{itemLot}[{locs}]"; // Check out script about CC, btw List <string> info = new List <string>(); if (quantity > 1) { info.Add($"{quantity}"); } if (points != totalPoints) { info.Add($"{100.0 * points / totalPoints}%"); } if (info.Count() > 0) { itemText += $" ({string.Join(",", info)})"; } if (quantity <= 0 && logUnused) { Console.WriteLine($"There is 0! of {itemText}"); } ItemScope scope; if (eventFlag != -1) { if (equivalentEvents.ContainsKey(eventFlag)) { eventFlag = equivalentEvents[eventFlag]; } scope = new ItemScope(ScopeType.EVENT, eventFlag); } else { // One time drops that directly award, that aren't covered by event flags. Mostly crystal lizards. if (entities.Count() == 1 && entityItemLots.ContainsKey(entities[0].EventEntityID) && entityItemLots[entities[0].EventEntityID] == entry.Key) { scope = new ItemScope(ScopeType.ENTITY, entities[0].EventEntityID); } // Non-respawning enemies with drops which can be missed. These are reused between different entities, so can drop multiple times. else if (entities.All(e => nonRespawningEntities.Contains(e.EventEntityID))) { scope = new ItemScope(ScopeType.ENTITY, entities.Select(e => e.EventEntityID).Min()); } else { int model = entities.Select(e => e.GetModelID()).Min(); if (model == -1) { if (logUnused) { // Ideally this should not be randomized, but can't check here Console.WriteLine($"Infinite item {itemLot} with no event flags, entity flags, or models: {itemText}"); } continue; } scope = new ItemScope(ScopeType.MODEL, model); } } LocationKey location = new LocationKey(LocationType.LOT, itemLot, itemText, entities, quantity, baseLocation); data.AddLocation(item, scope, location); if (baseLocation == null) { baseLocation = location; } } } itemLot++; if (crowLots.Contains(itemLot - 1)) { break; } } prevLocation = baseLocation; } foreach (PARAM.Row row in shops.Rows) { int shopID = (int)row.ID; string shopName = shopSplits[GetShopType(shopID)]; if (shopName == null) { continue; } int qwc = (int)row["qwcID"].Value; int type = (byte)row["equipType"].Value; int id = (int)row["EquipId"].Value; int quantity = (short)row["sellQuantity"].Value; int eventFlag = (int)row["EventFlag"].Value; int material = (int)row["mtrlId"].Value; string quantityText = quantity == -1 ? "" : $" ({quantity})"; string qwcText = qwc == -1 ? "" : $" {game.QwcName(qwc)}"; ItemKey item = new ItemKey((ItemType)type, id); string text = $"{shopName}{qwcText}{quantityText}"; text = $"{shopID}[{text}]"; LocationKey location = new LocationKey(LocationType.SHOP, shopID, text, new List <EntityId>(), quantity, null); ItemScope scope; if (eventFlag != -1) { if (equivalentEvents.ContainsKey(eventFlag)) { eventFlag = equivalentEvents[eventFlag]; } if (quantity <= 0) { Warn($"No quantity for event flag shop entry {text}"); } ScopeType scopeType = ScopeType.EVENT; if (restrictiveQwcs.Contains(qwc)) { // In DS3, if item becomes unavailable at some point, that is because it returns in infinite form scopeType = ScopeType.SHOP_INFINITE_EVENT; } scope = new ItemScope(scopeType, eventFlag); } else if (material != -1) { int materialItem = (int)materials[material]["MaterialId01"].Value; scope = new ItemScope(ScopeType.MATERIAL, materialItem); } else { scope = new ItemScope(ScopeType.SHOP_INFINITE, -1); } data.AddLocation(item, scope, location); } // Merge infinite and finite shops. Mostly done via heuristic (when event and infinite both exist) ItemScope infiniteKey = new ItemScope(ScopeType.SHOP_INFINITE, -1); foreach (ItemLocations locations in data.Data.Values) { foreach (ItemLocation restrict in locations.Locations.Values.Where(loc => loc.Scope.Type == ScopeType.SHOP_INFINITE_EVENT).ToList()) { if (locations.Locations.ContainsKey(infiniteKey)) { // Combine infinite shops into event ItemLocation infinite = locations.Locations[infiniteKey]; restrict.Keys.AddRange(infinite.Keys); locations.Locations.Remove(infiniteKey); } else { Warn($"No 1:1 match between event shops and infinite shops for {restrict}"); // No infinite shops, turn this into a regular event shop. (Doesn't happen in base DS3) ItemLocation eventLoc = new ItemLocation(new ItemScope(ScopeType.EVENT, restrict.Scope.ID)); eventLoc.Keys.AddRange(restrict.Keys); locations.Locations[eventLoc.Scope] = eventLoc; locations.Locations.Remove(restrict.Scope); } } } // Now calculate all location scopes - distinct item sources. // This is the main key for the annotations file, so scopes can be marked as missable or not, assigned logical areas, etc. // It is also used as the target for randomizing an item, because if several locations are equivalent, all should contain the same item. List <ScopeType> uniqueTypes = new List <ScopeType> { ScopeType.EVENT, ScopeType.ENTITY, ScopeType.MATERIAL }; foreach (KeyValuePair <ItemKey, ItemLocations> entry in data.Data) { ItemKey item = entry.Key; int unique = 0; foreach (KeyValuePair <ItemScope, ItemLocation> entry2 in entry.Value.Locations) { ItemScope scope = entry2.Key; ItemLocation loc = entry2.Value; int id = uniqueTypes.Contains(scope.Type) ? scope.ID : -1; unique = unique == -1 ? -1 : (id == -1 ? -1 : unique + 1); SortedSet <int> shopIds = new SortedSet <int>(loc.Keys.Where(k => k.Type == LocationType.SHOP).Select(k => GetShopType(k.ID))); SortedSet <int> shopQwcs = new SortedSet <int>(loc.Keys.Where(k => k.Type == LocationType.SHOP).Select(k => ((int)shops[k.ID]["qwcID"].Value, (int)shops[k.ID]["EventFlag"].Value)) .Where(e => e.Item1 != -1 && (!restrictiveQwcs.Contains(e.Item1) || e.Item2 == -1)) .Select(e => e.Item1)); SortedSet <int> allShop = new SortedSet <int>(shopIds.Union(shopQwcs)); if (shopIds.Count() + shopQwcs.Count() != allShop.Count()) { Warn($"Overlapping qwc/shop ids for location {loc}"); } SortedSet <int> modelBase = scope.Type == ScopeType.MODEL ? new SortedSet <int>(loc.Keys.Select(k => k.BaseID)) : new SortedSet <int>(); bool onlyShops = loc.Keys.All(k => k.Type == LocationType.SHOP) && allShop.Count() > 0; LocationScope locationScope = new LocationScope(scope.Type, id, allShop, modelBase, onlyShops); data.AddLocationScope(item, scope, locationScope); } entry.Value.Unique = unique > 0 && item.Type != ItemType.ARMOR; } if (logUnused) { foreach (KeyValuePair <ItemKey, string> entry in game.Names()) { ItemKey item = entry.Key; if (item.Type == 0) { item = new ItemKey(item.Type, item.ID - (item.ID % 10000)); } if (!data.Data.ContainsKey(item)) { // Mostly pulls up old DS1 items and gestures. Console.WriteLine($"Unused item {item.Type}-{entry.Key.ID}: {entry.Value}"); } } } return(data); }
private SortedDictionary <int, List <EntityId> > FindItemLots(GameData game) { PARAM itemLots = game.Param("ItemLotParam"); PARAM npcs = game.Param("NpcParam"); SortedDictionary <int, List <EntityId> > usedItemLots = new SortedDictionary <int, List <EntityId> >(); // TODO: Merge in Sekiro map scraper, which is a fair bit more sophisticated. Dictionary <EntityId, EntityId> objects = new Dictionary <EntityId, EntityId>(); Dictionary <int, List <EntityId> > usedNpcs = new Dictionary <int, List <EntityId> >(); Dictionary <int, List <EntityId> > usedEntities = new Dictionary <int, List <EntityId> >(); foreach (KeyValuePair <string, MSB3> entry in game.Maps) { string location = game.Locations[entry.Key]; foreach (MSB3.Part.Object part in entry.Value.Parts.Objects) { EntityId id = new EntityId(location, part.Name, part.EventEntityID); objects[id] = id; if (part.EventEntityID > 0) { AddMulti(usedEntities, part.EventEntityID, id); } } foreach (MSB3.Part.Enemy enemy in entry.Value.Parts.Enemies) { EntityId id = new EntityId(location, enemy.Name, enemy.EventEntityID, enemy.NPCParamID, enemy.CharaInitID); objects[id] = id; if (enemy.NPCParamID > 0) { AddMulti(usedNpcs, enemy.NPCParamID, id); } if (enemy.EventEntityID > 0) { AddMulti(usedEntities, enemy.EventEntityID, id); } } } foreach (KeyValuePair <string, MSB3> entry in game.Maps) { string location = game.Locations[entry.Key]; foreach (MSB3.Event.Treasure treasure in entry.Value.Events.Treasures) { if (treasure.PartName2 != null) { EntityId id = new EntityId(location, treasure.PartName2); if (!objects.ContainsKey(id)) { if (logUnused) { Console.WriteLine($"Missing entity for treasure {treasure.Name} with entity {treasure.PartName2} and lot {treasure.ItemLot1}"); } continue; } AddMulti(usedItemLots, treasure.ItemLot1, objects[id]); } } } foreach (PARAM.Row row in npcs.Rows) { int npcID = (int)row.ID; PARAM.Cell cell = row["ItemLotId1"]; if (cell == null || (int)cell.Value == -1) { continue; } int itemLot = (int)cell.Value; if (itemLots[itemLot] == null) { if (logUnused) { Console.WriteLine($"Invalid NPC lot item for {npcID} with lot {itemLot}"); } continue; } if (!usedNpcs.ContainsKey(npcID)) { if (logUnused) { Console.WriteLine($"Unused NPC: {npcID}"); } continue; } AddMulti(usedItemLots, itemLot, usedNpcs[npcID]); } foreach (KeyValuePair <int, int> entry in entityItemLots) { int entityId = entry.Key; List <int> itemLot = new List <int> { entry.Value }; if (additionalEntityItemLots.ContainsKey(entityId)) { itemLot.AddRange(additionalEntityItemLots[entityId]); } if (!usedEntities.ContainsKey(entityId)) { Warn($"Missing NPC {entityId} with item lot {String.Join(", ", itemLot)}"); continue; } List <EntityId> id = usedEntities[entityId]; foreach (int lot in itemLot) { if (logUnused && (int)itemLots[lot]["getItemFlagId"].Value == -1 && id[0].GetModelID() != 2150) { Warn($"Eventless entity drop, not crystal lizard, for {String.Join(", ", id)} item lot {lot}"); } AddMulti(usedItemLots, lot, id); } } foreach (int itemLot in Enumerable.Concat(talkLots, crowLots)) { if ((int)itemLots[itemLot]["getItemFlagId"].Value == -1) { Warn($"No event id attached to talk event {itemLot}"); } AddMulti(usedItemLots, itemLot, new EntityId("", "from talk")); } if (logUnused) { int lastLot = 0; foreach (PARAM.Row lot in itemLots.Rows) { int itemLot = (int)lot.ID; if (itemLot == lastLot + 1) { // Don't require groups of item lots to be connected, only the base lot lastLot = itemLot; continue; } if (!usedItemLots.ContainsKey(itemLot)) { Console.WriteLine($"Unconnected item lot {itemLot}: {game.LotName(itemLot)}"); } lastLot = itemLot; } } return(usedItemLots); }
private ItemLocs FindItemLocs(GameData game) { PARAM itemLots = game.Param("ItemLotParam"); PARAM npcs = game.Param("NpcParam"); ItemLocs ret = new ItemLocs { usedItemLots = new SortedDictionary <int, List <EntityId> >(), usedBaseShops = new SortedDictionary <int, List <EntityId> >(), baseLotsToCreate = new Dictionary <int, int>(), // TODO: See if we can have a reasaonable processing step newEntityLots = new Dictionary <int, int>(), }; Dictionary <EntityId, EntityId> objects = new Dictionary <EntityId, EntityId>(); Dictionary <int, List <EntityId> > usedNpcs = new Dictionary <int, List <EntityId> >(); Dictionary <int, List <EntityId> > usedEntities = new Dictionary <int, List <EntityId> >(); Dictionary <int, List <EntityId> > usedEsds = new Dictionary <int, List <EntityId> >(); // Map from item lot to ESD id, hackily extracted from ESD. Dictionary <int, HashSet <int> > talkItemLocations = new Dictionary <int, HashSet <int> >(); // This is a bit intensive. Ideally just dump this somewhere else HashSet <int> debugEsd = new HashSet <int>(); IEnumerable <ESD.Condition> GetCommands(List <ESD.Condition> condList) => Enumerable.Concat(condList, condList.SelectMany(cond => GetCommands(cond.Subconditions))); foreach (KeyValuePair <string, Dictionary <string, ESD> > entry in game.Talk) { string location = game.Locations.ContainsKey(entry.Key) ? game.Locations[entry.Key] : ""; // 62210 foreach (KeyValuePair <string, ESD> esdEntry in entry.Value) { ESD esd = esdEntry.Value; int esdId = int.Parse(esdEntry.Key.Substring(1)); foreach ((int, int, ESD.State)stateDesc in esd.StateGroups.SelectMany(stateGroup => stateGroup.Value.Select(state => (stateGroup.Key, state.Key, state.Value)))) { (int groupId, int id, ESD.State state) = stateDesc; foreach (ESD.CommandCall cmd in new[] { state.EntryCommands, state.WhileCommands, state.ExitCommands, GetCommands(state.Conditions).SelectMany(c => c.PassCommands) }.SelectMany(c => c)) { foreach (byte[] arg in cmd.Arguments) { if (arg.Length == 6 && arg[0] == 0x82 && arg[5] == 0xA1) { int opt = BitConverter.ToInt32(arg, 1); if (opt >= 60000 && opt <= 69900 && itemLots[opt] != null) { AddMulti(talkItemLocations, opt, esdId); } } } } } } } bool logEntities = false; foreach (KeyValuePair <string, MSBS> entry in game.Smaps) { string location = game.Locations[entry.Key]; MSBS msb = entry.Value; Dictionary <string, int> partEsds = new Dictionary <string, int>(); List <MSBS.Part.Enemy> enemies = msb.Parts.Enemies; // TODO: Update SoulsFormat and migrate to new names foreach (MSBS.Event.Talk ev in entry.Value.Events.Talks) { for (int i = 0; i < 2; i++) { string part = ev.EnemyNames[i]; int esdId = ev.TalkIDs[i]; if (esdId < 0 || part == null) { continue; } partEsds[part] = esdId; } } foreach (MSBS.Entry obj in entry.Value.Parts.GetEntries()) { MSBS.Part part = obj as MSBS.Part; if (part == null) { continue; } EntityId id; int esdId = 0; List <int> groupIDs = part.EntityGroupIDs.Where(groupID => groupID > 0).ToList(); if (part is MSBS.Part.Enemy enemy) { esdId = partEsds.ContainsKey(enemy.Name) ? partEsds[enemy.Name] : -1; id = new EntityId(location, enemy.Name, enemy.EntityID, enemy.NPCParamID, enemy.CharaInitID, groupIDs); } else if (part is MSBS.Part.Object || logEntities) { id = new EntityId(location, part.Name, part.EntityID, GroupIds: groupIDs); } else { continue; } objects[id] = id; if (id.EventEntityID > 0) { AddMulti(usedEntities, id.EventEntityID, id); } foreach (int groupID in groupIDs) { AddMulti(usedEntities, groupID, id); } if (id.NPCParamID > 0) { AddMulti(usedNpcs, id.NPCParamID, id); } if (esdId > 0) { if (debugEsd.Contains(esdId)) { Console.WriteLine($"ESD {esdId} belongs to {game.EntityName(id, true)} in {location}, entity id {id.EventEntityID}"); } AddMulti(usedEsds, esdId, id); } } } if (logEntities) { foreach (KeyValuePair <int, List <EntityId> > entry in usedEntities.OrderBy(e => e.Key)) { Console.WriteLine($"{entry.Key}: {string.Join(", ", entry.Value.Select(e => $"{game.EntityName(e, true)} in {e.MapName}"))}"); } } List <EntityId> unusedEsd = new List <EntityId> { new EntityId("", "Unknown Dialogue") }; foreach (KeyValuePair <int, HashSet <int> > entry in talkItemLocations) { List <EntityId> talkIds; if (addUnused) { talkIds = entry.Value.SelectMany(esd => usedEsds.ContainsKey(esd) ? usedEsds[esd] : unusedEsd).ToList(); } else { talkIds = entry.Value.Where(esd => usedEsds.ContainsKey(esd)).SelectMany(esd => usedEsds[esd]).ToList(); if (talkIds.Count == 0) { continue; } } AddMulti(ret.usedItemLots, entry.Key, talkIds); } foreach (KeyValuePair <int, int> entry in shopEsds) { if (!usedEsds.ContainsKey(entry.Value) && !addUnused) { continue; } AddMulti(ret.usedBaseShops, entry.Key, usedEsds.ContainsKey(entry.Value) ? usedEsds[entry.Value] : unusedEsd); } foreach (KeyValuePair <string, MSBS> entry in game.Smaps) { string location = game.Locations[entry.Key]; foreach (MSBS.Event.Treasure treasure in entry.Value.Events.Treasures) { if (treasure.TreasurePartName != null) { EntityId id = new EntityId(location, treasure.TreasurePartName); if (!objects.ContainsKey(id)) { if (logUnused) { Console.WriteLine($"Missing entity for treasure {treasure.Name} with entity {treasure.TreasurePartName} and lot {treasure.ItemLotID}"); } continue; } AddMulti(ret.usedItemLots, treasure.ItemLotID, objects[id]); } } } foreach (PARAM.Row row in npcs.Rows) { int npcID = (int)row.ID; PARAM.Cell cell = row["ItemLotId1"]; if (cell == null || (int)cell.Value == -1) { continue; } int itemLot = (int)cell.Value; if (itemLots[itemLot] == null) { if (logUnused) { Console.WriteLine($"Invalid NPC lot item for {npcID} with lot {itemLot}"); } continue; } if (!usedNpcs.ContainsKey(npcID)) { if (logUnused) { Console.WriteLine($"Unused NPC: {npcID}"); } if (addUnused) { AddMulti(ret.usedItemLots, itemLot, new EntityId("", "Unused NPC")); } continue; } AddMulti(ret.usedItemLots, itemLot, usedNpcs[npcID]); } foreach (KeyValuePair <int, int> entry in entityItemLots) { int entityId = entry.Key; List <int> itemLot = new List <int> { entry.Value }; if (additionalEntityItemLots.ContainsKey(entityId)) { itemLot.AddRange(additionalEntityItemLots[entityId]); } List <EntityId> id; if (usedEntities.ContainsKey(entityId)) { id = usedEntities[entityId]; } else { Console.WriteLine($"XX Missing entity {entityId} with item lot {String.Join(", ", itemLot)}"); id = new List <EntityId> { new EntityId("", "from entity") }; } foreach (int lot in itemLot) { if (logUnused && (int)itemLots[lot]["getItemFlagId"].Value == -1 && id[0].GetModelID() != 2150) { Console.WriteLine($"Eventless entity drop, not crystal lizard, for {String.Join(", ", id)} item lot {lot}"); } AddMulti(ret.usedItemLots, lot, id); } } Dictionary <int, int> syntheticLotBase = new Dictionary <int, int>(); foreach (KeyValuePair <int, int> entry in treasureCarpDrops) { int entityId = entry.Key; int baseLot = entry.Value; List <EntityId> id; if (usedEntities.ContainsKey(entityId)) { id = usedEntities[entityId]; } else { Console.WriteLine($"XX Missing entity {entityId} with item lot {baseLot}"); id = new List <EntityId> { new EntityId("", "from entity") }; } if (syntheticLotBase.ContainsKey(baseLot)) { syntheticLotBase[baseLot] += 5; } else { syntheticLotBase[baseLot] = baseLot; } int itemLot = syntheticLotBase[baseLot]; ret.baseLotsToCreate[itemLot] = baseLot; // TODO: See if this info can be extracted anywhere else ret.newEntityLots[entityId] = itemLot; AddMulti(ret.usedItemLots, itemLot, id); } foreach (KeyValuePair <int, string> entry in scriptLots) { int itemLot = entry.Key; if ((int)itemLots[itemLot]["getItemFlagId"].Value == -1) { // TODO: Make sure there are all classified... // Console.WriteLine($"XX No event id attached to script event {itemLot}"); } AddMulti(ret.usedItemLots, itemLot, new EntityId(entry.Value, "Scripted")); } foreach (int itemLot in talkLots) { if ((int)itemLots[itemLot]["getItemFlagId"].Value == -1) { Console.WriteLine($"XX No event id attached to talk event {itemLot}"); } AddMulti(ret.usedItemLots, itemLot, new EntityId("", "from talk")); } int lastLot = 0; bool lastConnected = false; foreach (PARAM.Row lot in itemLots.Rows) { int itemLot = (int)lot.ID; bool connected = (byte)lot["LotItemNum1"].Value == 1; if (itemLot == lastLot + 1) { // Don't require groups of item lots to be connected, only the base lot } else if (connected && lastConnected && (itemLot - lastLot) % 10 == 0 && (itemLot - lastLot) <= 40) { // This is also fine.... aaaa. Bell Demon drops. } else { if (!ret.usedItemLots.ContainsKey(itemLot)) { if (logUnused) { Console.WriteLine($"Unconnected item lot {itemLot}: {game.LotName(itemLot)}"); } if (addUnused) { AddMulti(ret.usedItemLots, itemLot, new EntityId("", "unknown")); } } } lastLot = itemLot; lastConnected = connected; } return(ret); }
public LocationData FindItems(GameData game) { ItemLocs allLocs = FindItemLocs(game); SortedDictionary <int, List <EntityId> > usedItemLots = allLocs.usedItemLots; SortedDictionary <int, List <EntityId> > usedBaseShops = allLocs.usedBaseShops; PARAM shops = game.Param("ShopLineupParam"); PARAM itemLots = game.Param("ItemLotParam"); PARAM materials = game.Param("EquipMtrlSetParam"); PARAM npcs = game.Param("NpcParam"); // First we may have to create lots - easier to do this at the start than keep data in side channels all the way through int baseEvent = 52500960; foreach (KeyValuePair <int, int> toCreate in allLocs.baseLotsToCreate) { PARAM.Row baseRow = itemLots[toCreate.Value]; int newLot = toCreate.Key; PARAM.Row row = itemLots[newLot]; if (row == null) { row = game.AddRow("ItemLotParam", newLot); foreach (PARAM.Cell newCell in row.Cells) { newCell.Value = baseRow[newCell.Def.InternalName].Value; } } // TODO: Re-enable this with flag id validation row["getItemFlagId"].Value = baseEvent; baseEvent++; } LocationData data = new LocationData(); data.NewEntityLots = allLocs.newEntityLots; LocationKey prevLocation = null; foreach (KeyValuePair <int, List <EntityId> > entry in usedItemLots) { int itemLot = entry.Key; if (prevLocation != null) { // TODO: If event flag is tracked in script, allow 1 maxslot prevLocation.MaxSlots = Math.Max(Math.Min(itemLot - prevLocation.ID - 1, 8), 1); if (prevLocation.MaxSlots < 1) { Console.WriteLine($"XX Overlapping slots at {itemLot}"); } prevLocation = null; } List <EntityId> entities = entry.Value; string locs = string.Join(", ", entities.Select(e => game.EntityName(e, true) + (e.MapName == "" ? "" : " " + e.MapName))); if (itemLots[itemLot] == null) { string text = game.LotName(itemLot); // These are fine - no-ops to game if (logUnused) { Console.WriteLine($"MISSING connected item lot!! {itemLot}: {text} @ {locs}"); } continue; } LocationKey baseLocation = null; while (itemLots[itemLot] != null) { bool isBase = itemLot == entry.Key; string text = game.LotName(itemLot); PARAM.Row row = itemLots[itemLot]; int clearCount = (sbyte)row["ClearCount"].Value; int eventFlag = (int)row["getItemFlagId"].Value; int totalPoints = 0; for (int i = 1; i <= 8; i++) { totalPoints += (short)row[$"LotItemBasePoint0{i}"].Value; } List <string> itemLotOutput = new List <string>(); for (int i = 1; i <= 8; i++) { int id = (int)row[$"ItemLotId{i}"].Value; if (id != 0) { uint type = (uint)row[$"LotItemCategory0{i}"].Value; int points = (short)row[$"LotItemBasePoint0{i}"].Value; int quantity = (ushort)row[$"NewLotItemNum{i}"].Value; if (type == 0xFFFFFFFF) { continue; } ItemKey item = new ItemKey(LocationData.LotTypes[type], id); string itemText = $"{itemLot}[{locs}]"; List <string> lotInfo = new List <string>(); if (quantity > 1) { lotInfo.Add($"{quantity}x"); } if (points != totalPoints) { lotInfo.Add($"{100.0 * points / totalPoints:0.##}%"); } PARAM.Row itemRow = game.Item(item); if (itemRow != null && item.Type == ItemType.GOOD && (byte)itemRow["goodsType"].Value == 7) { // Items which are not shown in inventory menu for various reasons, but having them still does something. lotInfo.Add($"(hidden)"); } itemLotOutput.Add($"{game.Name(item)} " + string.Join(" ", lotInfo)); if (lotInfo.Count() > 0) { itemText += $" {string.Join(", ", lotInfo)}"; } if (quantity <= 0) { Console.WriteLine($"XX There is 0! of {itemText}"); } ItemScope scope; if (eventFlag != -1) { if (equivalentEvents.ContainsKey(eventFlag)) { eventFlag = equivalentEvents[eventFlag]; } scope = new ItemScope(ScopeType.EVENT, eventFlag); // Note this doesn't necessarily have to be slot 1. But it should be only one slot... if (points != totalPoints) { Console.WriteLine($"Has event flag? But random? {itemText}"); } } else { // One time drops that directly award, that aren't covered by event flags. Mostly crystal lizards. if (entities.Count() == 1 && entityItemLots.ContainsKey(entities[0].EventEntityID) && entityItemLots[entities[0].EventEntityID] == entry.Key) { scope = new ItemScope(ScopeType.ENTITY, entities[0].EventEntityID); } // Non-respawning enemies with drops which can be missed. These are reused between different entities, so can drop multiple times. else if (entities.All(e => nonRespawningEntities.Contains(e.EventEntityID))) { scope = new ItemScope(ScopeType.ENTITY, entities.Select(e => e.EventEntityID).Min()); } else { int model = entities.Select(e => e.GetModelID()).Min(); // Infinite guaranteed or scripted drops are not randomized unless specifically added to entityItemLots if (model == -1 || points == totalPoints) { if (logUnused) { Console.WriteLine($"XX Item {game.Name(item)} {itemLot} has no associated event, but is guaranteed or global: {itemText}"); } continue; } scope = new ItemScope(ScopeType.MODEL, model); } } LocationKey location = new LocationKey(LocationType.LOT, itemLot, itemText, entities, quantity, baseLocation); data.AddLocation(item, scope, location); if (baseLocation == null) { baseLocation = location; } } } // Write out the info. Some deduplication of sources to make prettier output. string lotOutput = string.Join(", ", itemLotOutput); bool simple = false; string text2; if (simple) { SortedSet <string> locations = new SortedSet <string>(entities.Select(e => game.LocationNames[e.MapName])); SortedSet <string> models = new SortedSet <string>(entities.Select(e => e.EntityName.StartsWith("o") ? "Treasure" : game.EntityName(e))); text2 = $"{string.Join(", ", models)}: {string.Join(", ", locations)}"; if (models.All(x => x == "unknown")) { text2 = "Unused/Unknown"; } if (models.All(x => x == "Unused NPC")) { text2 = "Unused NPC"; } else if (models.Any(x => x == "Unused NPC")) { models.Remove("Unused NPC"); if (locations.Count > 1) { locations.Remove("Global"); } text2 = $"{string.Join(", ", models)}: {string.Join(", ", locations)}"; } } else { // e.NPCParamID > -1 ? $" #{e.NPCParamID}" : "" // SortedSet<string> models = new SortedSet<string>(entities.Select(e => e.EntityName.StartsWith("o") ? $"Treasure in {e.MapName}" : $"{game.ModelName(e, true)} in {e.MapName}")); SortedSet <string> models = new SortedSet <string>(entities.Select(e => game.EntityName(e, true) + (e.MapName == "" ? "" : $" in {e.MapName}"))); text2 = $"{string.Join(", ", models)}"; } // Console.WriteLine($"{itemLot} [{text2}] {lotOutput}"); if (itemLot == 2014) { break; // Unused, and item lot immediately after it is used. Won't be an issue once. ... ?? } // Try to navigate resource drops (affected by Bell Demon). if ((byte)row["LotItemNum1"].Value == 1) { int curOffset = itemLot % 100; int curBase = itemLot / 100 * 100; int offset; for (offset = curOffset + 10; offset <= 50; offset += 10) { PARAM.Row offRow = itemLots[curBase + offset]; if (offRow != null && (byte)offRow["LotItemNum1"].Value == 1) { break; } } if (offset <= 50) { itemLot = curBase + offset; } else { itemLot++; } } else { itemLot++; } } prevLocation = baseLocation; } if (prevLocation != null) { prevLocation.MaxSlots = 5; } SortedDictionary <int, List <string> > qwcs = new SortedDictionary <int, List <string> >(); Dictionary <int, LocationKey> baseShops = new Dictionary <int, LocationKey>(); foreach (PARAM.Row row in shops.Rows) { int shopID = (int)row.ID; int baseShop = GetShopType(shopID); string shopName = shopSplits[baseShop]; if (shopName == null) { if (!addUnused) { continue; } shopName = "Unknown shop"; } if (shopID >= 9000000) { continue; } int qwc = (int)row["qwcID"].Value; int type = (byte)row["equipType"].Value; int id = (int)row["EquipId"].Value; int quantity = (short)row["sellQuantity"].Value; int eventFlag = (int)row["EventFlag"].Value; int material = (int)row["mtrlId"].Value; int value = (int)row["value"].Value; float priceRate = (float)row["PriceRate"].Value; string quantityText = quantity == -1 ? "" : $" ({quantity})"; // (unlimited) string qwcText = qwc == -1 ? "" : $" {game.QwcName(qwc)}"; string costText = ""; ItemKey item = new ItemKey((ItemType)type, id); if (material != -1) { PARAM.Row matRow = materials[material]; int materialQuant = (sbyte)matRow["ItemNum01"].Value; int materialItem = (int)matRow["MaterialId01"].Value; costText = $" for {materialQuant} {game.Name(new ItemKey(ItemType.GOOD, materialItem))}"; } if (value != 0 || costText == "") { int actualCost = value; if (actualCost == -1) { actualCost = (int)game.Item(item)["shopId"].Value; } if (priceRate != 0) { actualCost = (int)(actualCost * priceRate); } costText = costText == "" ? $" for {actualCost} Sen" : $"{costText} and {actualCost} Sen"; } string shopText = $"{shopName}{qwcText}{quantityText}{costText} - event {eventFlag}"; string text = $"{shopID}[{shopText}]"; // Console.WriteLine($"{shopID} [{shopName}{qwcText}] {game.Name(item)}{quantityText}{costText}"); LocationKey location = new LocationKey( LocationType.SHOP, shopID, text, usedBaseShops.ContainsKey(baseShop) ? usedBaseShops[baseShop] : new List <EntityId>(), quantity, null); // try not to use base shops - baseShops.ContainsKey(baseShop) ? baseShops[baseShop] : null); if (shopID == baseShop) { baseShops[baseShop] = location; } ItemScope scope; AddMulti(qwcs, qwc, $"{game.Name(item)}: {text}"); if (eventFlag != -1) { if (equivalentEvents.ContainsKey(eventFlag)) { eventFlag = equivalentEvents[eventFlag]; } if (quantity <= 0) { Console.WriteLine("XX No quantity for event flag shop entry {text}"); } ScopeType scopeType = ScopeType.EVENT; if (restrictiveQwcs.Contains(qwc)) { // If item becomes unavailable at some point, it returns in infinite form scopeType = ScopeType.SHOP_INFINITE_EVENT; } scope = new ItemScope(scopeType, eventFlag); } else if (material != -1) { int materialItem = (int)materials[material]["MaterialId01"].Value; scope = new ItemScope(ScopeType.MATERIAL, materialItem); } else { scope = new ItemScope(ScopeType.SHOP_INFINITE, -1); } data.AddLocation(item, scope, location); } // Merge infinite and finite shops. Mostly done via heuristic (when event and infinite both exist), with exception of one event ItemScope infiniteKey = new ItemScope(ScopeType.SHOP_INFINITE, -1); foreach (ItemLocations locations in data.Data.Values) { foreach (ItemLocation restrict in locations.Locations.Values.Where(loc => loc.Scope.Type == ScopeType.SHOP_INFINITE_EVENT).ToList()) { if (locations.Locations.ContainsKey(infiniteKey)) { // Combine infinite shops into event ItemLocation infinite = locations.Locations[infiniteKey]; restrict.Keys.AddRange(infinite.Keys); locations.Locations.Remove(infiniteKey); } else { Console.WriteLine($"XX: No 1:1 match between event shops and infinite shops for {restrict}"); // No infinite shops, turn this into a regular event shop. (Doesn't happen in base DS3) ItemLocation eventLoc = new ItemLocation(new ItemScope(ScopeType.EVENT, restrict.Scope.ID)); eventLoc.Keys.AddRange(restrict.Keys); locations.Locations[eventLoc.Scope] = eventLoc; locations.Locations.Remove(restrict.Scope); } } } // Now can find all location scopes List <ScopeType> uniqueTypes = new List <ScopeType> { ScopeType.EVENT, ScopeType.ENTITY, ScopeType.MATERIAL }; foreach (KeyValuePair <ItemKey, ItemLocations> entry in data.Data) { int unique = 0; foreach (KeyValuePair <ItemScope, ItemLocation> entry2 in entry.Value.Locations) { ItemScope scope = entry2.Key; ItemLocation loc = entry2.Value; int id = uniqueTypes.Contains(scope.Type) ? scope.ID : -1; unique = unique == -1 ? -1 : (id == -1 ? -1 : unique + 1); SortedSet <int> shopIds = new SortedSet <int>(loc.Keys.Where(k => k.Type == LocationType.SHOP).Select(k => GetShopType(k.ID))); SortedSet <int> shopQwcs = new SortedSet <int>(loc.Keys.Where(k => k.Type == LocationType.SHOP).Select(k => (int)shops[k.ID]["qwcID"].Value) .Select(qwc => equivalentEvents.ContainsKey(qwc) ? equivalentEvents[qwc] : qwc) .Where(qwc => qwc != -1 && !restrictiveQwcs.Contains(qwc))); SortedSet <int> allShop = new SortedSet <int>(shopIds.Union(shopQwcs)); if (shopIds.Count() + shopQwcs.Count() != allShop.Count()) { Console.WriteLine($"XX Overlapping qwc/shop ids for location {loc}"); } SortedSet <int> modelBase = scope.Type == ScopeType.MODEL ? new SortedSet <int>(loc.Keys.Select(k => k.BaseID)) : new SortedSet <int>(); bool onlyShops = loc.Keys.All(k => k.Type == LocationType.SHOP) && allShop.Count() > 0; LocationScope locationScope = new LocationScope(scope.Type, id, allShop, modelBase, onlyShops); data.AddLocationScope(entry.Key, scope, locationScope); loc.LocScope = locationScope; } entry.Value.Unique = unique > 0; } if (logUnused) { Console.WriteLine("---------------------------------------------------------------------------"); foreach (KeyValuePair <ItemKey, string> entry in game.Names()) { ItemKey item = entry.Key; if (item.Type == 0) { item = new ItemKey(item.Type, item.ID - (item.ID % 10000)); } if (!data.Data.ContainsKey(item)) { // Mostly pulls up old DS1 items, crow items, and gestures. Console.WriteLine($"Unused item {item.Type}-{entry.Key.ID}: {entry.Value}"); } } } return(data); }
public void Write(Random random, RandomizerOptions options) { // Collect game items // For armor: headEquip/bodyEquip/armEquip/legEquip booleans. weight float // For weapons: weight float. // Requirements: correctStrength/correctAgility/corretMagic/corretFaith float. // Types: displayTypeId (maps to MenuValueTableParam, in FMGs). // enablePyromancy/enablePyromancy/enableMiracle bool? Check attack types other than isBlowAttackType for whether a proper weapon // rightHandEquipable/leftHandEquipable bool (bothHandEquipable)? // arrowSlotEquipable/boltSlotEquipable bool for bows. bool DisableShoot for greatbow // enableGuard for shield // Arrows/Bolts: eh a bit tricky. weaponCategory 13/14 for arrow/bolt, and bool DisableShoot for greatbow // Spells: in Magic table. requirementIntellect, requirementFaith. ezStateBehaviorType - 0 magic, 2 pyro, 1 miracle Dictionary <EquipCategory, List <ItemKey> > items = new Dictionary <EquipCategory, List <ItemKey> >(); Dictionary <ItemKey, float> weights = new Dictionary <ItemKey, float>(); Dictionary <ItemKey, StatReq> requirements = new Dictionary <ItemKey, StatReq>(); HashSet <ItemKey> crossbows = new HashSet <ItemKey>(); PARAM magics = game.Param("Magic"); bool twoHand = options["startingtwohand"]; foreach (ItemKey key in data.Data.Keys) { if (key.Type == ItemType.WEAPON) { PARAM.Row row = game.Item(key); EquipCategory mainCat = EquipCategory.WEAPON; int weaponCategory = (byte)row["weaponCategory"].Value; if (weaponCategories.ContainsKey(weaponCategory)) { mainCat = weaponCategories[weaponCategory]; } if ((byte)row["enableGuard"].Value == 1) { mainCat = EquipCategory.SHIELD; } if (mainCat == EquipCategory.BOW || mainCat == EquipCategory.ARROW || mainCat == EquipCategory.BOLT) { // Disable greatbow for starting - requirements too far off if ((byte)row["DisableShoot"].Value == 1) { continue; } } if (mainCat == EquipCategory.BOW) { if ((byte)row["boltSlotEquipable"].Value == 1) { crossbows.Add(key); } } if (mainCat != EquipCategory.UNSET) { AddMulti(items, mainCat, key); } if ((byte)row["enableMagic"].Value == 1) { AddMulti(items, EquipCategory.CATALYST, key); } if ((byte)row["enableMiracle"].Value == 1) { AddMulti(items, EquipCategory.TALISMAN, key); } if ((byte)row["enablePyromancy"].Value == 1) { AddMulti(items, EquipCategory.FLAME, key); } int str = (byte)row["properStrength"].Value; // Add two hand adjustment for weapons. Note this doesn't work exactly for casting items, but does not affect casting. if (twoHand && (byte)row["Unk14"].Value == 0 && (mainCat == EquipCategory.WEAPON || mainCat == EquipCategory.UNSET)) { str = (int)Math.Ceiling(str / 1.5); } requirements[key] = new StatReq { Str = (sbyte)str, Dex = (sbyte)(byte)row["properAgility"].Value, Mag = (sbyte)(byte)row["properMagic"].Value, Fai = (sbyte)(byte)row["properFaith"].Value, }; weights[key] = (float)row["weight"].Value; } else if (key.Type == ItemType.ARMOR) { PARAM.Row row = game.Item(key); for (int i = 0; i < 4; i++) { if ((byte)row[armorTypes[i]].Value == 1) { AddMulti(items, armorCats[i], key); weights[key] = (float)row["weight"].Value; break; } } } else if (key.Type == ItemType.GOOD) { PARAM.Row magic = magics[key.ID]; // Exclude Spook and Tears of Denial as they can be a key item, useful though they are if (magic != null && key.ID != 1354000 && key.ID != 3520000) { int magicCat = (byte)magic["ezStateBehaviorType"].Value; AddMulti(items, magicTypes[magicCat], key); requirements[key] = new StatReq { Str = 0, Dex = 0, Mag = (sbyte)(byte)magic["requirementIntellect"].Value, Fai = (sbyte)(byte)magic["requirementFaith"].Value, Att = (sbyte)(byte)magic["slotLength"].Value, }; } } } // Generate some armor sets. One downside of this approach is that each piece is represented only once - but it is just one shuffle per category, and tends to result in a similar distribution to normal. List <List <ItemKey> > weightedArmors = new List <List <ItemKey> >(); for (int i = 0; i < 4; i++) { weightedArmors.Add(WeightedShuffle(random, items[armorCats[i]], item => 1 / weights[item])); } List <ArmorSet> armors = new List <ArmorSet>(); int maxArmors = weightedArmors.Select(rank => rank.Count).Min(); for (int num = 0; num < maxArmors; num++) { ArmorSet armor = new ArmorSet(); for (int i = 0; i < 4; i++) { ItemKey item = weightedArmors[i][num]; armor.Ids[i] = item.ID; armor.Weight += weights[item]; } armors.Add(armor); } armors.Sort((a, b) => a.Weight.CompareTo(b.Weight)); PARAM chara = game.Param("CharaInitParam"); // Just for testing ;) bool cheat = false; for (int i = 0; i < 10; i++) { PARAM.Row row = chara[startId + i]; // First, always fudge magic to 10, so that Orbeck quest is possible. if ((sbyte)row["baseMag"].Value < 10) { row["baseMag"].Value = (sbyte)10; } if (cheat) { foreach (string stat in stats) { row[$"base{stat}"].Value = (sbyte)90; } } // Then, see stat diffs for weapons/spells/catalysts, and fudge if necessary CharacterClass chClass = classes[i]; int attStat = (sbyte)row["baseWil"].Value; StatReq chReqs = new StatReq { Str = (sbyte)row["baseStr"].Value, Dex = (sbyte)row["baseDex"].Value, Mag = (sbyte)row["baseMag"].Value, Fai = (sbyte)row["baseFai"].Value, Att = (sbyte)(attStat < 10 ? 0 : attStat < 14 ? 1 : 2), }; StatReq dynamicReqs = chReqs; double fudgeFactor = 1.5; float weaponWeight = 0f; int attSlots = 0; bool crossbowSelected = false; Console.WriteLine($"Randomizing starting equipment for {chClass.Name}"); foreach (KeyValuePair <string, EquipCategory> entry in baseStart.Concat(chClass.Start)) { EquipCategory cat = entry.Value; // TODO: If a catalyst etc also doubles as a weapon, maybe skip its slot. // This crossbow/bow logic relies on iteration order - try to make the order fixed... if ((cat == EquipCategory.ARROW && crossbowSelected) || (cat == EquipCategory.BOLT && !crossbowSelected)) { continue; } Dictionary <ItemKey, int> statDiffs = items[entry.Value].ToDictionary(item => item, item => requirements[item].Eligible(dynamicReqs)); List <ItemKey> candidates = items[entry.Value]; if (cat == EquipCategory.SHIELD || chClass.Name == "Deprived") { candidates = candidates.Where(item => statDiffs[item] >= 0).ToList(); } if (cat == EquipCategory.SORCERY || cat == EquipCategory.MIRACLE || cat == EquipCategory.PYROMANCY) { // Fit within attunement slots. Alternatively could increase attunement, but that unbalances things potentially. // Unfortunately means that pyromancer can't start with Chaos Bed Vestiges. Maybe for the best. if (attSlots == chReqs.Att) { row[entry.Key].Value = -1; continue; } candidates = candidates.Where(item => attSlots + requirements[item].Att <= chReqs.Att).ToList(); } // Select weapon and adjust stats if necessary List <ItemKey> weightKeys = WeightedShuffle(random, candidates, item => { int diff = statDiffs[item]; if (diff >= 4) { return((float)Math.Pow(2, -4 * (Math.Min(diff, 20) / 20.0))); } if (diff >= 0) { return(2); } return((float)Math.Pow(fudgeFactor, diff)); }); ItemKey selected = weightKeys[0]; items[entry.Value].Remove(selected); if (statDiffs[selected] < 0) { dynamicReqs.Adjust(requirements[selected]); fudgeFactor *= -statDiffs[selected]; } row[entry.Key].Value = selected.ID; if (weights.ContainsKey(selected)) { weaponWeight += weights[selected]; } attSlots = requirements[selected].Att; Console.WriteLine($" {entry.Key} is now {game.Name(selected)}, meets requirements by {statDiffs[selected]}"); } int statChange = dynamicReqs.Eligible(chReqs); if (statChange < 0) { row["baseStr"].Value = dynamicReqs.Str; row["baseDex"].Value = dynamicReqs.Dex; row["baseMag"].Value = dynamicReqs.Mag; row["baseFai"].Value = dynamicReqs.Fai; row["soulLvl"].Value = (short)((short)row["soulLvl"].Value - statChange); } // Armor time float totalWeight = 40 + (sbyte)row["baseDurability"].Value; List <ArmorSet> availableSets = armors.TakeWhile(armor => armor.Weight + weaponWeight < totalWeight * 0.69f).ToList(); if (availableSets.Count == 0) { availableSets = new List <ArmorSet> { armors[0] } } ; ArmorSet selectedArmor = Choice(random, availableSets); armors.Remove(selectedArmor); Console.WriteLine($" Armor: {string.Join(", ", selectedArmor.Ids.Select(id => game.Name(new ItemKey(ItemType.ARMOR, id))))}"); Console.WriteLine($" Weight: weapons {weaponWeight:0.##} + armor {selectedArmor.Weight:0.##} / {totalWeight:0.##} = {100*(weaponWeight+selectedArmor.Weight)/totalWeight:0.##}%"); for (int j = 0; j < 4; j++) { if ((int)row[armorSlots[j]].Value != -1) { row[armorSlots[j]].Value = selectedArmor.Ids[j]; } } if (cheat) { PARAM reinforce = game.Param("ReinforceParamWeapon"); HashSet <int> reinforceLevels = new HashSet <int>(reinforce.Rows.Select(r => (int)r.ID)); foreach (string wep in weaponSlots) { int id = (int)row[wep].Value; if (id != -1) { id = id - (id % 100); PARAM.Row item = game.Item(new ItemKey(ItemType.WEAPON, id)); int reinforceId = (short)item["reinforceTypeId"].Value; while (reinforceLevels.Contains(reinforceId + 5)) { reinforceId += 5; id += 5; } row[wep].Value = id; } } } } // Now, have fun with NPCs Dictionary <string, ArmorSet> npcArmors = new Dictionary <string, ArmorSet>(); Func <ItemType, PARAM.Cell, float> cellWeight = (type, cell) => { int id = (int)cell.Value; if (id == -1) { return(0); } ItemKey key = new ItemKey(type, id); if (!weights.ContainsKey(key)) { return(0); } return(weights[key]); }; foreach (PARAM.Row row in chara.Rows.Where(r => r.ID > startId + 10)) { string name = game.CharacterName((int)row.ID); if (name == "?CHARACTER?") { continue; } ArmorSet selectedArmor; if (!npcArmors.ContainsKey(name)) { float weaponWeight = weaponSlots.Select(slot => cellWeight(ItemType.WEAPON, row[slot])).Sum(); float armorWeight = armorSlots.Select(slot => cellWeight(ItemType.ARMOR, row[slot])).Sum(); float weightLimit = weaponWeight + armorWeight; float totalWeight = 40 + (sbyte)row["baseDurability"].Value; int armorLimit = armors.FindIndex(armor => armor.Weight + weaponWeight > weightLimit); if (armorLimit == -1) { armorLimit = armors.Count - 1; } armorLimit = Math.Min(20, armorLimit); selectedArmor = npcArmors[name] = armors[random.Next(armorLimit)]; armors.Remove(selectedArmor); Console.WriteLine($"Armor for {name}: {100 * weightLimit / totalWeight:0.##}% -> {100 * (selectedArmor.Weight + weaponWeight) / totalWeight:0.##}%: {string.Join(", ", selectedArmor.Ids.Select(id => game.Name(new ItemKey(ItemType.ARMOR, id))))}"); } selectedArmor = npcArmors[name]; for (int j = 0; j < 4; j++) { if ((int)row[armorSlots[j]].Value != -1) { row[armorSlots[j]].Value = selectedArmor.Ids[j]; } } } } }