Skip to content

Commit 6b10e2c

Browse files
feat(audience): add typed event schemas and IdentityType (SDK-147)
Public schema surface for the Audience SDK: - IEvent interface: EventName + ToProperties contract that the singleton's Track(IEvent) overload consumes. Usable by custom IEvent implementations as well as the built-in typed events. - Progression / Resource / Purchase / MilestoneReached: the four typed gameplay events with Required / Optional field hints and enum-to-wire mappers that throw on unknown casts. - Purchase validates ISO 4217 three-letter uppercase currency codes via a hand-rolled IsIso4217 helper (no System.Text.RegularExpressions, keeps IL2CPP build small). - MilestoneReached rejects null or empty Name. - ProgressionStatus (Start/Complete/Fail) and ResourceFlow (Source/Sink) enums with matching ToWireString extensions. - IdentityType enum for the eight backend-accepted provider names (Passport, Steam, Epic, Google, Apple, Discord, Email, Custom) plus its ToWireString mapper. The event classes throw ArgumentException from ToProperties on invalid payloads; the singleton's Track(IEvent) catches and drops with a warning so a buggy call site cannot crash the game.
1 parent 43a4976 commit 6b10e2c

5 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.Collections.Generic;
2+
3+
namespace Immutable.Audience
4+
{
5+
// Typed event contract for ImmutableAudience.Track(IEvent).
6+
public interface IEvent
7+
{
8+
string EventName { get; }
9+
Dictionary<string, object> ToProperties();
10+
}
11+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Immutable.Audience
5+
{
6+
// Progression event state.
7+
public enum ProgressionStatus
8+
{
9+
Start,
10+
Complete,
11+
Fail
12+
}
13+
14+
internal static class ProgressionStatusExtensions
15+
{
16+
// Throws on unknown casts. Progression.ToProperties propagates, and
17+
// Track(IEvent) catches + drops with a warning.
18+
internal static string ToWireString(this ProgressionStatus status) => status switch
19+
{
20+
ProgressionStatus.Start => "start",
21+
ProgressionStatus.Complete => "complete",
22+
ProgressionStatus.Fail => "fail",
23+
_ => throw new ArgumentOutOfRangeException(
24+
nameof(status), status, "Unhandled ProgressionStatus"),
25+
};
26+
}
27+
28+
// Player progressing through a world / level / stage.
29+
public class Progression : IEvent
30+
{
31+
// Required.
32+
public ProgressionStatus Status { get; set; }
33+
// Optional.
34+
public string World { get; set; }
35+
public string Level { get; set; }
36+
public string Stage { get; set; }
37+
public int? Score { get; set; }
38+
public float? DurationSec { get; set; }
39+
40+
public string EventName => "progression";
41+
42+
public Dictionary<string, object> ToProperties()
43+
{
44+
var props = new Dictionary<string, object>
45+
{
46+
["status"] = Status.ToWireString()
47+
};
48+
49+
if (World != null) props["world"] = World;
50+
if (Level != null) props["level"] = Level;
51+
if (Stage != null) props["stage"] = Stage;
52+
if (Score.HasValue) props["score"] = Score.Value;
53+
if (DurationSec.HasValue) props["durationSec"] = DurationSec.Value;
54+
55+
return props;
56+
}
57+
}
58+
59+
// Resource flow direction.
60+
public enum ResourceFlow
61+
{
62+
Source,
63+
Sink
64+
}
65+
66+
internal static class ResourceFlowExtensions
67+
{
68+
// Throws on unknown casts. Resource.ToProperties propagates, and
69+
// Track(IEvent) catches + drops with a warning.
70+
internal static string ToWireString(this ResourceFlow flow) => flow switch
71+
{
72+
ResourceFlow.Source => "source",
73+
ResourceFlow.Sink => "sink",
74+
_ => throw new ArgumentOutOfRangeException(
75+
nameof(flow), flow, "Unhandled ResourceFlow"),
76+
};
77+
}
78+
79+
// In-game currency earned or spent.
80+
public class Resource : IEvent
81+
{
82+
// Required.
83+
public ResourceFlow Flow { get; set; }
84+
public string Currency { get; set; }
85+
public float Amount { get; set; }
86+
// Optional.
87+
public string ItemType { get; set; }
88+
public string ItemId { get; set; }
89+
90+
public string EventName => "resource";
91+
92+
public Dictionary<string, object> ToProperties()
93+
{
94+
var props = new Dictionary<string, object>
95+
{
96+
["flow"] = Flow.ToWireString(),
97+
["currency"] = Currency,
98+
["amount"] = Amount
99+
};
100+
101+
if (ItemType != null) props["itemType"] = ItemType;
102+
if (ItemId != null) props["itemId"] = ItemId;
103+
104+
return props;
105+
}
106+
}
107+
108+
// Real-money transaction.
109+
public class Purchase : IEvent
110+
{
111+
// Required. ISO 4217 three-letter uppercase currency code.
112+
public string Currency { get; set; }
113+
// Required.
114+
public decimal Value { get; set; }
115+
// Optional.
116+
public string ItemId { get; set; }
117+
public string ItemName { get; set; }
118+
public int? Quantity { get; set; }
119+
public string TransactionId { get; set; }
120+
121+
public string EventName => "purchase";
122+
123+
// Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build.
124+
private static bool IsIso4217(string s)
125+
{
126+
if (s == null || s.Length != 3) return false;
127+
for (var i = 0; i < 3; i++)
128+
{
129+
var c = s[i];
130+
if (c < 'A' || c > 'Z') return false;
131+
}
132+
return true;
133+
}
134+
135+
public Dictionary<string, object> ToProperties()
136+
{
137+
if (!IsIso4217(Currency))
138+
throw new ArgumentException(
139+
$"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code");
140+
141+
var props = new Dictionary<string, object>
142+
{
143+
["currency"] = Currency,
144+
["value"] = Value
145+
};
146+
147+
if (ItemId != null) props["itemId"] = ItemId;
148+
if (ItemName != null) props["itemName"] = ItemName;
149+
if (Quantity.HasValue) props["quantity"] = Quantity.Value;
150+
if (TransactionId != null) props["transactionId"] = TransactionId;
151+
152+
return props;
153+
}
154+
}
155+
156+
// Named milestone or achievement.
157+
public class MilestoneReached : IEvent
158+
{
159+
// Required.
160+
public string Name { get; set; }
161+
162+
public string EventName => "milestone_reached";
163+
164+
public Dictionary<string, object> ToProperties()
165+
{
166+
if (string.IsNullOrEmpty(Name))
167+
throw new ArgumentException("MilestoneReached.Name must not be null or empty");
168+
169+
return new Dictionary<string, object>
170+
{
171+
["name"] = Name
172+
};
173+
}
174+
}
175+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace Immutable.Audience
2+
{
3+
// Identity provider accepted by the Audience backend.
4+
public enum IdentityType
5+
{
6+
Passport,
7+
Steam,
8+
Epic,
9+
Google,
10+
Apple,
11+
Discord,
12+
Email,
13+
Custom,
14+
}
15+
16+
internal static class IdentityTypeExtensions
17+
{
18+
// Returns null on unknown casts. The string overloads of Identify /
19+
// Alias check for null/empty and drop + warn, so an out-of-range
20+
// cast surfaces as a dropped event, not a corrupt wire payload.
21+
internal static string ToWireString(this IdentityType type) => type switch
22+
{
23+
IdentityType.Passport => "passport",
24+
IdentityType.Steam => "steam",
25+
IdentityType.Epic => "epic",
26+
IdentityType.Google => "google",
27+
IdentityType.Apple => "apple",
28+
IdentityType.Discord => "discord",
29+
IdentityType.Email => "email",
30+
IdentityType.Custom => "custom",
31+
_ => null,
32+
};
33+
}
34+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using NUnit.Framework;
2+
3+
namespace Immutable.Audience.Tests
4+
{
5+
[TestFixture]
6+
internal class TypedEventTests
7+
{
8+
[Test]
9+
public void Progression_EventName_IsProgression()
10+
{
11+
Assert.AreEqual("progression", new Progression().EventName);
12+
}
13+
14+
[Test]
15+
public void Progression_Complete_ProducesCorrectProperties()
16+
{
17+
var evt = new Progression
18+
{
19+
Status = ProgressionStatus.Complete,
20+
World = "tutorial",
21+
Level = "1",
22+
Score = 1500,
23+
DurationSec = 120.5f
24+
};
25+
26+
var props = evt.ToProperties();
27+
28+
Assert.AreEqual("complete", props["status"]);
29+
Assert.AreEqual("tutorial", props["world"]);
30+
Assert.AreEqual("1", props["level"]);
31+
Assert.AreEqual(1500, props["score"]);
32+
Assert.AreEqual(120.5f, props["durationSec"]);
33+
}
34+
35+
[Test]
36+
public void Progression_OptionalFieldsOmitted_WhenNull()
37+
{
38+
var props = new Progression { Status = ProgressionStatus.Start }.ToProperties();
39+
40+
Assert.IsTrue(props.ContainsKey("status"));
41+
Assert.IsFalse(props.ContainsKey("world"));
42+
Assert.IsFalse(props.ContainsKey("level"));
43+
Assert.IsFalse(props.ContainsKey("stage"));
44+
Assert.IsFalse(props.ContainsKey("score"));
45+
Assert.IsFalse(props.ContainsKey("durationSec"));
46+
}
47+
48+
[Test]
49+
public void Resource_Source_ProducesCorrectProperties()
50+
{
51+
var evt = new Resource
52+
{
53+
Flow = ResourceFlow.Source,
54+
Currency = "gold",
55+
Amount = 100,
56+
ItemType = "quest_reward",
57+
ItemId = "main_quest_01"
58+
};
59+
60+
var props = evt.ToProperties();
61+
62+
Assert.AreEqual("source", props["flow"]);
63+
Assert.AreEqual("gold", props["currency"]);
64+
Assert.AreEqual(100m, props["amount"]);
65+
Assert.AreEqual("quest_reward", props["itemType"]);
66+
Assert.AreEqual("main_quest_01", props["itemId"]);
67+
}
68+
69+
[Test]
70+
public void Resource_EventName_IsResource()
71+
{
72+
Assert.AreEqual("resource", new Resource().EventName);
73+
}
74+
75+
[Test]
76+
public void Purchase_ProducesCorrectProperties()
77+
{
78+
var evt = new Purchase
79+
{
80+
Currency = "USD",
81+
Value = 9.99m,
82+
ItemId = "gem_pack_01",
83+
ItemName = "Starter Gem Pack",
84+
Quantity = 1,
85+
TransactionId = "txn_abc123"
86+
};
87+
88+
var props = evt.ToProperties();
89+
90+
Assert.AreEqual("USD", props["currency"]);
91+
Assert.AreEqual(9.99m, props["value"]);
92+
Assert.AreEqual("gem_pack_01", props["itemId"]);
93+
Assert.AreEqual("Starter Gem Pack", props["itemName"]);
94+
Assert.AreEqual(1, props["quantity"]);
95+
Assert.AreEqual("txn_abc123", props["transactionId"]);
96+
}
97+
98+
[Test]
99+
public void Purchase_OptionalFieldsOmitted_WhenNull()
100+
{
101+
var props = new Purchase { Currency = "EUR", Value = 5.00m }.ToProperties();
102+
103+
Assert.IsTrue(props.ContainsKey("currency"));
104+
Assert.IsTrue(props.ContainsKey("value"));
105+
Assert.IsFalse(props.ContainsKey("itemId"));
106+
Assert.IsFalse(props.ContainsKey("itemName"));
107+
Assert.IsFalse(props.ContainsKey("quantity"));
108+
Assert.IsFalse(props.ContainsKey("transactionId"));
109+
}
110+
111+
[Test]
112+
public void Purchase_EventName_IsPurchase()
113+
{
114+
Assert.AreEqual("purchase", new Purchase().EventName);
115+
}
116+
117+
[Test]
118+
public void MilestoneReached_ProducesCorrectProperties()
119+
{
120+
var props = new MilestoneReached { Name = "first_boss_defeated" }.ToProperties();
121+
122+
Assert.AreEqual("first_boss_defeated", props["name"]);
123+
Assert.AreEqual(1, props.Count);
124+
}
125+
126+
[Test]
127+
public void MilestoneReached_EventName_IsMilestoneReached()
128+
{
129+
Assert.AreEqual("milestone_reached", new MilestoneReached().EventName);
130+
}
131+
}
132+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using NUnit.Framework;
2+
3+
namespace Immutable.Audience.Tests
4+
{
5+
[TestFixture]
6+
internal class IdentityTypeTests
7+
{
8+
[TestCase(IdentityType.Passport, "passport")]
9+
[TestCase(IdentityType.Steam, "steam")]
10+
[TestCase(IdentityType.Epic, "epic")]
11+
[TestCase(IdentityType.Google, "google")]
12+
[TestCase(IdentityType.Apple, "apple")]
13+
[TestCase(IdentityType.Discord, "discord")]
14+
[TestCase(IdentityType.Email, "email")]
15+
[TestCase(IdentityType.Custom, "custom")]
16+
public void ToWireString_MapsEachEnumValueToLowercaseBackendString(IdentityType type, string expected)
17+
{
18+
Assert.AreEqual(expected, type.ToWireString());
19+
}
20+
21+
[Test]
22+
public void ToWireString_UnknownValue_ReturnsNull()
23+
{
24+
// Never-throw contract: an out-of-range cast should not surface an
25+
// exception on the game thread. Callers drop the event via their
26+
// null/empty check instead.
27+
var invalid = (IdentityType)999;
28+
29+
Assert.IsNull(invalid.ToWireString());
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)