b451f83174
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
239 lines
12 KiB
C#
239 lines
12 KiB
C#
using Theriapolis.Core;
|
|
using Theriapolis.Core.Data;
|
|
using Theriapolis.Core.Entities;
|
|
using Theriapolis.Core.Items;
|
|
using Theriapolis.Core.Rules.Character;
|
|
using Theriapolis.Core.Rules.Dialogue;
|
|
using Theriapolis.Core.Rules.Quests;
|
|
using Theriapolis.Core.Rules.Reputation;
|
|
using Theriapolis.Core.Rules.Stats;
|
|
using Theriapolis.Core.Time;
|
|
using Theriapolis.Core.Util;
|
|
using Theriapolis.Core.World.Settlements;
|
|
using Xunit;
|
|
|
|
namespace Theriapolis.Tests.ActI;
|
|
|
|
/// <summary>
|
|
/// Phase 6 M6 — Act I content end-to-end. Drives the dialogue runner +
|
|
/// quest engine through the ship-point sequence:
|
|
/// 1. Player arrives in Millhaven → arrival quest auto-starts.
|
|
/// 2. Player talks to the magistrate → flag set, plot items handed
|
|
/// over, arrival quest advances.
|
|
/// 3. Player talks to Asha → Old Howl quest starts, stone given.
|
|
/// 4. Player returns to Asha → Old Howl quest completes.
|
|
/// 5. Player talks to Lacroix and interrogates him → climax resolved
|
|
/// with intel branch; Maw sigil obtained.
|
|
/// 6. Final inventory matches the design's ship-point checklist:
|
|
/// journal, formula, names list, Maw sigil, Howl-stone.
|
|
///
|
|
/// The test is content-driven: no mocks; loads the real JSON from
|
|
/// Content/Data/. If the dialogue or quest authoring drifts from the
|
|
/// engine's expectations, this test catches it.
|
|
/// </summary>
|
|
public sealed class ActIIntegrationTests : IClassFixture<WorldCache>
|
|
{
|
|
private const ulong TestSeed = 0xCAFEBABEUL;
|
|
private readonly WorldCache _cache;
|
|
public ActIIntegrationTests(WorldCache c) => _cache = c;
|
|
|
|
private static Character WolfFangsworn(ContentResolver content)
|
|
{
|
|
var b = new CharacterBuilder()
|
|
.WithClade(content.Clades["canidae"])
|
|
.WithSpecies(content.Species["wolf"])
|
|
.WithClass(content.Classes["fangsworn"])
|
|
.WithBackground(content.Backgrounds["pack_raised"])
|
|
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
|
|
var classD = content.Classes["fangsworn"];
|
|
var added = new HashSet<SkillId>();
|
|
for (int i = 0; i < classD.SkillOptions.Length && added.Count < classD.SkillsChoose; i++)
|
|
{
|
|
try
|
|
{
|
|
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
|
|
if (added.Add(sk)) b.ChooseSkill(sk);
|
|
}
|
|
catch (System.ArgumentException) { }
|
|
}
|
|
return b.Build();
|
|
}
|
|
|
|
private static NpcActor MakeNamedResident(ContentResolver content, string roleTag, int id, int settlementId)
|
|
{
|
|
var template = content.ResidentsByRoleTag[roleTag];
|
|
return new NpcActor(template)
|
|
{
|
|
Id = id,
|
|
Position = new Vec2(0, 0),
|
|
RoleTag = roleTag,
|
|
HomeSettlementId = settlementId,
|
|
};
|
|
}
|
|
|
|
private static void RunDialogueTree(DialogueRunner runner, params int[] optionIndices)
|
|
{
|
|
foreach (var idx in optionIndices)
|
|
{
|
|
var result = runner.ChooseOption(idx);
|
|
if (result.ClosedAfter || runner.IsOver) return;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ShipPoint_PlaysActIToCompletion_WithExpectedInventoryAndQuests()
|
|
{
|
|
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
|
|
var pc = WolfFangsworn(content);
|
|
var rep = new PlayerReputation();
|
|
var flags = new Dictionary<string, int>();
|
|
var anchors = new AnchorRegistry();
|
|
var clock = new WorldClock();
|
|
var world = _cache.Get(TestSeed).World;
|
|
anchors.RegisterAllAnchors(world);
|
|
|
|
var actors = new ActorManager();
|
|
actors.SpawnPlayer(new Vec2(0, 0), pc);
|
|
|
|
var qctx = new QuestContext(content, actors, rep, flags, anchors, clock, world)
|
|
{
|
|
PlayerCharacter = pc,
|
|
};
|
|
var qengine = new QuestEngine();
|
|
|
|
// Find Millhaven (skip if the seed didn't place it as a normal anchor — the test seed places one).
|
|
var millhaven = world.Settlements.FirstOrDefault(s => s.Anchor is Theriapolis.Core.World.NarrativeAnchor.Millhaven);
|
|
if (millhaven is null) return; // anchor placement varies per seed; this run skips
|
|
|
|
// ── Step 1: arrive in Millhaven (set the player position there). ──
|
|
actors.Player!.Position = new Vec2(
|
|
millhaven.TileX * C.WORLD_TILE_PIXELS,
|
|
millhaven.TileY * C.WORLD_TILE_PIXELS);
|
|
qengine.Tick(qctx);
|
|
Assert.True(qengine.IsActive("main_act_i_001_arrival"));
|
|
|
|
// ── Step 2: talk to the magistrate. ──
|
|
var magistrate = MakeNamedResident(content, "millhaven.magistrate", 100, millhaven.Id);
|
|
var magCtx = new DialogueContext(magistrate, pc, rep, flags, content);
|
|
var magRunner = new DialogueRunner(content.Dialogues["millhaven_magistrate"], magCtx, TestSeed);
|
|
// Walk: intro → "Tell me what happened" (option 0) → "I'll take them" (option 0)
|
|
RunDialogueTree(magRunner, 0, 0);
|
|
|
|
Assert.True(flags.GetValueOrDefault("spoke_to_millhaven_magistrate") == 1);
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "briarstead_journal");
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "formula_partial");
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "names_list");
|
|
|
|
// Quest engine should advance to "investigate" step.
|
|
qengine.Tick(qctx);
|
|
Assert.Equal("investigate", qengine.Get("main_act_i_001_arrival")!.CurrentStep);
|
|
|
|
// ── Step 3: talk to Asha → start Old Howl. ──
|
|
var asha = MakeNamedResident(content, "millhaven.grandmother_asha", 101, millhaven.Id);
|
|
var ashaCtx1 = new DialogueContext(asha, pc, rep, flags, content);
|
|
var ashaRunner1 = new DialogueRunner(content.Dialogues["millhaven_grandmother_asha"], ashaCtx1, TestSeed);
|
|
// intro options: 0 (parents), 1 (lore), 2 (journal_recognised, conditional TRUE), 3 (<end>).
|
|
// parents → wind_smell, then wind_smell → favour (option 1 the first time).
|
|
// favour → "I'll fetch the stone" (option 0) sets asha_offered_favour and start_quest.
|
|
RunDialogueTree(ashaRunner1, 0, 0, 1, 0);
|
|
|
|
// First time through, "favour" branch sets asha_offered_favour and starts the quest.
|
|
// Hand-fire the start_quest in case the runner buffered it.
|
|
if (ashaCtx1.StartQuestRequests.Count > 0)
|
|
{
|
|
foreach (var qid in ashaCtx1.StartQuestRequests)
|
|
qengine.Start(qid, qctx);
|
|
ashaCtx1.StartQuestRequests.Clear();
|
|
}
|
|
Assert.True(qengine.IsActive("side_act_i_old_howl"),
|
|
"Asha's dialogue should have started the Old Howl side quest");
|
|
|
|
// The quest's find_stone step's on_enter should have given the stone.
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "howl_stone");
|
|
|
|
// ── Step 4: return to Asha with the stone. ──
|
|
var ashaCtx2 = new DialogueContext(asha, pc, rep, flags, content);
|
|
var ashaRunner2 = new DialogueRunner(content.Dialogues["millhaven_grandmother_asha"], ashaCtx2, TestSeed);
|
|
// intro: now visible options include journal_recognised + parents + lore + end.
|
|
// Re-talk: 0 (parents) → 0 (wind_smell) → 0 (favour_offer, conditional on has_flag: asha_offered_favour).
|
|
// From favour_offer: 0 (stone returned, conditional on has_item: howl_stone) → stone_returned.
|
|
RunDialogueTree(ashaRunner2, 0, 0, 0, 0);
|
|
|
|
Assert.True(flags.GetValueOrDefault("asha_received_howl_stone") == 1,
|
|
"Returning the stone must set the asha_received_howl_stone flag");
|
|
Assert.DoesNotContain(pc.Inventory.Items, i => i.Def.Id == "howl_stone");
|
|
|
|
qengine.Tick(qctx);
|
|
Assert.True(qengine.IsCompleted("side_act_i_old_howl"));
|
|
|
|
// ── Step 5: confront Lacroix and interrogate him. ──
|
|
var lacroix = MakeNamedResident(content, "millhaven.lacroix", 102, millhaven.Id);
|
|
var lacCtx = new DialogueContext(lacroix, pc, rep, flags, content);
|
|
var lacRunner = new DialogueRunner(content.Dialogues["millhaven_lacroix"], lacCtx, TestSeed);
|
|
// intro options: 0 (what_doing), 1 (accuse, conditional on has_item: briarstead_journal — TRUE), 2 (end)
|
|
// We have the journal. Visible: 0 (what_doing), 1 (accuse), 2 (end). Pick 1 (accuse).
|
|
// accuse options: 0 (intimidate skill check), 1 (settle here = fight), 2 (let go)
|
|
// Pick 0 (intimidate). Skill check is deterministic; we accept either branch since
|
|
// both lead to climax_resolved; we just need maw_sigil.
|
|
RunDialogueTree(lacRunner, 1, 0);
|
|
// After the skill check, the runner is at either "interrogate" or "fight".
|
|
// From "interrogate", pick 0 (let him go) → set lacroix_climax_resolved + give maw_sigil + act_i_briarstead_searched.
|
|
// From "fight", pick 0 (decisive blow) → same flags + maw_sigil.
|
|
// Either way one more option pick closes:
|
|
if (!lacRunner.IsOver) RunDialogueTree(lacRunner, 0);
|
|
|
|
Assert.True(flags.GetValueOrDefault("lacroix_climax_resolved") == 1,
|
|
"Lacroix encounter must resolve");
|
|
Assert.True(flags.GetValueOrDefault("act_i_briarstead_searched") == 1,
|
|
"Lacroix climax sets the briarstead_searched flag (chains arrival quest)");
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "maw_sigil");
|
|
|
|
// ── Step 6: arrival quest completes; following_dead chains in. ──
|
|
qengine.Tick(qctx);
|
|
Assert.True(qengine.IsCompleted("main_act_i_001_arrival"),
|
|
"Arrival quest must complete after Briarstead is searched");
|
|
Assert.True(qengine.IsActive("main_act_i_003_following_dead")
|
|
|| qengine.IsCompleted("main_act_i_003_following_dead"),
|
|
"Following the Dead must have started (and possibly completed via climax flags)");
|
|
|
|
// ── Final ship-point inventory check. ──
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "briarstead_journal");
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "formula_partial");
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "names_list");
|
|
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "maw_sigil");
|
|
// The howl_stone has been returned to Asha — that's correct end-state.
|
|
Assert.DoesNotContain(pc.Inventory.Items, i => i.Def.Id == "howl_stone");
|
|
}
|
|
|
|
[Fact]
|
|
public void AllActIQuests_LoadAndValidate()
|
|
{
|
|
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
|
|
Assert.True(content.Quests.ContainsKey("main_act_i_001_arrival"));
|
|
Assert.True(content.Quests.ContainsKey("main_act_i_003_following_dead"));
|
|
Assert.True(content.Quests.ContainsKey("side_act_i_old_howl"));
|
|
Assert.True(content.Quests.ContainsKey("side_act_i_fence_lines"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllActINamedNpcDialogues_Exist()
|
|
{
|
|
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
|
|
Assert.True(content.Dialogues.ContainsKey("millhaven_magistrate"));
|
|
Assert.True(content.Dialogues.ContainsKey("millhaven_grandmother_asha"));
|
|
Assert.True(content.Dialogues.ContainsKey("millhaven_constable"));
|
|
Assert.True(content.Dialogues.ContainsKey("millhaven_lacroix"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllActIPlotItems_Exist()
|
|
{
|
|
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
|
|
Assert.True(content.Items.ContainsKey("briarstead_journal"));
|
|
Assert.True(content.Items.ContainsKey("formula_partial"));
|
|
Assert.True(content.Items.ContainsKey("names_list"));
|
|
Assert.True(content.Items.ContainsKey("maw_sigil"));
|
|
Assert.True(content.Items.ContainsKey("howl_stone"));
|
|
}
|
|
}
|