Skip to content

Commit d2f3e95

Browse files
committed
Implement Game Graph Simulation and integrated reachability health checks
1 parent 120bcbe commit d2f3e95

15 files changed

Lines changed: 364 additions & 103 deletions

Mythril.Blazor/wwwroot/data/cadence_abilities.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@
1717
{ "Name": "J-Speed", "Description": "Allows junctioning magic to the Speed stat" },
1818
{ "Name": "Magic Pocket I", "Description": "Increases global magic capacity to 60" },
1919
{ "Name": "Magic Pocket II", "Description": "Advanced space-folding techniques further expand magic storage to 100." },
20-
{ "Name": "Logistics I", "Description": "Allows a character to perform two tasks at once" }
20+
{ "Name": "Logistics I", "Description": "Allows a character to perform two tasks at once" },
21+
{ "Name": "Refine Life", "Description": "Refine ancient components into life magic" }
2122
]

Mythril.Blazor/wwwroot/data/cadences.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@
106106
"Ability": "J-Vit",
107107
"Requirements": [ { "Item": "Gold", "Quantity": 2000 } ],
108108
"PrimaryStat": "Vitality"
109+
},
110+
{
111+
"Ability": "Refine Life",
112+
"Requirements": [ { "Item": "Ancient Bark", "Quantity": 5 } ],
113+
"PrimaryStat": "Magic"
109114
}
110115
]
111116
},

Mythril.Blazor/wwwroot/data/quest_details.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@
6060
"DurationSeconds": 60,
6161
"Type": "Recurring",
6262
"Requirements": [],
63-
"Rewards": [ { "Item": "Gold", "Quantity": 100 }, { "Item": "Fire Shard", "Quantity": 1 } ],
63+
"Rewards": [ { "Item": "Gold", "Quantity": 100 }, { "Item": "Fire Shard", "Quantity": 1 }, { "Item": "Mana Leaf", "Quantity": 1 } ],
6464
"PrimaryStat": "Strength"
6565
},
6666
{
6767
"Quest": "Chop Wood",
6868
"DurationSeconds": 75,
6969
"Type": "Recurring",
7070
"Requirements": [],
71-
"Rewards": [ { "Item": "Log", "Quantity": 2 } ],
71+
"Rewards": [ { "Item": "Log", "Quantity": 2 }, { "Item": "Ancient Bark", "Quantity": 1 } ],
7272
"PrimaryStat": "Strength"
7373
},
7474
{

Mythril.Blazor/wwwroot/data/refinements.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,12 @@
3636
{ "InputItem": "Web", "InputQuantity": 1, "OutputItem": "Gold", "OutputQuantity": 100 },
3737
{ "InputItem": "Slime", "InputQuantity": 1, "OutputItem": "Gold", "OutputQuantity": 150 }
3838
]
39+
},
40+
{
41+
"Ability": "Refine Life",
42+
"PrimaryStat": "Magic",
43+
"Recipes": [
44+
{ "InputItem": "Ancient Bark", "InputQuantity": 1, "OutputItem": "Cure I", "OutputQuantity": 5 }
45+
]
3946
}
4047
]

