Skip to content

Commit d04d8aa

Browse files
feat(run): meta-progression layer + generic tools (content gap)
The cross-run layer above RunState — a serializable MetaState "save profile" (a GENERIC bag: unlock/milestone flags + counters like meta-currency/wins/ascension) — plus the two engine hooks and a tiny data effect vocabulary. The engine models ONLY the container + tools; what the flags/counters MEAN, and which character needs which unlock, is content (MetaRules), exactly like relic reactions or rewards. No unlock/ascension content baked in. - MetaState (+ MetaStateData snapshot + MetaJson round-trip) — the actual save file. - MetaProgression.ApplyRunEnd (WRITE hook): fold a finished run into the profile via the content's MetaRules (WhenResult condition + effects SetMetaFlag / AddMetaCounter / PromoteRunResource — the run→meta data flow). - MetaProgression.AvailableCharacters (READ hook): the character roster gated by the profile — RunCharacter gains an optional UnlockFlag; the meta layer's first consumer. Tests: a victory applies its rules (unlock flag + win counter + gold promoted to currency); a defeat skips victory-only rules but still counts the run; the profile accumulates across runs and round-trips as a save file; characters are gated by unlock flags. Follow-ups: serialize MetaRules as authorable data (polymorphic MetaEffect converter) + Studio authoring; wire ApplyRunEnd into the run runner / playtest; an ascension-level run-start modifier hook reading the profile. Suite green: Run 343; format clean.
1 parent a9ab9ce commit d04d8aa

4 files changed

Lines changed: 243 additions & 3 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
namespace RogueDeck.Run;
2+
3+
// The generic TOOLS over the meta layer (MetaState). These are the engine's two hooks — write the profile when a run
4+
// ENDS, read it when a run STARTS — plus a tiny effect vocabulary the rules compose. The engine supplies the
5+
// mechanism; the RULES (which flags/counters/promotions, which character needs which unlock) are game-specific
6+
// CONTENT, exactly like relic reactions or rewards. No unlock/ascension content is baked in here.
7+
8+
// One meta-effect a rule applies to the profile. A small closed set kept as records so it can serialize as data
9+
// (like the run-effect vocabulary); richer effects can be added without touching the engine's apply loop.
10+
public abstract record MetaEffect;
11+
12+
// Set an unlock/milestone flag (e.g. "unlocked.character.mage").
13+
public sealed record SetMetaFlag(string Flag) : MetaEffect;
14+
15+
// Add to a meta counter (e.g. meta-currency, wins). Negative amounts spend.
16+
public sealed record AddMetaCounter(string Counter, int Amount) : MetaEffect;
17+
18+
// Carry a finished run's resource total into a meta counter (e.g. gold-earned → meta-currency). The run→meta data
19+
// flow, without hardcoding which resource or counter.
20+
public sealed record PromoteRunResource(string RunResource, string MetaCounter) : MetaEffect;
21+
22+
// A run-end progression rule: apply its effects to the profile when the finished run's result is one of WhenResult
23+
// (empty ⇒ any outcome). The rules are content; ApplyRunEnd is the engine tool that evaluates them.
24+
public sealed record MetaRule(IReadOnlyList<RunResult> WhenResult, IReadOnlyList<MetaEffect> Effects);
25+
26+
public static class MetaProgression
27+
{
28+
// WRITE hook: fold a finished run into the profile via the content's rules. Call once when a run ends.
29+
public static void ApplyRunEnd(MetaState meta, RunState run, IReadOnlyList<MetaRule> rules)
30+
{
31+
ArgumentNullException.ThrowIfNull(meta);
32+
ArgumentNullException.ThrowIfNull(run);
33+
ArgumentNullException.ThrowIfNull(rules);
34+
35+
foreach (var rule in rules)
36+
{
37+
if (rule.WhenResult.Count > 0 && !rule.WhenResult.Contains(run.Result))
38+
continue;
39+
foreach (var effect in rule.Effects)
40+
Apply(meta, run, effect);
41+
}
42+
}
43+
44+
private static void Apply(MetaState meta, RunState run, MetaEffect effect)
45+
{
46+
switch (effect)
47+
{
48+
case SetMetaFlag e:
49+
meta.SetFlag(e.Flag);
50+
break;
51+
case AddMetaCounter e:
52+
meta.AddCounter(e.Counter, e.Amount);
53+
break;
54+
case PromoteRunResource e:
55+
meta.AddCounter(e.MetaCounter, run.GetResource(new RunResourceId(e.RunResource)));
56+
break;
57+
}
58+
}
59+
60+
// READ hook: the character roster gated by the profile — the meta layer's first consumer. A character is
61+
// available when it declares no UnlockFlag, or when the profile has that flag set. Which flag unlocks a
62+
// character (and how it is earned) is content; the gate itself is generic.
63+
public static IReadOnlyList<RunCharacter> AvailableCharacters(RunBlueprint blueprint, MetaState meta)
64+
{
65+
ArgumentNullException.ThrowIfNull(blueprint);
66+
ArgumentNullException.ThrowIfNull(meta);
67+
return blueprint.Characters
68+
.Where(c => c.UnlockFlag is null || meta.HasFlag(c.UnlockFlag))
69+
.ToArray();
70+
}
71+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.Text.Json;
2+
3+
namespace RogueDeck.Run;
4+
5+
// The cross-run persistence layer above a single RunState — a player's "save profile". Deliberately a GENERIC bag:
6+
// a set of flags (unlocks / milestones) and a dictionary of counters (meta-currency, wins, an ascension level). The
7+
// engine models ONLY this container + the tools to read/write it (MetaProgression); it never bakes in what the flags
8+
// or counters MEAN — that is game-specific content, authored as MetaRules. Round-trips via MetaJson so it can be a
9+
// real save file. This is the "one extra layer" a roguelike-deckbuilder needs above the run, and nothing more.
10+
public sealed class MetaState
11+
{
12+
private readonly HashSet<string> _flags;
13+
private readonly Dictionary<string, int> _counters;
14+
15+
public MetaState()
16+
{
17+
_flags = new HashSet<string>();
18+
_counters = new Dictionary<string, int>();
19+
}
20+
21+
public MetaState(IEnumerable<string> flags, IReadOnlyDictionary<string, int> counters)
22+
{
23+
ArgumentNullException.ThrowIfNull(flags);
24+
ArgumentNullException.ThrowIfNull(counters);
25+
_flags = new HashSet<string>(flags);
26+
_counters = new Dictionary<string, int>(counters);
27+
}
28+
29+
public IReadOnlyCollection<string> Flags => _flags;
30+
public IReadOnlyDictionary<string, int> Counters => _counters;
31+
32+
public bool HasFlag(string flag) => _flags.Contains(flag);
33+
public void SetFlag(string flag) => _flags.Add(flag);
34+
public void ClearFlag(string flag) => _flags.Remove(flag);
35+
36+
public int GetCounter(string counter) => _counters.TryGetValue(counter, out var value) ? value : 0;
37+
public void SetCounter(string counter, int value) => _counters[counter] = value;
38+
public void AddCounter(string counter, int amount) => _counters[counter] = GetCounter(counter) + amount;
39+
40+
// A serializable snapshot (round-trips via MetaJson); the profile is the actual save data.
41+
public MetaStateData Snapshot() => new(_flags.OrderBy(f => f).ToArray(), new Dictionary<string, int>(_counters));
42+
43+
public static MetaState FromSnapshot(MetaStateData data)
44+
{
45+
ArgumentNullException.ThrowIfNull(data);
46+
return new MetaState(data.Flags, data.Counters);
47+
}
48+
}
49+
50+
// The flat, serializable shape of a MetaState — a plain record so it round-trips through System.Text.Json with no
51+
// custom converter (flags as an ordered list for stable output; counters as a map).
52+
public sealed record MetaStateData(IReadOnlyList<string> Flags, IReadOnlyDictionary<string, int> Counters);
53+
54+
// Serialize the cross-run profile to/from JSON — the meta layer's save file.
55+
public static class MetaJson
56+
{
57+
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
58+
59+
public static string ToJson(MetaState meta) => JsonSerializer.Serialize(meta.Snapshot(), Options);
60+
61+
public static MetaState FromJson(string json) =>
62+
MetaState.FromSnapshot(JsonSerializer.Deserialize<MetaStateData>(json, Options)
63+
?? new MetaStateData(Array.Empty<string>(), new Dictionary<string, int>()));
64+
}

src/RogueDeck.Run/Serialization/RunBlueprint.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ public RunStart ResolveStart(string? characterId = null)
6262
}
6363
}
6464

65-
// One selectable starting character (character selection): a stable id (for the pick + a future meta unlock gate)
66-
// plus its full RunStart. A plain record so it round-trips through RunJson like the rest of the blueprint.
67-
public sealed record RunCharacter(string Id, RunStart Start);
65+
// One selectable starting character (character selection): a stable id (for the pick + the meta unlock gate) plus
66+
// its full RunStart, and an optional UnlockFlag. When set, the character is only offered once the meta profile has
67+
// that flag (MetaProgression.AvailableCharacters); null ⇒ always available. Which flag unlocks it — and how it is
68+
// earned — is content. A plain record so it round-trips through RunJson like the rest of the blueprint.
69+
public sealed record RunCharacter(string Id, RunStart Start, string? UnlockFlag = null);
6870

