diff --git a/src/Packages/Audience/.gitignore b/src/Packages/Audience/.gitignore
new file mode 100644
index 000000000..e660fd93d
--- /dev/null
+++ b/src/Packages/Audience/.gitignore
@@ -0,0 +1 @@
+bin/
diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs
index b3806e91b..5f1a3ea03 100644
--- a/src/Packages/Audience/Runtime/AssemblyInfo.cs
+++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Immutable.Audience.Runtime.Tests")]
+[assembly: InternalsVisibleTo("Immutable.Audience.Unity")]
diff --git a/src/Packages/Audience/Runtime/Audience.Runtime.csproj b/src/Packages/Audience/Runtime/Audience.Runtime.csproj
index 388f1006b..7fc7f1620 100644
--- a/src/Packages/Audience/Runtime/Audience.Runtime.csproj
+++ b/src/Packages/Audience/Runtime/Audience.Runtime.csproj
@@ -7,8 +7,12 @@
Immutable.Audience.Runtime
+
+
+
diff --git a/src/Packages/Audience/Runtime/Core/ConsentStore.cs b/src/Packages/Audience/Runtime/Core/ConsentStore.cs
new file mode 100644
index 000000000..8701a56e2
--- /dev/null
+++ b/src/Packages/Audience/Runtime/Core/ConsentStore.cs
@@ -0,0 +1,52 @@
+#nullable enable
+
+using System;
+using System.IO;
+
+namespace Immutable.Audience
+{
+ internal static class ConsentStore
+ {
+ internal static void Save(string persistentDataPath, ConsentLevel level)
+ {
+ Directory.CreateDirectory(AudiencePaths.AudienceDir(persistentDataPath));
+
+ var filePath = AudiencePaths.ConsentFile(persistentDataPath);
+ var tmpPath = filePath + ".tmp";
+
+ File.WriteAllText(tmpPath, ((int)level).ToString());
+
+ try
+ {
+ File.Move(tmpPath, filePath);
+ }
+ catch (IOException)
+ {
+ File.Delete(filePath);
+ File.Move(tmpPath, filePath);
+ }
+ }
+
+ // Returns null on missing/malformed/unreadable file; caller falls back to config default.
+ internal static ConsentLevel? Load(string persistentDataPath)
+ {
+ try
+ {
+ var filePath = AudiencePaths.ConsentFile(persistentDataPath);
+ if (!File.Exists(filePath)) return null;
+
+ var text = File.ReadAllText(filePath).Trim();
+ if (int.TryParse(text, out var raw) && Enum.IsDefined(typeof(ConsentLevel), raw))
+ return (ConsentLevel)raw;
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs
index a7ed09792..9cb2a6de0 100644
--- a/src/Packages/Audience/Runtime/Core/Constants.cs
+++ b/src/Packages/Audience/Runtime/Core/Constants.cs
@@ -1,3 +1,5 @@
+#nullable enable
+
namespace Immutable.Audience
{
internal static class Constants
@@ -14,17 +16,41 @@ internal static class Constants
internal const int DefaultFlushSize = 20;
internal const int MaxBatchSize = 100;
internal const int StaleEventDays = 30;
+ internal const int MaxFieldLength = 256; // Backend schema limit.
internal const string LibraryName = "com.immutable.audience";
+ internal const string LibraryVersion = "0.1.0";
internal const string Surface = "unity";
internal const string ConsentSource = "UnitySDK";
- internal static string BaseUrl(string publishableKey) =>
+ internal const string PublishableKeyHeader = "x-immutable-publishable-key";
+
+ internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath;
+ internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath;
+ internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath;
+
+ internal static string BaseUrl(string? publishableKey) =>
publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
? SandboxBaseUrl
: ProductionBaseUrl;
}
+ // Message type values written to (and read back from) the "type" field.
+ internal static class MessageTypes
+ {
+ internal const string Track = "track";
+ internal const string Identify = "identify";
+ internal const string Alias = "alias";
+ }
+
+ // Wire-format field names that cross module boundaries inside the SDK
+ // (read by one module, written by another).
+ internal static class MessageFields
+ {
+ internal const string Type = "type";
+ internal const string UserId = "userId";
+ }
+
// Common distribution platform values for AudienceConfig.DistributionPlatform.
public static class DistributionPlatforms
{
diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs
index 2b1143d38..8ae1f6778 100644
--- a/src/Packages/Audience/Runtime/Core/Identity.cs
+++ b/src/Packages/Audience/Runtime/Core/Identity.cs
@@ -1,3 +1,5 @@
+#nullable enable
+
using System;
using System.IO;
@@ -12,13 +14,53 @@ namespace Immutable.Audience
internal sealed class Identity
{
// In-memory cache — volatile so background threads always see the latest write.
- private static volatile string _cachedId;
+ private static volatile string? _cachedId;
private static readonly object _sync = new object();
+ // Returns the existing anonymous ID, or null if none exists.
+ // Unlike GetOrCreate, never generates or persists a new one.
+ internal static string? Get(string persistentDataPath)
+ {
+ if (_cachedId != null) return _cachedId;
+
+ lock (_sync)
+ {
+ if (_cachedId != null) return _cachedId;
+
+ try
+ {
+ var filePath = AudiencePaths.IdentityFile(persistentDataPath);
+ if (!File.Exists(filePath)) return null;
+
+ _cachedId = File.ReadAllText(filePath).Trim();
+ return _cachedId;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+ }
+
+ // Drops the in-memory cache without touching disk. Called on
+ // Shutdown/ResetState so a subsequent Init with a different
+ // persistentDataPath re-reads the file from the new location.
+ internal static void ClearCache()
+ {
+ lock (_sync)
+ {
+ _cachedId = null;
+ }
+ }
+
// Returns the anonymous ID, generating and persisting it on first call.
// Returns null without touching disk when consent is None.
// Safe to call from any thread after ImmutableAudience.Init() has run on the main thread.
- internal static string GetOrCreate(string persistentDataPath, ConsentLevel consent)
+ internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent)
{
// No ID until the player grants at least anonymous consent.
if (consent == ConsentLevel.None)
diff --git a/src/Packages/Audience/Runtime/Events/IEvent.cs b/src/Packages/Audience/Runtime/Events/IEvent.cs
new file mode 100644
index 000000000..0ef5a28d0
--- /dev/null
+++ b/src/Packages/Audience/Runtime/Events/IEvent.cs
@@ -0,0 +1,13 @@
+#nullable enable
+
+using System.Collections.Generic;
+
+namespace Immutable.Audience
+{
+ // Typed event contract for ImmutableAudience.Track(IEvent).
+ public interface IEvent
+ {
+ string EventName { get; }
+ Dictionary ToProperties();
+ }
+}
\ No newline at end of file
diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs
index efc3ec396..1347e4665 100644
--- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs
+++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs
@@ -1,3 +1,5 @@
+#nullable enable
+
using System;
using System.Collections.Generic;
@@ -7,46 +9,52 @@ internal static class MessageBuilder
{
internal static Dictionary Track(
string eventName,
- string anonymousId,
- string userId,
+ string? anonymousId,
+ string? userId,
string packageVersion,
- Dictionary properties = null)
+ Dictionary? properties = null)
{
- var msg = BuildBase("track", packageVersion);
- msg["eventName"] = Truncate(eventName, 256);
+ var msg = BuildBase(MessageTypes.Track, packageVersion);
+ msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength);
if (!string.IsNullOrEmpty(anonymousId))
- msg["anonymousId"] = Truncate(anonymousId, 256);
+ msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength);
if (!string.IsNullOrEmpty(userId))
- msg["userId"] = Truncate(userId, 256);
+ msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);
if (properties != null && properties.Count > 0)
+ {
+ TruncateStringValues(properties);
msg["properties"] = properties;
+ }
return msg;
}
internal static Dictionary Identify(
- string anonymousId,
- string userId,
- string identityType,
+ string? anonymousId,
+ string? userId,
+ string? identityType,
string packageVersion,
- Dictionary traits = null)
+ Dictionary? traits = null)
{
- var msg = BuildBase("identify", packageVersion);
+ var msg = BuildBase(MessageTypes.Identify, packageVersion);
if (!string.IsNullOrEmpty(anonymousId))
- msg["anonymousId"] = Truncate(anonymousId, 256);
+ msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength);
if (!string.IsNullOrEmpty(userId))
- msg["userId"] = Truncate(userId, 256);
+ msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength);
if (!string.IsNullOrEmpty(identityType))
- msg["identityType"] = Truncate(identityType, 256);
+ msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength);
if (traits != null && traits.Count > 0)
+ {
+ TruncateStringValues(traits);
msg["traits"] = traits;
+ }
return msg;
}
@@ -58,11 +66,11 @@ internal static Dictionary Alias(
string toType,
string packageVersion)
{
- var msg = BuildBase("alias", packageVersion);
- msg["fromId"] = Truncate(fromId, 256);
- msg["fromType"] = Truncate(fromType, 256);
- msg["toId"] = Truncate(toId, 256);
- msg["toType"] = Truncate(toType, 256);
+ var msg = BuildBase(MessageTypes.Alias, packageVersion);
+ msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength);
+ msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength);
+ msg["toId"] = Truncate(toId, Constants.MaxFieldLength);
+ msg["toType"] = Truncate(toType, Constants.MaxFieldLength);
return msg;
}
@@ -70,13 +78,13 @@ private static Dictionary BuildBase(string type, string packageV
{
return new Dictionary
{
- ["type"] = type,
+ [MessageFields.Type] = type,
["messageId"] = Guid.NewGuid().ToString(),
["eventTimestamp"] = DateTime.UtcNow.ToString("o"),
["context"] = new Dictionary
{
["library"] = Constants.LibraryName,
- ["libraryVersion"] = Truncate(packageVersion, 256)
+ ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength)
},
["surface"] = Constants.Surface
};
@@ -84,9 +92,20 @@ private static Dictionary BuildBase(string type, string packageV
private static string Truncate(string s, int maxLen)
{
- if (s == null || s.Length <= maxLen)
+ if (s.Length <= maxLen)
return s;
return s.Substring(0, maxLen);
}
+
+ private static void TruncateStringValues(Dictionary dict)
+ {
+ // Snapshot keys to avoid mutating the collection during iteration.
+ var keys = new List(dict.Keys);
+ foreach (var key in keys)
+ {
+ if (dict[key] is string s && s.Length > Constants.MaxFieldLength)
+ dict[key] = Truncate(s, Constants.MaxFieldLength);
+ }
+ }
}
}
diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs
new file mode 100644
index 000000000..36d35d2e0
--- /dev/null
+++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs
@@ -0,0 +1,180 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+
+namespace Immutable.Audience
+{
+ // Progression event state.
+ public enum ProgressionStatus
+ {
+ Start,
+ Complete,
+ Fail
+ }
+
+ internal static class ProgressionStatusExtensions
+ {
+ // Throws on unknown casts. Progression.ToProperties propagates, and
+ // Track(IEvent) catches + drops with a warning.
+ internal static string ToLowercaseString(this ProgressionStatus status) => status switch
+ {
+ ProgressionStatus.Start => "start",
+ ProgressionStatus.Complete => "complete",
+ ProgressionStatus.Fail => "fail",
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(status), status, "Unhandled ProgressionStatus"),
+ };
+ }
+
+ // Player progressing through a world / level / stage.
+ public class Progression : IEvent
+ {
+ // Required.
+ public ProgressionStatus Status { get; set; }
+ // Optional.
+ public string? World { get; set; }
+ public string? Level { get; set; }
+ public string? Stage { get; set; }
+ public int? Score { get; set; }
+ public float? DurationSec { get; set; }
+
+ public string EventName => "progression";
+
+ public Dictionary ToProperties()
+ {
+ var props = new Dictionary
+ {
+ ["status"] = Status.ToLowercaseString()
+ };
+
+ if (World != null) props["world"] = World;
+ if (Level != null) props["level"] = Level;
+ if (Stage != null) props["stage"] = Stage;
+ if (Score.HasValue) props["score"] = Score.Value;
+ if (DurationSec.HasValue) props["durationSec"] = DurationSec.Value;
+
+ return props;
+ }
+ }
+
+ // Resource flow direction.
+ public enum ResourceFlow
+ {
+ Source,
+ Sink
+ }
+
+ internal static class ResourceFlowExtensions
+ {
+ // Throws on unknown casts. Resource.ToProperties propagates, and
+ // Track(IEvent) catches + drops with a warning.
+ internal static string ToLowercaseString(this ResourceFlow flow) => flow switch
+ {
+ ResourceFlow.Source => "source",
+ ResourceFlow.Sink => "sink",
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(flow), flow, "Unhandled ResourceFlow"),
+ };
+ }
+
+ // In-game currency earned or spent.
+ public class Resource : IEvent
+ {
+ // Required.
+ public ResourceFlow Flow { get; set; }
+ public string? Currency { get; set; }
+ public float Amount { get; set; }
+ // Optional.
+ public string? ItemType { get; set; }
+ public string? ItemId { get; set; }
+
+ public string EventName => "resource";
+
+ public Dictionary ToProperties()
+ {
+ if (string.IsNullOrEmpty(Currency))
+ throw new ArgumentException("Resource.Currency must not be null or empty");
+
+ var props = new Dictionary
+ {
+ ["flow"] = Flow.ToLowercaseString(),
+ ["currency"] = Currency,
+ ["amount"] = Amount
+ };
+
+ if (ItemType != null) props["itemType"] = ItemType;
+ if (ItemId != null) props["itemId"] = ItemId;
+
+ return props;
+ }
+ }
+
+ // Real-money transaction.
+ public class Purchase : IEvent
+ {
+ // Required. ISO 4217 three-letter uppercase currency code.
+ public string? Currency { get; set; }
+ // Required.
+ public decimal Value { get; set; }
+ // Optional.
+ public string? ItemId { get; set; }
+ public string? ItemName { get; set; }
+ public int? Quantity { get; set; }
+ public string? TransactionId { get; set; }
+
+ public string EventName => "purchase";
+
+ // Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build.
+ private static bool IsIso4217(string s)
+ {
+ if (s.Length != 3) return false;
+ for (var i = 0; i < 3; i++)
+ {
+ var c = s[i];
+ if (c < 'A' || c > 'Z') return false;
+ }
+ return true;
+ }
+
+ public Dictionary ToProperties()
+ {
+ if (Currency == null || !IsIso4217(Currency))
+ throw new ArgumentException(
+ $"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code");
+
+ var props = new Dictionary
+ {
+ ["currency"] = Currency,
+ ["value"] = Value
+ };
+
+ if (ItemId != null) props["itemId"] = ItemId;
+ if (ItemName != null) props["itemName"] = ItemName;
+ if (Quantity.HasValue) props["quantity"] = Quantity.Value;
+ if (TransactionId != null) props["transactionId"] = TransactionId;
+
+ return props;
+ }
+ }
+
+ // Named milestone or achievement.
+ public class MilestoneReached : IEvent
+ {
+ // Required.
+ public string? Name { get; set; }
+
+ public string EventName => "milestone_reached";
+
+ public Dictionary ToProperties()
+ {
+ if (string.IsNullOrEmpty(Name))
+ throw new ArgumentException("MilestoneReached.Name must not be null or empty");
+
+ return new Dictionary
+ {
+ ["name"] = Name
+ };
+ }
+ }
+}
diff --git a/src/Packages/Audience/Runtime/IdentityType.cs b/src/Packages/Audience/Runtime/IdentityType.cs
new file mode 100644
index 000000000..eed0312ea
--- /dev/null
+++ b/src/Packages/Audience/Runtime/IdentityType.cs
@@ -0,0 +1,36 @@
+#nullable enable
+
+namespace Immutable.Audience
+{
+ // Identity provider accepted by the Audience backend.
+ public enum IdentityType
+ {
+ Passport,
+ Steam,
+ Epic,
+ Google,
+ Apple,
+ Discord,
+ Email,
+ Custom,
+ }
+
+ internal static class IdentityTypeExtensions
+ {
+ // Returns null on unknown casts. The string overloads of Identify /
+ // Alias check for null/empty and drop + warn, so an out-of-range
+ // cast surfaces as a dropped event, not a corrupt wire payload.
+ internal static string? ToLowercaseString(this IdentityType type) => type switch
+ {
+ IdentityType.Passport => "passport",
+ IdentityType.Steam => "steam",
+ IdentityType.Epic => "epic",
+ IdentityType.Google => "google",
+ IdentityType.Apple => "apple",
+ IdentityType.Discord => "discord",
+ IdentityType.Email => "email",
+ IdentityType.Custom => "custom",
+ _ => null,
+ };
+ }
+}
diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs
index 7590e4229..67750ccb7 100644
--- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs
+++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs
@@ -38,7 +38,7 @@ internal HttpTransport(
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey));
- _url = Constants.BaseUrl(publishableKey) + Constants.MessagesPath;
+ _url = Constants.MessagesUrl(publishableKey);
_onError = onError;
_client = handler != null ? new HttpClient(handler) : new HttpClient();
_client.Timeout = TimeSpan.FromSeconds(30);
@@ -78,7 +78,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default)
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, _url);
- request.Headers.Add("x-immutable-publishable-key", _publishableKey);
+ request.Headers.Add(Constants.PublishableKeyHeader, _publishableKey);
#if IMMUTABLE_AUDIENCE_GZIP
var compressed = Gzip.Compress(payload);
request.Content = new ByteArrayContent(compressed);
diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs
new file mode 100644
index 000000000..b0af0ea8d
--- /dev/null
+++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs
@@ -0,0 +1,33 @@
+using System.IO;
+using NUnit.Framework;
+
+namespace Immutable.Audience.Tests
+{
+ [TestFixture]
+ internal class ConstantsTests
+ {
+ [Test]
+ public void LibraryVersion_MatchesPackageJson()
+ {
+ // Fails the build if Constants.LibraryVersion and package.json
+ // "version" drift. context.libraryVersion on every outgoing event
+ // depends on them agreeing.
+ var packageJson = ReadPackageJson();
+ var parsed = JsonReader.DeserializeObject(packageJson);
+
+ Assert.IsTrue(parsed.TryGetValue("version", out var versionObj),
+ "package.json is missing a \"version\" field");
+ Assert.AreEqual(Constants.LibraryVersion, versionObj,
+ "Constants.LibraryVersion must match package.json version");
+ }
+
+ private static string ReadPackageJson()
+ {
+ var testDir = TestContext.CurrentContext.TestDirectory;
+ // Tests/bin/Debug/net8.0/ → Tests/ → Audience/package.json
+ var packagePath = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "package.json"));
+ Assert.IsTrue(File.Exists(packagePath), $"package.json not found at {packagePath}");
+ return File.ReadAllText(packagePath);
+ }
+ }
+}
diff --git a/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs
new file mode 100644
index 000000000..85713b777
--- /dev/null
+++ b/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs
@@ -0,0 +1,70 @@
+using System.IO;
+using NUnit.Framework;
+
+namespace Immutable.Audience.Tests
+{
+ [TestFixture]
+ internal class ConsentStoreTests
+ {
+ private string _testDir;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(_testDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+
+ [Test]
+ public void Load_NoFile_ReturnsNull()
+ {
+ Assert.IsNull(ConsentStore.Load(_testDir));
+ }
+
+ [Test]
+ public void SaveThenLoad_RoundtripsValue([Values] ConsentLevel level)
+ {
+ ConsentStore.Save(_testDir, level);
+ Assert.AreEqual(level, ConsentStore.Load(_testDir));
+ }
+
+ [Test]
+ public void Save_OverwritesPreviousValue()
+ {
+ ConsentStore.Save(_testDir, ConsentLevel.Anonymous);
+ ConsentStore.Save(_testDir, ConsentLevel.Full);
+
+ Assert.AreEqual(ConsentLevel.Full, ConsentStore.Load(_testDir));
+ }
+
+ [Test]
+ public void Load_MalformedFile_ReturnsNull()
+ {
+ // A garbage value that isn't a valid enum int.
+ var dir = AudiencePaths.AudienceDir(_testDir);
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(AudiencePaths.ConsentFile(_testDir), "not-an-int");
+
+ Assert.IsNull(ConsentStore.Load(_testDir));
+ }
+
+ [Test]
+ public void Load_OutOfRangeIntInFile_ReturnsNull()
+ {
+ // 999 is parseable as int but not a defined ConsentLevel.
+ var dir = AudiencePaths.AudienceDir(_testDir);
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(AudiencePaths.ConsentFile(_testDir), "999");
+
+ Assert.IsNull(ConsentStore.Load(_testDir));
+ }
+
+ }
+}
diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs
similarity index 70%
rename from src/Packages/Audience/Tests/Runtime/IdentityTests.cs
rename to src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs
index 1518d62d6..cd2cf5fd6 100644
--- a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs
+++ b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs
@@ -31,7 +31,7 @@ public void NewDirectory_GeneratesNonEmptyId_AndWritesFile()
Assert.IsNotNull(id);
Assert.IsNotEmpty(id);
- var filePath = Path.Combine(_testDir, "imtbl_audience", "identity");
+ var filePath = AudiencePaths.IdentityFile(_testDir);
Assert.IsTrue(File.Exists(filePath), "identity file should exist on disk");
}
@@ -40,9 +40,9 @@ public void ExistingFile_ReturnsPreviousId_WithoutGeneratingNew()
{
// Simulate a returning player by pre-writing an identity file (as a previous launch would have done).
var expectedId = "pre-existing-id-from-last-launch";
- var dir = Path.Combine(_testDir, "imtbl_audience");
+ var dir = AudiencePaths.AudienceDir(_testDir);
Directory.CreateDirectory(dir);
- File.WriteAllText(Path.Combine(dir, "identity"), expectedId);
+ File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId);
var result = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous);
@@ -76,8 +76,29 @@ public void ConsentNone_ReturnsNull_AndNoFileWritten()
Assert.IsNull(id);
- var filePath = Path.Combine(_testDir, "imtbl_audience", "identity");
+ var filePath = AudiencePaths.IdentityFile(_testDir);
Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None");
}
+
+ [Test]
+ public void Get_NoExistingFile_ReturnsNull_AndDoesNotCreate()
+ {
+ var id = Identity.Get(_testDir);
+
+ Assert.IsNull(id);
+ var filePath = AudiencePaths.IdentityFile(_testDir);
+ Assert.IsFalse(File.Exists(filePath), "Get must not create the identity file");
+ }
+
+ [Test]
+ public void Get_ExistingFile_ReturnsPersistedId()
+ {
+ var expectedId = "pre-existing-id";
+ var dir = AudiencePaths.AudienceDir(_testDir);
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId);
+
+ Assert.AreEqual(expectedId, Identity.Get(_testDir));
+ }
}
}
diff --git a/src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs
rename to src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs
diff --git a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs
new file mode 100644
index 000000000..67c4ad861
--- /dev/null
+++ b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs
@@ -0,0 +1,132 @@
+using NUnit.Framework;
+
+namespace Immutable.Audience.Tests
+{
+ [TestFixture]
+ internal class TypedEventTests
+ {
+ [Test]
+ public void Progression_EventName_IsProgression()
+ {
+ Assert.AreEqual("progression", new Progression().EventName);
+ }
+
+ [Test]
+ public void Progression_Complete_ProducesCorrectProperties()
+ {
+ var evt = new Progression
+ {
+ Status = ProgressionStatus.Complete,
+ World = "tutorial",
+ Level = "1",
+ Score = 1500,
+ DurationSec = 120.5f
+ };
+
+ var props = evt.ToProperties();
+
+ Assert.AreEqual("complete", props["status"]);
+ Assert.AreEqual("tutorial", props["world"]);
+ Assert.AreEqual("1", props["level"]);
+ Assert.AreEqual(1500, props["score"]);
+ Assert.AreEqual(120.5f, props["durationSec"]);
+ }
+
+ [Test]
+ public void Progression_OptionalFieldsOmitted_WhenNull()
+ {
+ var props = new Progression { Status = ProgressionStatus.Start }.ToProperties();
+
+ Assert.IsTrue(props.ContainsKey("status"));
+ Assert.IsFalse(props.ContainsKey("world"));
+ Assert.IsFalse(props.ContainsKey("level"));
+ Assert.IsFalse(props.ContainsKey("stage"));
+ Assert.IsFalse(props.ContainsKey("score"));
+ Assert.IsFalse(props.ContainsKey("durationSec"));
+ }
+
+ [Test]
+ public void Resource_Source_ProducesCorrectProperties()
+ {
+ var evt = new Resource
+ {
+ Flow = ResourceFlow.Source,
+ Currency = "gold",
+ Amount = 100,
+ ItemType = "quest_reward",
+ ItemId = "main_quest_01"
+ };
+
+ var props = evt.ToProperties();
+
+ Assert.AreEqual("source", props["flow"]);
+ Assert.AreEqual("gold", props["currency"]);
+ Assert.AreEqual(100m, props["amount"]);
+ Assert.AreEqual("quest_reward", props["itemType"]);
+ Assert.AreEqual("main_quest_01", props["itemId"]);
+ }
+
+ [Test]
+ public void Resource_EventName_IsResource()
+ {
+ Assert.AreEqual("resource", new Resource().EventName);
+ }
+
+ [Test]
+ public void Purchase_ProducesCorrectProperties()
+ {
+ var evt = new Purchase
+ {
+ Currency = "USD",
+ Value = 9.99m,
+ ItemId = "gem_pack_01",
+ ItemName = "Starter Gem Pack",
+ Quantity = 1,
+ TransactionId = "txn_abc123"
+ };
+
+ var props = evt.ToProperties();
+
+ Assert.AreEqual("USD", props["currency"]);
+ Assert.AreEqual(9.99m, props["value"]);
+ Assert.AreEqual("gem_pack_01", props["itemId"]);
+ Assert.AreEqual("Starter Gem Pack", props["itemName"]);
+ Assert.AreEqual(1, props["quantity"]);
+ Assert.AreEqual("txn_abc123", props["transactionId"]);
+ }
+
+ [Test]
+ public void Purchase_OptionalFieldsOmitted_WhenNull()
+ {
+ var props = new Purchase { Currency = "EUR", Value = 5.00m }.ToProperties();
+
+ Assert.IsTrue(props.ContainsKey("currency"));
+ Assert.IsTrue(props.ContainsKey("value"));
+ Assert.IsFalse(props.ContainsKey("itemId"));
+ Assert.IsFalse(props.ContainsKey("itemName"));
+ Assert.IsFalse(props.ContainsKey("quantity"));
+ Assert.IsFalse(props.ContainsKey("transactionId"));
+ }
+
+ [Test]
+ public void Purchase_EventName_IsPurchase()
+ {
+ Assert.AreEqual("purchase", new Purchase().EventName);
+ }
+
+ [Test]
+ public void MilestoneReached_ProducesCorrectProperties()
+ {
+ var props = new MilestoneReached { Name = "first_boss_defeated" }.ToProperties();
+
+ Assert.AreEqual("first_boss_defeated", props["name"]);
+ Assert.AreEqual(1, props.Count);
+ }
+
+ [Test]
+ public void MilestoneReached_EventName_IsMilestoneReached()
+ {
+ Assert.AreEqual("milestone_reached", new MilestoneReached().EventName);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs
new file mode 100644
index 000000000..7b2ed71c7
--- /dev/null
+++ b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs
@@ -0,0 +1,32 @@
+using NUnit.Framework;
+
+namespace Immutable.Audience.Tests
+{
+ [TestFixture]
+ internal class IdentityTypeTests
+ {
+ [TestCase(IdentityType.Passport, "passport")]
+ [TestCase(IdentityType.Steam, "steam")]
+ [TestCase(IdentityType.Epic, "epic")]
+ [TestCase(IdentityType.Google, "google")]
+ [TestCase(IdentityType.Apple, "apple")]
+ [TestCase(IdentityType.Discord, "discord")]
+ [TestCase(IdentityType.Email, "email")]
+ [TestCase(IdentityType.Custom, "custom")]
+ public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(IdentityType type, string expected)
+ {
+ Assert.AreEqual(expected, type.ToLowercaseString());
+ }
+
+ [Test]
+ public void ToLowercaseString_UnknownValue_ReturnsNull()
+ {
+ // Never-throw contract: an out-of-range cast should not surface an
+ // exception on the game thread. Callers drop the event via their
+ // null/empty check instead.
+ var invalid = (IdentityType)999;
+
+ Assert.IsNull(invalid.ToLowercaseString());
+ }
+ }
+}
diff --git a/src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs
rename to src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs
diff --git a/src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs.meta
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs.meta
rename to src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs.meta
diff --git a/src/Packages/Audience/Tests/Runtime/EventQueueTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/EventQueueTests.cs
rename to src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs
diff --git a/src/Packages/Audience/Tests/Runtime/EventQueueTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs.meta
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/EventQueueTests.cs.meta
rename to src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs.meta
diff --git a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs
rename to src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs
diff --git a/src/Packages/Audience/Tests/Runtime/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/GzipTests.cs
rename to src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs
diff --git a/src/Packages/Audience/Tests/Runtime/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs
similarity index 100%
rename from src/Packages/Audience/Tests/Runtime/JsonTests.cs
rename to src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs