public void Randomize(RandomizerOptions options, Action <string> notify = null, string outPath = null, bool sekiro = false, Preset preset = null, bool encrypted = true) { // sekiro = false; string distDir = sekiro ? "dists" : "dist"; if (!Directory.Exists(distDir)) { // From Release/Debug dirs distDir = $@"..\..\..\{distDir}"; } if (!Directory.Exists(distDir)) { throw new Exception("Missing data directory"); } if (outPath == null) { outPath = Directory.GetCurrentDirectory(); } Console.WriteLine($"Options and seed: {options}"); Console.WriteLine(); int seed = (int)options.Seed; notify?.Invoke("Loading game data"); string modDir = null; if (options["mergemods"]) { string modPath = sekiro ? "mods" : "mod"; DirectoryInfo modDirInfo = new DirectoryInfo($@"{outPath}\..\{modPath}"); if (!modDirInfo.Exists) { throw new Exception($"Can't merge mods: {modDirInfo.FullName} not found"); } modDir = modDirInfo.FullName; if (new DirectoryInfo(outPath).FullName == modDir) { throw new Exception($"Can't merge mods: already running from 'mods' directory"); } } GameData game = new GameData(distDir, sekiro); game.Load(modDir); // game.SearchParamInt(15200090); return; if (modDir != null) { Console.WriteLine(); } // Prologue if (options["enemy"]) { Console.WriteLine("Ctrl+F 'Boss placements' or 'Miniboss placements' or 'Basic placements' to see enemy placements."); } if (options["item"] || !sekiro) { Console.WriteLine("Ctrl+F 'Hints' to see item placement hints, or Ctrl+F for a specific item name."); } Console.WriteLine(); #if !DEBUG for (int i = 0; i < 50; i++) { Console.WriteLine(); } #endif // Slightly different high-level algorithm for each game. As always, can try to merge more in the future. if (sekiro) { Events events = new Events(@"dists\Base\sekiro-common.emedf.json"); EventConfig eventConfig; using (var reader = File.OpenText("dists/Base/events.txt")) { IDeserializer deserializer = new DeserializerBuilder().Build(); eventConfig = deserializer.Deserialize <EventConfig>(reader); } EnemyLocations locations = null; if (options["enemy"]) { notify?.Invoke("Randomizing enemies"); locations = new EnemyRandomizer(game, events, eventConfig).Run(options, preset); if (!options["enemytoitem"]) { locations = null; } } if (options["item"]) { notify?.Invoke("Randomizing items"); SekiroLocationDataScraper scraper = new SekiroLocationDataScraper(); LocationData data = scraper.FindItems(game); AnnotationData anns = new AnnotationData(game, data); anns.Load(options); anns.AddEnemyLocations(locations); SkillSplitter.Assignment split = null; if (!options["norandom_skills"] && options["splitskills"]) { split = new SkillSplitter(game, data, anns, events).SplitAll(); } Permutation perm = new Permutation(game, data, anns, explain: false); perm.Logic(new Random(seed), options, preset); notify?.Invoke("Editing game files"); PermutationWriter write = new PermutationWriter(game, data, anns, events, eventConfig); write.Write(new Random(seed + 1), perm, options); if (!options["norandom_skills"]) { SkillWriter skills = new SkillWriter(game, data, anns); skills.RandomizeTrees(new Random(seed + 2), perm, split); } if (options["edittext"]) { HintWriter hints = new HintWriter(game, data, anns); hints.Write(options, perm); } } MiscSetup.SekiroCommonPass(game, events, options); notify?.Invoke("Writing game files"); if (!options["dryrun"]) { game.SaveSekiro(outPath); } return; } else { Events events = new Events(@"dist\Base\ds3-common.emedf.json"); LocationDataScraper scraper = new LocationDataScraper(logUnused: false); LocationData data = scraper.FindItems(game); AnnotationData ann = new AnnotationData(game, data); ann.Load(options); ann.AddSpecialItems(); notify?.Invoke("Randomizing"); Random random = new Random(seed); Permutation permutation = new Permutation(game, data, ann, explain: false); permutation.Logic(random, options, null); notify?.Invoke("Editing game files"); random = new Random(seed + 1); PermutationWriter writer = new PermutationWriter(game, data, ann, events, null); writer.Write(random, permutation, options); random = new Random(seed + 2); CharacterWriter characters = new CharacterWriter(game, data); characters.Write(random, options); notify?.Invoke("Writing game files"); if (!options["dryrun"]) { game.SaveDS3(outPath, encrypted); } } }
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 CharacterWriter(GameData game, LocationData data) { this.game = game; this.data = data; }
public KeyItemsPermutation(RandomizerOptions options, LocationData data, AnnotationData ann, EnemyLocations enemies, bool explain) { this.data = data; this.ann = ann; this.explain = explain; Dictionary <string, bool> config = ann.GetConfig(options.GetLogicOptions()); Dictionary <string, Expr> configExprs = config.ToDictionary(e => e.Key, e => e.Value ? Expr.TRUE : Expr.FALSE); Dictionary <LocationScope, (UniqueCategory, int)> counts = ann.GetUniqueCounts(); Dictionary <string, Dictionary <UniqueCategory, int> > areaCounts = ann.AllAreas.ToDictionary(e => e.Key, e => { Dictionary <UniqueCategory, int> dict = Node.EmptyCounts(); foreach (LocationScope scope in e.Value.Where(s => counts.ContainsKey(s))) { (UniqueCategory cat, int count) = counts[scope]; dict[cat] += count; } return(dict); }); Dictionary <string, string> equivalentGraph = new Dictionary <string, string>(); void processDependencies(AreaAnnotation area, ISet <string> frees, bool assignItems) { string name = area.Name; HashSet <string> dependentAreas = new HashSet <string>(); bool other = false; foreach (string free in frees) { if (ann.Items.ContainsKey(free)) { items.Add(free); if (assignItems) { if (itemEvents.ContainsKey(free)) { throw new Exception($"Internal error: {free} activates multiple events"); } AddMulti(itemEvents, free, name); if (area.AlwaysBefore != null) { AddMulti(itemEvents, free, area.AlwaysBefore); } } other = true; } else if (ann.Areas.ContainsKey(free)) { dependentAreas.Add(free); } else if (ann.Events.ContainsKey(free)) { if (ann.EventAreas.TryGetValue(free, out string evArea)) { dependentAreas.Add(evArea); } else { other = true; } } else { throw new Exception($"Internal error: Unknown dependency {free} in requirements for {area.Name}"); } } if (dependentAreas.Count == 1 && !other) { equivalentGraph[name] = dependentAreas.First(); if (explain) { Console.WriteLine($"Collapsed events for key item generation: {name} -> {frees.First()}"); } } // This is used for equivalence graph things. Should probably use this information in weight groups instead of actually combining the areas AddMulti(combinedAreas, name, name); // Weight base is used to specify that a key item, if placed in the base area, should also apply to this other area. AddMulti(combinedWeights, name, name); if (area.WeightBase != null) { AddMulti(combinedWeights, area.WeightBase, name); } } foreach (AreaAnnotation ev in ann.Events.Values) { string name = ev.Name; Expr req = ev.ReqExpr.Substitute(configExprs).Simplify(); if (req.IsFalse()) { // Can happen with DLC unusedAreas.Add(ev.Name); continue; } processDependencies(ev, req.FreeVars(), true); // Events are not dynamically placed anywhere, nor is anything placed inside of them, so they are always added to the graph upfront nodes[name] = new Node { Name = name, Req = req, Counts = Node.EmptyCounts(), CumKeyCount = -1, }; } foreach (AreaAnnotation area in ann.Areas.Values) { string name = area.Name; Expr req = area.ReqExpr.Substitute(configExprs).Simplify(); if (req.IsFalse()) { // Can happen with DLC unusedAreas.Add(area.Name); continue; } // Proper aliases are already represented using the BaseArea slot property, skip those if (ann.AreaAliases[name] != name) { continue; } processDependencies(area, req.FreeVars(), false); // This is where we used to skip combined areas in DS3, but now weight bases are added automatically nodes[name] = new Node { Name = name, Counts = areaCounts[name], Req = req, Weight = 1, CumKeyCount = -1 }; areas.Add(name); } // Quick collapse of equivalence graph Dictionary <string, string> equivalent = new Dictionary <string, string>(); string getBaseName(string name) { if (equivalent.ContainsKey(name)) { return(equivalent[name]); } else if (equivalentGraph.ContainsKey(name)) { string root = getBaseName(equivalentGraph[name]); equivalent[name] = root; AddMulti(combinedAreas, root, name); return(root); } else { return(name); } }; foreach (KeyValuePair <string, string> equivalence in equivalentGraph) { getBaseName(equivalence.Key); } // TODO: Remove combinedAreas foreach (KeyValuePair <string, List <string> > entry in combinedAreas) { foreach (string alias in entry.Value) { if (alias != entry.Key) { AddMulti(combinedWeights, entry.Key, alias); } } } foreach (KeyValuePair <string, HashSet <string> > entry in combinedWeights.Where(e => e.Value.Count > 1).ToList()) { foreach (string sharedArea in entry.Value.ToList()) { entry.Value.UnionWith(combinedWeights[sharedArea]); combinedWeights[sharedArea] = entry.Value; } } if (explain) { HashSet <string> explained = new HashSet <string>(); foreach (KeyValuePair <string, HashSet <string> > entry in combinedWeights) { if (explained.Contains(entry.Key)) { continue; } Console.WriteLine($"Combined group: [{string.Join(",", entry.Value)}]"); explained.UnionWith(entry.Value); } } // TODO: Make the dictionaries less of a slog to produce combinedAreas = combinedWeights.ToDictionary(e => e.Key, e => e.Value.ToList()); // Last step - calculate rough measures of area difficulty, in terms of minimal number of items required for the area int getCumulativeCounts(string name) { Node node = nodes[name]; if (node.CumKeyCount != -1) { return(node.KeyCount + node.CumKeyCount); } List <string> deps = node.Req.FreeVars().Where(free => areas.Contains(free) || ann.Events.ContainsKey(free)).ToList(); int count = deps.Select(free => getCumulativeCounts(free)).DefaultIfEmpty().Max(); node.CumKeyCount = count; return(node.KeyCount + count); }; foreach (Node node in nodes.Values) { if (unusedAreas.Contains(node.Name)) { continue; } getCumulativeCounts(node.Name); if (explain) { Console.WriteLine($"{node.Name} ({node.Counts[UniqueCategory.KEY_SHOP]} shop / {node.KeyCount} area / ({node.Counts[UniqueCategory.QUEST_LOT]} quest / {node.CumKeyCount} cumulative): {node.Req}"); } } }
public SkillWriter(GameData game, LocationData data, AnnotationData ann) { this.game = game; this.data = data; this.ann = ann; }