6971
// The authored opening state of a run: the hero's display name, starting/maximum health, starting resources
7072
// (resource id → amount, e.g. gold), and the relics the hero begins with (ids resolved from the run's content when
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using RogueDeck.Core.Combat;
2+
using RogueDeck.Run;
3+
4+
namespace RogueDeck.Run.Tests;
5+
6+
// The meta layer (content gap): a cross-run MetaState profile + the generic tools to write it at run-end (MetaRules)
7+
// and read it at run-start (character-unlock gating). The engine models only the container + tools; the rules and
8+
// which flags/counters mean what are content. These tests exercise both hooks + the profile's serialization.
9+
public class MetaProgressionTests
10+
{
11+
private static readonly RunResourceId Gold = StandardRunIds.Gold;
12+
13+
private static RunState FinishedRun(RunResult result, int gold)
14+
{
15+
var run = new RunState(new RunId("r"), new HealthState(20, 30), new RunMap(Array.Empty<Node>()));
16+
run.SetResource(Gold, gold);
17+
run.SetResult(result);
18+
return run;
19+
}
20+
21+
// A ruleset: on a victory, unlock the mage + count a win + carry the run's gold into meta-currency; on ANY
22+
// outcome, count a completed run.
23+
private static IReadOnlyList<MetaRule> Rules() => new[]
24+
{
25+
new MetaRule(new[] { RunResult.Victory }, new MetaEffect[]
26+
{
27+
new SetMetaFlag("unlocked.character.mage"),
28+
new AddMetaCounter("wins", 1),
29+
new PromoteRunResource(Gold.Value, "meta.currency"),
30+
}),
31+
new MetaRule(Array.Empty<RunResult>(), new MetaEffect[] { new AddMetaCounter("runs", 1) }),
32+
};
33+
34+
[Fact]
35+
public void A_victory_applies_its_rules_to_the_profile()
36+
{
37+
var meta = new MetaState();
38+
39+
MetaProgression.ApplyRunEnd(meta, FinishedRun(RunResult.Victory, gold: 45), Rules());
40+
41+
Assert.True(meta.HasFlag("unlocked.character.mage"));
42+
Assert.Equal(1, meta.GetCounter("wins"));
43+
Assert.Equal(45, meta.GetCounter("meta.currency")); // the run's gold promoted into the profile
44+
Assert.Equal(1, meta.GetCounter("runs")); // the any-outcome rule also fired
45+
}
46+
47+
[Fact]
48+
public void A_defeat_skips_victory_only_rules_but_still_counts_the_run()
49+
{
50+
var meta = new MetaState();
51+
52+
MetaProgression.ApplyRunEnd(meta, FinishedRun(RunResult.Defeat, gold: 45), Rules());
53+
54+
Assert.False(meta.HasFlag("unlocked.character.mage")); // victory-only, skipped
55+
Assert.Equal(0, meta.GetCounter("wins"));
56+
Assert.Equal(0, meta.GetCounter("meta.currency"));
57+
Assert.Equal(1, meta.GetCounter("runs")); // the any-outcome rule fired
58+
}
59+
60+
[Fact]
61+
public void The_profile_accumulates_across_runs_and_round_trips_as_a_save_file()
62+
{
63+
var meta = new MetaState();
64+
MetaProgression.ApplyRunEnd(meta, FinishedRun(RunResult.Victory, 10), Rules());
65+
MetaProgression.ApplyRunEnd(meta, FinishedRun(RunResult.Victory, 20), Rules());
66+
67+
Assert.Equal(2, meta.GetCounter("wins"));
68+
Assert.Equal(30, meta.GetCounter("meta.currency")); // 10 + 20 accumulated across runs
69+
70+
var reloaded = MetaJson.FromJson(MetaJson.ToJson(meta));
71+
Assert.True(reloaded.HasFlag("unlocked.character.mage"));
72+
Assert.Equal(2, reloaded.GetCounter("wins"));
73+
Assert.Equal(30, reloaded.GetCounter("meta.currency"));
74+
Assert.Equal(2, reloaded.GetCounter("runs"));
75+
}
76+
77+
[Fact]
78+
public void Available_characters_are_gated_by_the_profiles_unlock_flags()
79+
{
80+
var blueprint = new RunBlueprint(
81+
Deck: Array.Empty<CardDefinitionId>(),
82+
Events: new Dictionary<string, EventScript>(),
83+
Encounters: Array.Empty<EncounterDefinition>(),
84+
Cards: Array.Empty<RogueDeck.Scenario.Authoring.CardData>(),
85+
EnemyActions: Array.Empty<RogueDeck.Scenario.Authoring.EnemyActionData>(),
86+
Map: new RunMap(Array.Empty<Node>()))
87+
{
88+
Characters = new[]
89+
{
90+
new RunCharacter("knight", new RunStart()), // always available
91+
new RunCharacter("mage", new RunStart(), UnlockFlag: "unlocked.character.mage"), // gated
92+
},
93+
};
94+
95+
var locked = MetaProgression.AvailableCharacters(blueprint, new MetaState());
96+
Assert.Equal(new[] { "knight" }, locked.Select(c => c.Id));
97+
98+
var meta = new MetaState();
99+
meta.SetFlag("unlocked.character.mage");
100+
var unlocked = MetaProgression.AvailableCharacters(blueprint, meta);
101+
Assert.Equal(new[] { "knight", "mage" }, unlocked.Select(c => c.Id));
102+
}
103+
}

0 commit comments

Comments
 (0)