Mythril.Blazor/wwwroot/data/stat_augments.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,23 @@
22
{
33
"Item": "Fire I",
44
"Augments": [
5-
{ "Stat": "Strength", "ModifierAtFull": 10 },
6-
{ "Stat": "Magic", "ModifierAtFull": 5 }
5+
{ "Stat": "Strength", "ModifierAtFull": 50 },
6+
{ "Stat": "Magic", "ModifierAtFull": 20 }
77
]
88
},
99
{
1010
"Item": "Ice I",
1111
"Augments": [
12-
{ "Stat": "Magic", "ModifierAtFull": 10 },
12+
{ "Stat": "Magic", "ModifierAtFull": 100 },
13+
{ "Stat": "Speed", "ModifierAtFull": 100 },
1314
{ "Stat": "Vitality", "ModifierAtFull": 50 }
1415
]
1516
},
1617
{
1718
"Item": "Cure I",
1819
"Augments": [
19-
{ "Stat": "Magic", "ModifierAtFull": 15 },
20-
{ "Stat": "Vitality", "ModifierAtFull": 100 }
20+
{ "Stat": "Magic", "ModifierAtFull": 150 },
21+
{ "Stat": "Vitality", "ModifierAtFull": 200 }
2122
]
2223
},
2324
{

Mythril.Headless/Program.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ static void Main(string[] args)
4141
if (args.Length < 1)
4242
{
4343
Console.WriteLine("Usage: Mythril.Headless <command_file.json> [output_state.json]");
44+
Console.WriteLine(" Mythril.Headless --run-sim");
4445
Environment.Exit(1);
4546
}
4647

47-
string commandFilePath = args[0];
48+
bool isSimMode = args[0].Equals("--run-sim", StringComparison.OrdinalIgnoreCase);
49+
string commandFilePath = isSimMode ? "" : args[0];
4850
string outputFilePath = args.Length > 1 ? args[1] : "state.json";
4951

5052
// 1. Initialize Content (Manual load for Headless)
@@ -137,6 +139,15 @@ static void Main(string[] args)
137139

138140
resourceManager.Initialize();
139141

142+
if (isSimMode)
143+
{
144+
var simulator = new Simulation.ReachabilitySimulator(
145+
items, quests, questDetails, questUnlocks, questToCadenceUnlocks,
146+
cadences, locations, refinements, statAugments, stats);
147+
simulator.Run();
148+
return;
149+
}
150+
140151
var json = File.ReadAllText(commandFilePath);
141152
var commandFile = JsonConvert.DeserializeObject<CommandFile>(json);
142153

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Mythril.Data;
5+
6+
namespace Mythril.Headless.Simulation;
7+
8+
public partial class ReachabilitySimulator(
9+
Items items,
10+
Quests quests,
11+
QuestDetails questDetails,
12+
QuestUnlocks questUnlocks,
13+
QuestToCadenceUnlocks questToCadenceUnlocks,
14+
Cadences cadences,
15+
Locations locations,
16+
ItemRefinements refinements,
17+
StatAugments statAugments,
18+
Stats stats)
19+
{
20+
private readonly HashSet<string> _infiniteResources = [];
21+
private readonly Dictionary<string, int> _oneTimeResources = [];
22+
private readonly HashSet<string> _completedQuests = [];
23+
private readonly HashSet<string> _unlockedCadences = [];
24+
private readonly HashSet<string> _unlockedAbilities = []; // "CadenceName:AbilityName"
25+
private readonly Dictionary<string, int> _maxStats = stats.All.ToDictionary(s => s.Name, _ => 10);
26+
private int _magicCapacity = 30;
27+
28+
private readonly Dictionary<string, double> _minTimeToReachResource = [];
29+
private readonly Dictionary<string, double> _minTimeToReachQuest = [];
30+
31+
public void Run()
32+
{
33+
bool changed = true;
34+
int iteration = 0;
35+
36+
foreach (var item in items.All) _minTimeToReachResource[item.Name] = double.PositiveInfinity;
37+
foreach (var quest in quests.All) _minTimeToReachQuest[quest.Name] = double.PositiveInfinity;
38+
39+
while (changed && iteration < 1000)
40+
{
41+
changed = false;
42+
iteration++;
43+
44+
foreach (var loc in locations.All)
45+
{
46+
if (string.IsNullOrEmpty(loc.RequiredQuest) || _completedQuests.Contains(loc.RequiredQuest))
47+
{
48+
foreach (var quest in loc.Quests)
49+
{
50+
if (CanCompleteQuest(quest, out double time))
51+
{
52+
if (!_completedQuests.Contains(quest.Name)) { _completedQuests.Add(quest.Name); changed = true; }
53+
if (time < _minTimeToReachQuest[quest.Name])
54+
{
55+
_minTimeToReachQuest[quest.Name] = time;
56+
changed = true;
57+
var detail = questDetails[quest];
58+
foreach (var reward in detail.Rewards) UpdateResourceReachability(reward.Item.Name, reward.Quantity, detail.Type == QuestType.Recurring, time);
59+
foreach (var cadence in questToCadenceUnlocks[quest]) { if (!_unlockedCadences.Contains(cadence.Name)) { _unlockedCadences.Add(cadence.Name); changed = true; } }
60+
}
61+
}
62+
}
63+
}
64+
}
65+
66+
foreach (var cadenceName in _unlockedCadences)
67+
{
68+
var cadence = cadences.All.First(c => c.Name == cadenceName);
69+
foreach (var unlock in cadence.Abilities)
70+
{
71+
string abilityKey = $"{cadence.Name}:{unlock.Ability.Name}";
72+
if (!_unlockedAbilities.Contains(abilityKey) && CanAfford(unlock.Requirements, out double costTime))
73+
{
74+
_unlockedAbilities.Add(abilityKey); changed = true;
75+
if (unlock.Ability.Name == "Magic Pocket I") _magicCapacity = Math.Max(_magicCapacity, 60);
76+
if (unlock.Ability.Name == "Magic Pocket II") _magicCapacity = Math.Max(_magicCapacity, 100);
77+
}
78+
}
79+
}
80+
81+
foreach (var abilityKvp in refinements.ByKey)
82+
{
83+
if (_unlockedAbilities.Any(ua => ua.EndsWith($":{abilityKvp.Key.Name}")))
84+
{
85+
foreach (var recipeKvp in abilityKvp.Value.Recipes)
86+
{
87+
if (IsResourceAvailable(recipeKvp.Key.Name, recipeKvp.Value.InputQuantity, out double inputTime))
88+
{
89+
double outputTime = inputTime + (15.0 / recipeKvp.Value.OutputQuantity);
90+
if (UpdateResourceReachability(recipeKvp.Value.OutputItem.Name, recipeKvp.Value.OutputQuantity, true, outputTime)) changed = true;
91+
}
92+
}
93+
}
94+
}
95+
96+
if (UpdateStats()) changed = true;
97+
}
98+
GenerateReport();
99+
}
100+
101+
private bool IsResourceAvailable(string name, int qty, out double time)
102+
{
103+
time = _minTimeToReachResource.GetValueOrDefault(name, double.PositiveInfinity);
104+
return _infiniteResources.Contains(name) || _oneTimeResources.GetValueOrDefault(name, 0) >= qty;
105+
}
106+
107+
private bool CanAfford(ItemQuantity[] requirements, out double time)
108+
{
109+
time = 0;
110+
foreach (var req in requirements)
111+
{
112+
if (!IsResourceAvailable(req.Item.Name, req.Quantity, out double resTime)) { time = double.PositiveInfinity; return false; }
113+
time = Math.Max(time, resTime);
114+
}
115+
return true;
116+
}
117+
118+
private bool CanCompleteQuest(Quest quest, out double time)
119+
{
120+
var detail = questDetails[quest];
121+
if (!CanAfford(detail.Requirements, out double costTime)) { time = double.PositiveInfinity; return false; }
122+
foreach (var reqQuest in questUnlocks[quest])
123+
{
124+
if (!_completedQuests.Contains(reqQuest.Name)) { time = double.PositiveInfinity; return false; }
125+
costTime = Math.Max(costTime, _minTimeToReachQuest[reqQuest.Name]);
126+
}
127+
if (detail.RequiredStats != null)
128+
{
129+
foreach (var statReq in detail.RequiredStats) if (_maxStats[statReq.Key] < statReq.Value) { time = double.PositiveInfinity; return false; }
130+
}
131+
double duration = detail.DurationSeconds / (1.0 + (_maxStats[detail.PrimaryStat] / 100.0));
132+
time = costTime + duration;
133+
return true;
134+
}
135+
136+
private bool UpdateResourceReachability(string name, int qty, bool infinite, double time)
137+
{
138+
bool changed = false;
139+
if (infinite && !_infiniteResources.Contains(name)) { _infiniteResources.Add(name); changed = true; }
140+
int currentQty = _oneTimeResources.GetValueOrDefault(name, 0);
141+
if (currentQty < 9999) { _oneTimeResources[name] = currentQty + qty; changed = true; }
142+
if (time < _minTimeToReachResource[name]) { _minTimeToReachResource[name] = time; changed = true; }
143+
return changed;
144+
}
145+
146+
private bool UpdateStats()
147+
{
148+
bool changed = false;
149+
foreach (var stat in stats.All)
150+
{
151+
int bestVal = 10;
152+
foreach (var magicName in _infiniteResources)
153+
{
154+
var item = items.All.First(i => i.Name == magicName);
155+
if (item.ItemType == ItemType.Spell)
156+
{
157+
string abilityName = stat.Name switch { "Strength" => "J-Str", "Magic" => "J-Magic", "Vitality" => "J-Vit", "Speed" => "J-Speed", _ => "J-" + stat.Name };
158+
if (_unlockedAbilities.Any(ua => ua.EndsWith($":{abilityName}")))
159+
{
160+
var augment = statAugments[item].FirstOrDefault(a => a.Stat.Name == stat.Name);
161+
bestVal = Math.Max(bestVal, 10 + (int)(_magicCapacity * (augment.Stat.Name != null ? augment.ModifierAtFull / 100.0 : 0.1)));
162+
}
163+
}
164+
}
165+
if (bestVal > _maxStats[stat.Name]) { _maxStats[stat.Name] = bestVal; changed = true; }
166+
}
167+
return changed;
168+
}
169+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using Mythril.Data;
6+
7+
namespace Mythril.Headless.Simulation;
8+
9+
public partial class ReachabilitySimulator
10+
{
11+
private void GenerateReport()
12+
{
13+
var report = new System.Text.StringBuilder();
14+
report.AppendLine("# Simulation Reachability Report");
15+
report.AppendLine($"Generated at: {DateTime.Now}");
16+
report.AppendLine();
17+
18+
var unreachableQuests = quests.All.Where(q => !_completedQuests.Contains(q.Name)).ToList();
19+
if (unreachableQuests.Any())
20+
{
21+
report.AppendLine("## ❌ Unreachable Content");
22+
foreach (var q in unreachableQuests)
23+
{
24+
var detail = questDetails[q];
25+
string reason = "Unknown";
26+
27+
// Heuristic for failure reason
28+
if (detail.RequiredStats != null && detail.RequiredStats.Any(rs => _maxStats[rs.Key] < rs.Value))
29+
{
30+
var failStat = detail.RequiredStats.First(rs => _maxStats[rs.Key] < rs.Value);
31+
reason = $"Insufficient {failStat.Key} (Max potential: {_maxStats[failStat.Key]}, Need: {failStat.Value})";
32+
}
33+
else if (detail.Requirements.Any(r => !_infiniteResources.Contains(r.Item.Name) && _oneTimeResources.GetValueOrDefault(r.Item.Name, 0) < r.Quantity))
34+
{
35+
var failItem = detail.Requirements.First(r => !_infiniteResources.Contains(r.Item.Name) && _oneTimeResources.GetValueOrDefault(r.Item.Name, 0) < r.Quantity);
36+
reason = $"Missing resource: {failItem.Item.Name}";
37+
}
38+
else if (questUnlocks[q].Any(rq => !_completedQuests.Contains(rq.Name)))
39+
{
40+
var failQuest = questUnlocks[q].First(rq => !_completedQuests.Contains(rq.Name));
41+
reason = $"Prerequisite quest not completed: {failQuest.Name}";
42+
}
43+
44+
report.AppendLine($"- **{q.Name}**: {reason}");
45+
}
46+
}
47+
else
48+
{
49+
report.AppendLine("## ✅ All Content Reachable");
50+
report.AppendLine("No orphaned or mathematically impossible quests detected.");
51+
}
52+
53+
report.AppendLine();
54+
report.AppendLine("## ⏱️ Milestone Estimates (Optimal Path)");
55+
var keyMilestones = new[] { "Prologue", "Visit Starting Town", "Learn About Cadences", "Learn about the Mines", "Learn about the Dark Forest", "Rekindling the Spark" };
56+
foreach (var m in keyMilestones)
57+
{
58+
double time = _minTimeToReachQuest.GetValueOrDefault(m, double.PositiveInfinity);
59+
string timeStr = double.IsInfinity(time) ? "REACHABLE" : TimeSpan.FromSeconds(time).ToString(@"hh\:mm\:ss");
60+
report.AppendLine($"- **{m}**: {timeStr}");
61+
}
62+
63+
report.AppendLine();
64+
report.AppendLine("## 📊 Economy Summary");
65+
report.AppendLine($"**Discovered Infinite Resources**: {string.Join(", ", _infiniteResources)}");
66+
report.AppendLine($"**Max Stat Potentials**: {string.Join(", ", _maxStats.Select(kvp => $"{kvp.Key}: {kvp.Value}"))}");
67+
report.AppendLine($"**Final Magic Capacity**: {_magicCapacity}");
68+
69+
File.WriteAllText("simulation_report.md", report.ToString());
70+
Console.WriteLine(unreachableQuests.Any() ? "SIMULATION FAILED: Unreachable content detected." : "SIMULATION PASSED: All content reachable.");
71+
72+
if (unreachableQuests.Any())
73+
{
74+
Environment.Exit(1);
75+
}
76+
}
77+
}

Mythril.Tests/JunctionStatTests.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ public void JunctionManager_JunctionMagic_IncreasesStats()
145145
_junctionManager.JunctionMagic(character, strengthStat, fireMagic, _resourceManager.UnlockedAbilities);
146146

147147
var val = _junctionManager.GetStatValue(character, "Strength");
148-
Assert.AreEqual(13, val);
148+
Assert.AreEqual(25, val); // Base 10 + (30 items * (50/100)) = 10 + 15 = 25
149149
}
150150

151151
[TestMethod]
@@ -203,7 +203,10 @@ public void ResourceManager_UpdateMagicCapacity_Works()
203203

204204
_resourceManager.UnlockedAbilities.Add("AnyCadence:Magic Pocket I");
205205
_resourceManager.UpdateMagicCapacity();
206-
207206
Assert.AreEqual(60, _resourceManager.Inventory.MagicCapacity);
207+
208+
_resourceManager.UnlockedAbilities.Add("AnyCadence:Magic Pocket II");
209+
_resourceManager.UpdateMagicCapacity();
210+
Assert.AreEqual(100, _resourceManager.Inventory.MagicCapacity);
208211
}
209212
}

0 commit comments

Comments
 (0)