public static RandomizerOptions Parse(IEnumerable <string> args, bool Sekiro, Predicate <string> optionsFilter = null) { RandomizerOptions options = new RandomizerOptions(Sekiro); uint seed = 0; uint seed2 = 0; int difficulty = 0; List <string> preset = new List <string>(); string op = null; int numIndex = 0; foreach (string arg in args) { if (arg == "--preset") { op = "preset"; continue; } else if (arg.StartsWith("--")) { op = null; } if (op == "preset") { preset.Add(arg); } else if (uint.TryParse(arg, out uint num)) { if (numIndex == 0) { difficulty = (int)num; } else if (numIndex == 1) { seed = num; } else if (numIndex == 2) { seed2 = num; } numIndex++; } else { if (optionsFilter != null && !optionsFilter(arg)) { continue; } options[arg] = true; } } options.Difficulty = difficulty; options.Seed = seed; options.Seed2 = seed2; if (preset.Count > 0) { options.Preset = string.Join(" ", preset); } return(options); }
public SekiroForm() { InitializeComponent(); // One-time initialization for errors and things if (!MiscSetup.CheckRequiredSekiroFiles(out string req)) { SetError(req, true); } else { SetWarning(); } SetStatus(null); presetL.Text = ""; enemyseed.GotFocus += enemyseed_TextChanged; enemyseed.LostFocus += enemyseed_TextChanged; // The rest of initialization RandomizerOptions initialOpts = new RandomizerOptions(true); SetControlFlags(this, initialOpts); defaultOpts = initialOpts.FullString(); string existingOpts = Settings.Default.Options; if (string.IsNullOrWhiteSpace(existingOpts)) { options.Difficulty = difficulty.Value; SetControlFlags(this); // Seed and checkboxes will be updated in UpdateEnabled } else { SetOptionsString(existingOpts); if (options.Seed != 0) { defaultReroll.Enabled = true; defaultReroll.Checked = false; } // Enemy seed and checkboxes will be updated in UpdateEnabled } SetStatus(null); UpdateEnabled(); UpdateLabels(); void parentImage(PictureBox child) { child.Location = new Point(child.Location.X - title.Location.X, child.Location.Y - title.Location.Y); child.Parent = title; } parentImage(mascot); parentImage(itemPic); parentImage(catPic); RefreshImage(); }
public MainForm() { InitializeComponent(); initialColor = BackColor; if (!MiscSetup.CheckRequiredDS3Files(out string req)) { SetError(req, true); } else { SetWarning(); } SetStatus(null); // The rest of initialization RandomizerOptions initialOpts = new RandomizerOptions(false); SetControlFlags(this, initialOpts); defaultOpts = initialOpts.FullString(); string existingOpts = Settings.Default.Options; if (string.IsNullOrWhiteSpace(existingOpts)) { options.Difficulty = difficulty.Value; SetControlFlags(this); } else { SetOptionsString(existingOpts); if (options.Seed != 0) { defaultReroll.Enabled = true; defaultReroll.Checked = false; } } SetStatus(null); UpdateEnabled(); UpdateLabels(); }
private void SetControlFlags(Control control, RandomizerOptions customOpt = null) { RandomizerOptions getOpt = customOpt ?? options; if (control is RadioButton radio) { getOpt[control.Name] = radio.Checked; } else if (control is CheckBox check) { getOpt[control.Name] = check.Checked; } else { foreach (Control sub in control.Controls) { SetControlFlags(sub, customOpt); } } }
public static void SekiroCommonPass(GameData game, Events events, RandomizerOptions opt) { Dictionary <string, PARAM> Params = game.Params; // Snap (for convenience, but can also softlock the player) if (opt["snap"]) { Params["EquipParamGoods"][3980]["goodsUseAnim"].Value = (sbyte)84; } // No tutorials if (Params.ContainsKey("MenuTutorialParam")) { Params["MenuTutorialParam"].Rows = Params["MenuTutorialParam"].Rows.Where(r => r.ID == 0).ToList(); } // Memos pop up and don't just disappear mysteriously Params["EquipParamGoods"][9221]["Unk20"].Value = (byte)6; Params["EquipParamGoods"][9223]["Unk20"].Value = (byte)6; Params["EquipParamGoods"][9225]["Unk20"].Value = (byte)6; // These are just always deleted HashSet <string> deleteCommands = new HashSet <string> { "Show Tutorial Text", "Show Hint Box", "Show Small Hint Box", "Award Achievement" }; HashSet <int> deleteEvents = new HashSet <int> { // Putting away sword in areas 20006200, }; // Slowless slow walk if (opt["headlesswalk"]) { deleteEvents.Add(20005431); } foreach (KeyValuePair <string, EMEVD> entry in game.Emevds) { foreach (EMEVD.Event e in entry.Value.Events) { bool commonInit = entry.Key == "common" && e.ID == 0; int maxPermSlot = 0; for (int i = 0; i < e.Instructions.Count; i++) { Instr instr = events.Parse(e.Instructions[i]); bool delete = false; if (instr.Init) { if (deleteEvents.Contains(instr.Callee)) { delete = true; } else if (commonInit && instr.Callee == 750 && instr.Offset == 2) { maxPermSlot = Math.Max(maxPermSlot, (int)instr[0]); } } else { if (deleteCommands.Contains(instr.Name)) { delete = true; } } if (delete) { EMEVD.Instruction newInstr = new EMEVD.Instruction(1014, 69); e.Instructions[i] = newInstr; // Just in case... e.Parameters = e.Parameters.Where(p => p.InstructionIndex != i).ToList(); } } // Add permanent shop placement flags. Also.... abuse this for headless ape bestowal lot, if enemy rando is enabled. if (opt["enemy"] && opt["bosses"] && commonInit) { entry.Value.Events[0].Instructions.Add(new EMEVD.Instruction(2000, 0, new List <object> { maxPermSlot + 1, (uint)750, (uint)9307, (uint)9314 })); } } } }
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); } } }
private void SetOptionsString(string defaultOpts) { HashSet <string> validOptions = new HashSet <string>(); GetAllControlNames(this, validOptions); bool isValidOption(string s) { if (validOptions.Contains(s)) { return(true); } if (uint.TryParse(s, out var ignored)) { return(true); } return(false); } previousOpts = new HashSet <string>(defaultOpts.Split(' ')); options = RandomizerOptions.Parse(previousOpts, true, isValidOption); // New defaults if (previousOpts.Contains("v1")) { options["veryearlyhirata"] = true; options["openstart"] = true; } if (previousOpts.Contains("v1") || previousOpts.Contains("v2")) { options["scale"] = true; options["edittext"] = true; } simultaneousUpdate = true; InsertControlFlags(this); difficulty.Value = options.Difficulty; if (!enemytoitem.Checked) { defaultAllowReroll.Checked = true; } simultaneousUpdate = false; fixedseed.Text = options.Seed == 0 ? "" : $"{options.Seed}"; enemyseed.Text = options.Seed2 == 0 || options.Seed == options.Seed2 ? "" : $"{options.Seed2}"; // Also need to set enemy seed. This may be done by enemyseed text change handler? if (options.Preset == null) { SetPreset(null); } else { try { Preset preset = LoadPreset(options.Preset, extractOopsAll: true); SetPreset(preset); } catch (Exception e) { Console.WriteLine(e); SetPreset(null); } } }
private async void randomize_Click(object sender, EventArgs e) { if (working) { return; } SetWarning(); Random seedRandom = new Random(); if (!defaultReroll.Checked && fixedseed.Text.Trim() != "") { if (uint.TryParse(fixedseed.Text.Trim(), out uint seed)) { options.Seed = seed; } else { SetStatus("Invalid fixed seed", true); return; } } else { options.Seed = (uint)seedRandom.Next(); } bool newEnemySeed = false; if (!defaultRerollEnemy.Checked && enemyseed.Text.Trim() != "" && enemyseed.Text != enemySeedPlaceholder) { if (uint.TryParse(enemyseed.Text.Trim(), out uint seed)) { options.Seed2 = seed; } else { SetStatus("Invalid enemy seed", true); return; } } // TODO: What is exact condition to use for when rerolling should be allowed? What is condition for enemySeedPlaceholder about to be set? else if (defaultRerollEnemy.Checked && defaultRerollEnemy.Enabled) { options.Seed2 = (uint)seedRandom.Next(); newEnemySeed = true; } else { options.Seed2 = 0; } SaveOptions(); RandomizerOptions rand = options.Copy(); working = true; string buttonText = randomize.Text; randomize.Text = $"Running..."; randomize.BackColor = Color.LightYellow; fixedseed.Text = rand.Seed.ToString(); if (newEnemySeed) { enemyseed.Text = rand.Seed2.ToString(); enemyseed.ForeColor = SystemColors.WindowText; } bool success = false; Randomizer randomizer = new Randomizer(); await Task.Factory.StartNew(() => { Directory.CreateDirectory("spoiler_logs"); string seed2 = rand.Seed2 == 0 || rand.Seed2 == rand.Seed ? "" : $"_{rand.Seed2}"; string runId = $"{DateTime.Now.ToString("yyyy-MM-dd_HH.mm.ss")}_log_{rand.Seed}{seed2}_{rand.ConfigHash()}.txt"; TextWriter log = File.CreateText($@"spoiler_logs\{runId}"); TextWriter stdout = Console.Out; Console.SetOut(log); try { randomizer.Randomize(rand, status => { SetStatus(status); }, sekiro: true, preset: selectedPreset); SetStatus($"Done! Hints and spoilers in spoiler_logs directory as {runId} - Restart your game!!", success: true); success = true; } catch (Exception ex) { Console.WriteLine(ex); SetError($"Error encountered: {ex.Message}\r\nIt may work to try again with a different seed. See most recent file in spoiler_logs directory for the full error."); SetStatus($"Error! Partial log in spoiler_logs directory as {runId}", true); } finally { log.Close(); Console.SetOut(stdout); } }); randomize.Text = buttonText; randomize.BackColor = SystemColors.Control; working = false; if (success) { RefreshImage(); } }
public Assignment AssignItems(Random random, RandomizerOptions options, Preset preset) { List <string> itemOrder = new List <string>(items); // Right now, assign key items in a random order, with endgame items last. // We will get more devious runs from assigning later items later, may be worth looking into, especially for game with clear phases like Sekiro has. Shuffle(random, itemOrder); bool isEndgame(string i) => i.StartsWith("cinder") || i == "secretpassagekey"; itemOrder = itemOrder.OrderBy(i => isEndgame(i) ? 1 : 0).ToList(); // In race mode, always put shinobiprosthetic and younglordsbellcharm first, since there is only one ashinaoutskirts_template spot for key item placement logic if (ann.RaceModeItems.Count > 0) { itemOrder = itemOrder.OrderBy(i => i == "shinobiprosthetic" || i == "younglordsbellcharm" ? 0 : 1).ToList(); } Assignment ret = new Assignment(); // Find which events are required to access other things, and do not give them as much weight as dead ends with placement biases foreach (Node node in nodes.Values) { if (node.Req != null) { ret.RequiredEvents.UnionWith(node.Req.FreeVars().Where(v => ann.Events.ContainsKey(v))); } } // First assign ashes for key item placement. Other quest assignments can happen later. // TODO: Make ashes its own system, rather than abusing quest system. if (ann.ItemGroups.ContainsKey("ashes")) { List <string> ashesOrder = new List <string>(areas); ashesOrder = WeightedShuffle(random, ashesOrder, loc => Math.Min(nodes[loc].KeyCount - nodes[loc].Counts[UniqueCategory.KEY_SHOP], 3)); int ashesIndex = 0; foreach (KeyValuePair <LocationScope, SlotAnnotation> entry in ann.Slots) { LocationScope scope = entry.Key; SlotAnnotation slot = entry.Value; HashSet <string> tags = slot.GetTags(); // Unique item unlocks other unique items unconditionally - can add a location for key item. Mainly for ashes. if (slot.QuestReqs != null && !slot.HasAnyTags(ann.NoKeyTags) && scope.UniqueId > 0 && slot.ItemReqs.Count == 1) { ItemKey key = slot.ItemReqs[0]; if (ret.Assign.ContainsKey(key)) { throw new Exception($"Multiple assignments for {slot.QuestReqs}"); } string selected = ashesOrder[ashesIndex++]; if (explain) { Console.WriteLine($"Assigning key quest item {slot.QuestReqs} to {selected}"); } int ashesCount = data.Location(scope).Count; nodes[selected].AddShopCapacity(false, ashesCount); nodes[selected].AddItem(false, false); ret.Assign[key] = new HashSet <string> { selected }; if (ann.AreaEvents.TryGetValue(selected, out List <string> events)) { ret.Assign[key].UnionWith(events); } } } } if (explain) { int raceModeCount = 0; foreach (string area in areas) { int rmode = nodes[area].Count(false, true); if (rmode > 0) { Console.WriteLine($"RACEMODE {area}: {rmode}"); raceModeCount += rmode; } } Console.WriteLine($"TOTAL: {raceModeCount}"); } Dictionary <string, string> forcemap = new Dictionary <string, string>(); if (options["norandom"]) { foreach (string item in itemOrder) { ItemKey itemKey = ann.Items[item]; // Some hardcoding for Path of the Dragon which is not an actual item if (item == "pathofthedragon") { forcemap[item] = "highwall_garden"; } else { ItemLocation loc = data.Data[itemKey].Locations.Values.First(); SlotAnnotation sn = ann.Slot(loc.LocScope); forcemap[item] = sn.Area; } } } else if (preset?.Items != null) { foreach (KeyValuePair <string, string> entry in preset.Items) { forcemap[entry.Key] = entry.Value; } } // Assign key items bool debugChoices = false; float scaling = options.GetNum("keyitemchainweight"); Dictionary <string, Expr> reqs = CollapseReqs(); foreach (string item in itemOrder) { List <string> allowedAreas = areas.Where(a => !reqs[a].Needs(item)).ToList(); if (debugChoices) { Console.WriteLine($"\n> {item} not allowed in areas: {string.Join(",", areas.Where(a => !allowedAreas.Contains(a)))}"); } bool redundant = allowedAreas.Count == areas.Count; HashSet <string> neededForEvent = itemEvents.TryGetValue(item, out HashSet <string> ev) ? ev : null; allowedAreas.RemoveAll(a => ann.Areas[a].Until != null && (neededForEvent == null || !neededForEvent.Contains(ann.Areas[a].Until))); Dictionary <string, float> specialScaling = new Dictionary <string, float>(); if (item == "smalllothricbanner") { specialScaling = allowedAreas.Where(area => ann.Areas[area].BoringKeyItem).ToDictionary(area => area, _ => 1 / scaling); } // A bit hacky, but prevent Small Lothric Banner from being so prevalent in Firelink Shrine Dictionary <string, float> weights = allowedAreas.ToDictionary(area => area, area => Weight(area) * (specialScaling.TryGetValue(area, out float sp) ? sp : 1)); if (debugChoices) { Console.WriteLine($"> Choices for {item}: " + string.Join(", ", allowedAreas.OrderBy(a => weights[a]).Select(a => $"{a} {weights[a]}"))); } string selected = WeightedChoice(random, allowedAreas, a => weights[a]); if (forcemap.TryGetValue(item, out string forced)) { if (explain && !allowedAreas.Contains(forced)) { Console.WriteLine($"Key item {item} put in non-random location {forced} which isn't normally allowed by logic"); } selected = forced; } AddItem(item, selected, forced != null); ItemKey itemKey = ann.Items[item]; ret.Priority.Add(itemKey); ret.Assign[itemKey] = new HashSet <string> { selected }; // Areas should include events there. Except for bell charm being dropped by chained ogre, if that option is enabled // todo: check this works okay with racemode key items, and nothing else randomized. if (!(item == "younglordsbellcharm" && options["earlyhirata"])) { if (ann.AreaEvents.TryGetValue(selected, out List <string> events)) { ret.Assign[itemKey].UnionWith(events); } } if (explain || debugChoices) { Console.WriteLine($"Adding {item} to {string.Join(",", ret.Assign[itemKey])}"); } // Update weights reqs = CollapseReqs(); // If item was not really needed, don't update weighhts if (redundant) { if (explain) { Console.WriteLine($"{item} is redundant to another key item (does not uniquely make new areas available)"); } continue; } // Heuristic which forms chains and spreads items across areas // Reduce weight for this area, and increase weight for areas which depend on the item AdjustWeight(selected, 1 / scaling); HashSet <string> addedAreas = new HashSet <string>(); foreach (string area in areas) { if (addedAreas.Contains(combinedWeights[area].First())) { continue; } if (reqs[area].Needs(item)) { AdjustWeight(area, scaling); addedAreas.Add(combinedWeights[area].First()); } } } // The last placed item has the highest priority ret.Priority.Reverse(); // Now that all key items have been assigned, determine which areas are blocked by other areas. // This is used to determine lateness within the game (by # of items encountered up to that point). HashSet <string> getIncludedAreas(string name, List <string> path) { path = path.Concat(new[] { name }).ToList(); if (!nodes.TryGetValue(name, out Node node)) { throw new Exception($"Bad options: no way to access area \"{name}\""); } if (ret.IncludedAreas.ContainsKey(name)) { if (ret.IncludedAreas[name] == null) { throw new Exception($"Loop from {name} to {node.Req} - path {string.Join(",", path)}"); } return(ret.IncludedAreas[name]); } ret.IncludedAreas[name] = null; HashSet <string> result = new HashSet <string>(); // Add all nodes for now, but remove items later if (areas.Contains(name) || ann.Events.ContainsKey(name) || ann.Items.ContainsKey(name)) { result.Add(name); } foreach (string free in node.Req.FreeVars()) { if (!(loops.ContainsKey(name) && loops[name].Contains(free))) { result.UnionWith(getIncludedAreas(free, path)); } } ret.IncludedAreas[name] = result; return(result); }; foreach (Node node in nodes.Values) { getIncludedAreas(node.Name, new List <string>()); // Redefine weights for quest selection node.Weight = 1; if (areas.Contains(node.Name)) { node.CumKeyCount = ret.IncludedAreas[node.Name].Where(n => nodes[n].Counts != null).Select(n => nodes[n].Count(true, true)).Sum(); if (explain && false) { Console.WriteLine($"Quest area {node.Name}: {node.Count(true, true)}/{node.CumKeyCount}: {string.Join(",", ret.IncludedAreas[node.Name])}"); } } } // Find out which items are required to access the ending, while items are still included in the graph. // For hints later. // Also do this in DS3 after deciding where to add hints. if (ret.IncludedAreas.TryGetValue("ashinareservoir_end", out HashSet <string> requiredForEnd)) { List <ItemKey> requiredItems = requiredForEnd.Where(t => ann.Items.ContainsKey(t)).Select(t => ann.Items[t]).ToList(); ret.NotRequiredKeyItems.UnionWith(ret.Priority.Except(requiredItems)); } // The above DFS adds both items and areas together, so remove the items. foreach (string key in ret.IncludedAreas.Keys.ToList()) { if (ann.Items.ContainsKey(key)) { ret.IncludedAreas.Remove(key); } else { ret.IncludedAreas[key].RemoveWhere(v => ann.Items.ContainsKey(v)); } } foreach (string area in unusedAreas) { ret.IncludedAreas[area] = new HashSet <string>(); } // Make an area order List <string> areaOrder = areas.OrderBy(a => nodes[a].CumKeyCount).ToList(); Dictionary <string, int> areaIndex = Enumerable.Range(0, areaOrder.Count()).ToDictionary(i => areaOrder[i], i => i); string latestArea(IEnumerable <string> ns) { return(areaOrder[ns.Select(n => areaIndex.TryGetValue(n, out int i) ? i : throw new Exception($"No order for area {n}")).DefaultIfEmpty().Max()]);
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 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]; } } } } }
public void Write(RandomizerOptions opt, Permutation permutation) { // Overall: 9020, and 9200 to 9228 // Exclude 9209 and 9215 and 9228 bc it's useful // Also, 9221 9223 9225 are 'purchase to read' // Can replace everything in quotes // Or after the first :\n // Or after the last \n\n // If the message ends with ' -?Name' can leave that. space or ideographic space (3000). multiple whitespace either way // // Alternatively: replace individual text, like 'moon-view tower' // defeat a named enemy // defeat a formidable foe // defeat a powerful enemy FMG itemDesc = game.ItemFMGs["アイテム説明"]; FMG eventText = game.MenuFMGs["イベントテキスト"]; if (opt["writehints"]) { List <int> eventIds = new List <int> { 12000000, 12000331, 12000021, 12000261, 12000275, 12000321, 12000285, 12000241, 12000011, 12000311, 12000231, 12000291, 12000341, }; eventIds.Sort(); HintData write = new HintData(); void createHint(int id, string name, string text) { Hint hint = new Hint { ID = id, Name = name, Versions = new List <HintTemplate> { new HintTemplate { Type = "default", Text = text.Split('\n').ToList(), } }, }; write.Hints.Add(hint); } foreach (KeyValuePair <ItemKey, string> entry in game.Names()) { ItemKey key = entry.Key; if (!(key.Type == ItemType.GOOD && (key.ID == 9020 || key.ID >= 9200 && key.ID <= 9228))) { continue; } createHint(key.ID, entry.Value, itemDesc[key.ID]); } foreach (int id in eventIds) { createHint(id, null, eventText[id]); } ISerializer serializer = new SerializerBuilder().DisableAliases().Build(); using (var writer = File.CreateText("hints.txt")) { serializer.Serialize(writer, write); } return; } IDeserializer deserializer = new DeserializerBuilder().Build(); HintData hints; using (var reader = File.OpenText("dists/Base/hints.txt")) { hints = deserializer.Deserialize <HintData>(reader); } // Preprocess some items Dictionary <HintType, TypeHint> typeNames = hints.Types.ToDictionary(e => e.Name, e => e); Dictionary <ItemCategory, List <ItemKey> > categories = ((ItemCategory[])Enum.GetValues(typeof(ItemCategory))).ToDictionary(e => e, e => new List <ItemKey>()); Dictionary <ItemCategory, string> categoryText = new Dictionary <ItemCategory, string>(); foreach (ItemHint cat in hints.ItemCategories) { if (cat.Includes != null) { categories[cat.Name].AddRange(cat.Includes.Split(' ').Select(i => ann.Items.TryGetValue(i, out ItemKey key) ? key : throw new Exception($"Unrecognized name {i}"))); } if (cat.IncludesName != null) { categories[cat.Name].AddRange(phraseRe.Split(cat.IncludesName).Select(i => game.ItemForName(i))); } if (cat.Text != null) { categoryText[cat.Name] = cat.Text; } } if (opt["earlyhirata"]) { categories[ItemCategory.ExcludeHints].Add(ann.Items["younglordsbellcharm"]); } categories[ItemCategory.ExcludeHints].AddRange(permutation.NotRequiredKeyItems); // TODO: Exclude non-technically-required items... calculate this in key item permutations List <ItemKey> allItems = permutation.KeyItems.ToList(); if (opt["norandom_skills"]) { categories[ItemCategory.ImportantTool].Clear(); } else { allItems.AddRange(categories[ItemCategory.ImportantTool]); } allItems.AddRange(categories[ItemCategory.HintFodder]); Dictionary <string, ItemHintName> specialItemNames = hints.ItemNames.ToDictionary(n => n.Name, n => n); // Process areas Dictionary <string, AreaHint> areas = new Dictionary <string, AreaHint>(); HashSet <string> gameAreas = new HashSet <string>(ann.Areas.Keys); List <string> getAreasForName(string names) { List <string> nameList = new List <string>(); foreach (string name in names.Split(' ')) { if (name.EndsWith("*")) { string prefix = name.Substring(0, name.Length - 1); List <string> matching = gameAreas.Where(n => n.StartsWith(prefix)).ToList(); if (matching.Count == 0) { throw new Exception($"Unrecognized area in hint config: {name}"); } nameList.AddRange(matching); } else { if (!gameAreas.Contains(name)) { throw new Exception($"Unrecognized area in hint config: {name}"); } nameList.Add(name); } } return(nameList); } foreach (AreaHint area in hints.Areas) { if (area.Name == null || area.Includes == null) { throw new Exception($"Missing data in area hint grouping {area.Name}"); } areas[area.Name] = area; area.Areas.UnionWith(getAreasForName(area.Includes)); if (area.Excludes != null) { area.Areas.ExceptWith(getAreasForName(area.Excludes)); } if (area.LaterIncludes != null) { area.LaterAreas.UnionWith(getAreasForName(area.LaterIncludes)); if (!area.LaterAreas.IsSubsetOf(area.Areas)) { throw new Exception($"Error in hint config: later areas of {area.Name} are not a subset of all areas"); } } area.EarlyAreas.UnionWith(area.Areas.Except(area.LaterAreas)); if (area.Parent != null) { if (!areas.TryGetValue(area.Parent, out AreaHint parent)) { throw new Exception($"Error in hint config: parent of {area.Name} does not exist: {area.Parent}"); } area.Parents.Add(parent); area.Parents.UnionWith(parent.Parents); } if (area.Present != null) { area.Types = area.Present.Split(' ').Select(t => (HintType)Enum.Parse(typeof(HintType), t)).ToList(); } } bool printText = opt["hinttext"]; // Process items to search for List <ItemCategory> categoryOverrides = new List <ItemCategory> { ItemCategory.RequiredKey, ItemCategory.RequiredAbility, ItemCategory.ImportantTool, ItemCategory.HintFodder }; HashSet <string> chests = new HashSet <string> { "o005300", "o005400", "o255300" }; List <Placement> placements = new List <Placement>(); Dictionary <ItemKey, Placement> itemPlacement = new Dictionary <ItemKey, Placement>(); foreach (ItemKey key in allItems) { if (categories[ItemCategory.ExcludeHints].Contains(key)) { continue; } if (!permutation.SkillAssignment.TryGetValue(key, out ItemKey lookup)) { lookup = key; } SlotKey targetKey = permutation.GetFiniteTargetKey(lookup); ItemLocation itemLoc = data.Location(targetKey); if (!ann.Slots.TryGetValue(itemLoc.LocScope, out SlotAnnotation slot)) { continue; } ItemCategory category = ItemCategory.RequiredItem; foreach (ItemCategory cat in categoryOverrides) { // Use the last applicable category if (categories[cat].Contains(key)) { category = cat; } } List <HintType> types = new List <HintType>(); if (slot.HasTag("boss") || slot.HasTag("bosshint")) { types.Add(HintType.Boss); types.Add(HintType.Enemy); } else if (slot.HasTag("miniboss")) { types.Add(HintType.Miniboss); types.Add(HintType.Enemy); } else if (slot.HasTag("enemyhint")) { types.Add(HintType.Enemy); } else if (slot.HasTag("carp")) { types.Add(HintType.Carp); } else if (itemLoc.Keys.Any(k => k.Type == LocationKey.LocationType.SHOP && k.ID / 100 != 11005)) { types.Add(HintType.Shop); } else { if (slot.Area.EndsWith("_underwater") || slot.HasTag("underwater")) { types.Add(HintType.Underwater); } if (itemLoc.Keys.Any(k => k.Type == LocationKey.LocationType.LOT && k.Entities.Any(e => chests.Contains(e.ModelName)))) { types.Add(HintType.Chest); } types.Add(HintType.Treasure); } string name = game.Name(key); Placement placement = new Placement { Item = key, FullName = name, Category = category, LateEligible = categories[ItemCategory.LatenessHints].Contains(key), Important = category != ItemCategory.HintFodder, Area = slot.Area, Types = types, }; if (placement.Important) { placements.Add(placement); } itemPlacement[key] = placement; if (printText) { Console.WriteLine(placement); } } foreach (Hint hint in hints.Hints) // Lovely { hint.Types = hint.Versions.Where(v => v.Type != "default").ToDictionary(v => v.Type, v => v); } // Classify early and late areas HashSet <string> early = new HashSet <string>(permutation.IncludedAreas.Where(e => !e.Value.Contains("ashinacastle")).Select(e => e.Key)); HashSet <string> late = new HashSet <string>(permutation.IncludedAreas.Where(e => e.Value.Contains("fountainhead_bridge")).Select(e => e.Key)); // Start hints Random random = new Random((int)opt.Seed); List <Hint> sources = hints.Hints.Where(s => s.Types.Any(e => e.Key != "default")).ToList(); Shuffle(random, sources); sources = sources.OrderBy(s => (s.HasInfix("bad") && s.Types.ContainsKey("hint")) ? 1 : 0).ToList(); string choose(List <string> items) { return(items.Count == 1 ? items[0] : Choice(random, items)); } // Process all hint types. There are 20 item locations in the entire game, plus 13 fixed texts, for a total of 33 Regex format = new Regex(@"\(([^\)]*)\)"); if (printText) { Console.WriteLine($"No hint items: {string.Join(", ", categories[ItemCategory.ExcludeHints].Select(k => game.Name(k)))}"); } void addHint(Hint hint, HintTemplate t, Placement mainPlacement, Placement otherPlacement = null) { string text = printText ? string.Join(" ", t.Text.Select(l => l.Trim())) : string.Join("\n", t.Text); bool positive = !t.Type.Contains("bad"); foreach (Match m in format.Matches(text)) { string variable = m.Groups[1].Value; string[] parts = variable.Split('_'); string kind = parts[0].ToLowerInvariant(); bool upper = char.IsUpper(parts[0][0]); string subkind = parts.Length > 1 ? parts[1] : null; string value; Placement placement = kind == "location2" && otherPlacement != null ? otherPlacement : mainPlacement; if (kind == "item") { if (positive) { if (placement.LateHint || !categoryText.ContainsKey(placement.Category)) { value = placement.FullName; if (specialItemNames.TryGetValue(value, out ItemHintName vagueName)) { value = choose(vagueName.GetNames()); } } else { value = categoryText[placement.Category]; } } else { // Shouldn't be used in this context, but fall back value = "nothing"; } } else if (kind == "type") { HintType type = placement.Types.FirstOrDefault(); value = choose(typeNames[placement.Types[0]].GetNames(subkind)); } else if (kind == "location" || kind == "location2") { bool prep; if (subkind == null) { prep = false; } else if (subkind == "preposition") { prep = true; } else { throw new Exception($"Unknown hint config variable {variable}"); } if (positive || placement.Types.Count == 0) { value = placement.AreaHint == null ? (positive ? "somewhere" : "anywhere") : choose(placement.AreaHint.GetNames(prep)); } else { if (prep || placement.AreaHint != null) { value = (prep ? "from " : "") + choose(typeNames[placement.Types[0]].GetNames("noun")) + " " + (placement.AreaHint == null ? (positive ? "somewhere" : "anywhere") : choose(placement.AreaHint.GetNames(true))); } else { value = value = choose(typeNames[placement.Types[0]].GetNames("gerund")); } } } else { throw new Exception($"Unknown hint variable {variable}"); } if (upper) { value = value[0].ToString().ToUpperInvariant() + value.Substring(1); } text = text.Replace($"({variable})", value); } if (printText) { Console.WriteLine(text + "\n"); } if (hint.ID < 10000) { itemDesc[hint.ID] = text; } else { eventText[hint.ID] = text; } } AreaHint mostSpecificArea(string name) { AreaHint selected = null; foreach (AreaHint area in hints.Areas) { if (area.EarlyAreas.Contains(name)) { if (selected == null || area.Parents.Contains(selected)) { selected = area; } } } return(selected); } T pop <T>(List <T> list) { T ret = list[list.Count - 1]; list.RemoveAt(list.Count - 1); return(ret); } // In priority order: // Item hints: Find item at (type) and (location). Always filled in. 1 of these. HashSet <ItemKey> exactKey = new HashSet <ItemKey>(); foreach (Hint hint in sources.Where(s => s.Types.ContainsKey("itemhint")).ToList()) { HintTemplate t = hint.Types["itemhint"]; if (!ann.Items.TryGetValue(t.Req, out ItemKey key)) { throw new Exception($"Unrecognized name {t.Req}"); } if (!itemPlacement.TryGetValue(key, out Placement placement)) { continue; } exactKey.Add(key); placement = placement.Copy(); placement.AreaHint = mostSpecificArea(placement.Area); addHint(hint, t, placement); sources.Remove(hint); } // Location hints: Find (item/item category/nothing) at (location). Always filled in. 2 of these. foreach (Hint hint in sources.Where(s => s.Types.ContainsKey("locationhint")).ToList()) { HintTemplate t = hint.Types["locationhint"]; List <string> reqAreas = t.Req.Split(' ').ToList(); List <Placement> places = placements.Where(p => reqAreas.Contains(p.Area)).ToList(); if (places.Count == 0 && hint.Types.TryGetValue("locationbadhint", out HintTemplate t2)) { addHint(hint, t2, null); } else { Placement placement = places[0].Copy(); addHint(hint, t, placement); } sources.Remove(hint); } // Global negative hint: There is nothing at (type). Always include as many as applicable for likely types, treasure/chest/boss/miniboss/enemy/underwater/shop (but more like 1-2) List <HintType> present = placements.SelectMany(p => p.Types).Distinct().ToList(); List <HintType> absent = new List <HintType> { HintType.Treasure, HintType.Chest, HintType.Boss, HintType.Miniboss, HintType.Enemy, HintType.Underwater, HintType.Shop } .Except(present).ToList(); foreach (Hint hint in sources.Where(s => s.Types.ContainsKey("badhint")).ToList()) { if (absent.Count == 0) { break; } HintType type = pop(absent); HintTemplate t = hint.Types["badhint"]; addHint(hint, t, new Placement { Types = new List <HintType> { type }, }); sources.Remove(hint); } // Positive hint: There is (item/item category) at (type) in (location). Include one per key item, up to 13, and special items, currently 3. List <Placement> toPlace = placements.Where(p => !exactKey.Contains(p.Item)).ToList(); foreach (Hint hint in sources.Where(s => s.Types.ContainsKey("hint")).ToList()) { if (toPlace.Count == 0) { break; } HintTemplate t = hint.Types["hint"]; Placement placement = pop(toPlace); placement = placement.Copy(); placement.AreaHint = mostSpecificArea(placement.Area); addHint(hint, t, placement); sources.Remove(hint); } // Lateness hint: There is (item) at (type) (late/early). Include one per location item, up to 6. toPlace = placements.Where(p => p.LateEligible).ToList(); foreach (Hint hint in sources.Where(s => s.Types.ContainsKey("hint")).ToList()) { if (toPlace.Count == 0) { break; } HintTemplate t = hint.Types["hint"]; Placement placement = pop(toPlace); placement = placement.Copy(); placement.LateHint = true; string info = early.Contains(placement.Area) ? "an early game location" : (late.Contains(placement.Area) ? "a late game location" : "a mid game location"); string prep = early.Contains(placement.Area) ? "in the early game" : (late.Contains(placement.Area) ? "in the late game" : "in the mid game"); placement.AreaHint = new AreaHint { Name = info, Vague = info, VaguePrep = prep }; addHint(hint, t, placement); sources.Remove(hint); } // So far, around 25 hints created, with ~10 left to go. At this point, pick randomly from either of these categories. List <AreaHint> withoutRedundantChildren(List <AreaHint> allAreas) { return(allAreas.Where(area => area.Parents.Count == 0 || !area.Parents.Any(p => allAreas.Contains(p))).ToList()); } List <AreaHint> areasWithoutPlacements(HintType type = HintType.None) { HashSet <string> importantAreas = new HashSet <string>(placements.Where(p => type == HintType.None || p.Types.Contains(type)).Select(p => p.Area)); return(hints.Areas.Where(h => (type == HintType.None || h.Types.Contains(type)) && !importantAreas.Overlaps(h.Areas)).ToList()); } // Area negative hint: There is nothing in (location). Can be included for all such areas, but eliminate a hint if its parent also applies. Dictionary <HintType, List <AreaHint> > negativeHints = new Dictionary <HintType, List <AreaHint> >(); List <AreaHint> unimportantAreas = areasWithoutPlacements(); negativeHints[HintType.None] = withoutRedundantChildren(unimportantAreas); foreach (HintType noType in new[] { HintType.Boss, HintType.Miniboss, HintType.Treasure }) { negativeHints[noType] = withoutRedundantChildren(areasWithoutPlacements(noType)).Except(unimportantAreas).ToList(); } negativeHints[HintType.Enemy] = new List <AreaHint>(); foreach (AreaHint allEnemy in negativeHints[HintType.Boss].Intersect(negativeHints[HintType.Miniboss]).ToList()) { // Add 'no powerful enemy' hints if no boss and no miniboss. negativeHints[HintType.Enemy].Add(allEnemy); negativeHints[HintType.Boss].Remove(allEnemy); negativeHints[HintType.Miniboss].Remove(allEnemy); } List <Placement> negatives = new List <Placement>(); foreach (KeyValuePair <HintType, List <AreaHint> > entry in negativeHints) { List <HintType> types = entry.Key == HintType.None ? new List <HintType>() : new List <HintType> { entry.Key }; foreach (AreaHint area in entry.Value) { negatives.Add(new Placement { AreaHint = area, Types = types, }); } } // Area type hint: There is nothing at (type) in (location). Include this for treasure and for miniboss/boss/enemy, eliminating when parent (or parent type) applies. Shuffle(random, negatives); negatives = negatives.OrderBy(a => a.AreaHint.AreaRank).ToList(); // If there are at least 4 negative hints, allow up to 3 of the remainder to become fodder. List <Hint> negativeHintTemplates = sources.Where(s => s.Types.ContainsKey("badhint") || s.Types.ContainsKey("badhint2")).ToList(); if (negatives.Count >= 4 && negativeHintTemplates.Count > 4) { int cutoffIndex = Math.Max(negativeHintTemplates.Count - 3, 4); negativeHintTemplates.RemoveRange(cutoffIndex, negativeHintTemplates.Count - cutoffIndex); } foreach (Hint hint in negativeHintTemplates) { if (negatives.Count == 0) { break; } HintTemplate t = hint.Types.ContainsKey("badhint") ? hint.Types["badhint"] : hint.Types["badhint2"]; Placement placement = pop(negatives); Placement otherPlacement = t.Type == "badhint2" && negatives.Count > 0 ? pop(negatives) : null; addHint(hint, t, placement, otherPlacement); sources.Remove(hint); } if (printText) { Console.WriteLine($"{sources.Count} remaining hints: [{string.Join(", ", sources.Select(s => string.Join("/", s.Types.Keys)))}]"); } if (sources.Count > 0) { // At this point, pull in misc hints for somewhat useful items toPlace = categories[ItemCategory.HintFodder].Select(k => itemPlacement[k]).ToList(); Shuffle(random, toPlace); foreach (Hint hint in sources.Where(s => s.Types.ContainsKey("hint")).ToList()) { if (toPlace.Count == 0) { break; } HintTemplate t = hint.Types["hint"]; Placement placement = pop(toPlace); placement = placement.Copy(); placement.AreaHint = mostSpecificArea(placement.Area); addHint(hint, t, placement); sources.Remove(hint); } } // Need to figure out which items are strictly required to beat the game // Also, for bad hints, find all strictly required items plus key items // List all locations which can have hints scoped to them. The entirety of Sunken Valley, or just lower/upper, or burrow // Most key items get minimum specificity // Required side area items will get early/lateness specificity when that applies // Skills/prosthetics will get maximum specificity, maybe even two for Mikiri // Mortal Blade is excluded, since it has its own explicit hint // Young Lord Bell Charm is excluded if earlyhirata }