From 69124392b1155d46f48ab479a89ce78a70ab0571 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Tue, 30 Jun 2026 17:08:01 +1200 Subject: [PATCH] feat(audience): add AchievementUnlocked standard event to Unity SDK Adds the AchievementUnlocked typed event with required AchievementId/AchievementName and optional AchievementType (onboarding, progression, mastery, social, collection), matching the CDP contract event catalogue. --- .../Scripts/AudienceSample.Events.cs | 29 ++++++ .../Tests/Runtime/SampleAppLiveFireTests.cs | 10 ++ src/Packages/Audience/README.md | 2 +- .../Audience/Runtime/Events/TypedEvents.cs | 93 +++++++++++++++++++ .../Tests/Runtime/Events/TypedEventTests.cs | 55 +++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index 245240707..f9c625fba 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -77,6 +77,13 @@ internal readonly struct EventSpec EventField.Text("itemType", optional: true), EventField.Text("itemId", optional: true), }), + new EventSpec("achievement_unlocked", new[] { + EventField.Text("achievementId"), + EventField.Text("achievementName"), + EventField.Enum("achievementType", + new[] { "onboarding", "progression", "mastery", "social", "collection" }, + optional: true), + }), new EventSpec("milestone_reached", new[] { EventField.Text("name") }), new EventSpec("game_page_viewed", new[] { EventField.Text("gameId"), @@ -128,6 +135,13 @@ internal readonly struct EventSpec Quantity = OptionalInt(props, "quantity"), TransactionId = OptionalString(props, "transactionId"), }; + case "achievement_unlocked": + return new AchievementUnlocked + { + AchievementId = OptionalString(props, "achievementId") ?? "", + AchievementName = OptionalString(props, "achievementName") ?? "", + AchievementType = ParseAchievementType(props), + }; case "milestone_reached": return new MilestoneReached { Name = OptionalString(props, "name") ?? "" }; default: @@ -148,6 +162,21 @@ internal readonly struct EventSpec }; } + private static AchievementType? ParseAchievementType(Dictionary props) + { + var s = OptionalString(props, "achievementType"); + if (string.IsNullOrEmpty(s)) return null; + return s switch + { + "onboarding" => AchievementType.Onboarding, + "progression" => AchievementType.Progression, + "mastery" => AchievementType.Mastery, + "social" => AchievementType.Social, + "collection" => AchievementType.Collection, + _ => (AchievementType?)null, + }; + } + private static ResourceFlow? ParseResourceFlow(Dictionary props) { var s = OptionalString(props, "flow"); diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index 534bcf29c..a009c2ba0 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -209,6 +209,16 @@ public IEnumerator TypedEvent_MilestoneReached_FlushReportsOk() }); } + [UnityTest] + public IEnumerator TypedEvent_AchievementUnlocked_FlushReportsOk() + { + yield return DriveTypedEventAndFlush(SampleAppUi.Buttons.TypedEvent("achievement_unlocked"), root => + { + root.Q(SampleAppUi.TypedEventField("achievement_unlocked", "achievementId")).value = "ach_enemies_100"; + root.Q(SampleAppUi.TypedEventField("achievement_unlocked", "achievementName")).value = "100 Enemies Defeated"; + }); + } + // Shared driver: Init → fill required fields → click typed-event Send → Flush → // assert no error rows. fillFields is null when the event has no required fields // beyond defaults. diff --git a/src/Packages/Audience/README.md b/src/Packages/Audience/README.md index 05153200b..25e1877df 100644 --- a/src/Packages/Audience/README.md +++ b/src/Packages/Audience/README.md @@ -1,6 +1,6 @@ # Immutable Audience -Typed C# tracking SDK for Unity games. Captures `game_launch`, `session_start` / `session_heartbeat` / `session_end` automatically; predefined events (`Progression`, `Resource`, `Purchase`, `MilestoneReached`) and custom events on demand. +Typed C# tracking SDK for Unity games. Captures `game_launch`, `session_start` / `session_heartbeat` / `session_end` automatically; predefined events (`Progression`, `Resource`, `Purchase`, `AchievementUnlocked`, `MilestoneReached`) and custom events on demand. > **Status:** alpha. APIs and behavior may change between releases. diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index db22d4fdb..52474e01b 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -265,6 +265,99 @@ public Dictionary ToProperties() } } + /// + /// Category of an achievement. + /// + public enum AchievementType + { + /// + /// Introductory achievements tied to first-time player actions. + /// + Onboarding, + + /// + /// Achievements tied to advancing through story or level content. + /// + Progression, + + /// + /// Skill- or challenge-based achievements. + /// + Mastery, + + /// + /// Multiplayer or community achievements. + /// + Social, + + /// + /// Completionist achievements for gathering or finding items. + /// + Collection + } + + internal static class AchievementTypeExtensions + { + // Throws on unknown casts. AchievementUnlocked.ToProperties propagates, and + // Track(IEvent) catches and drops with a warning. + internal static string ToLowercaseString(this AchievementType type) => type switch + { + AchievementType.Onboarding => "onboarding", + AchievementType.Progression => "progression", + AchievementType.Mastery => "mastery", + AchievementType.Social => "social", + AchievementType.Collection => "collection", + _ => throw new ArgumentOutOfRangeException( + nameof(type), type, "Unhandled AchievementType"), + }; + } + + /// + /// Player unlocked an achievement. Track via + /// . + /// + public class AchievementUnlocked : IEvent + { + /// + /// Required. Stable identifier for the achievement (for example, + /// ach_enemies_100). + /// + public string? AchievementId { get; set; } + + /// + /// Required. Display name of the achievement (for example, + /// 100 Enemies Defeated). + /// + public string? AchievementName { get; set; } + + /// + /// Optional. Category of the achievement. + /// + public AchievementType? AchievementType { get; set; } + + /// + public string EventName => "achievement_unlocked"; + + /// + public Dictionary ToProperties() + { + if (string.IsNullOrEmpty(AchievementId)) + throw new ArgumentException("AchievementUnlocked.AchievementId is required. Set a non-empty string before calling Track(IEvent)."); + if (string.IsNullOrEmpty(AchievementName)) + throw new ArgumentException("AchievementUnlocked.AchievementName is required. Set a non-empty string before calling Track(IEvent)."); + + var props = new Dictionary + { + ["achievement_id"] = AchievementId, + ["achievement_name"] = AchievementName + }; + + if (AchievementType.HasValue) props["achievement_type"] = AchievementType.Value.ToLowercaseString(); + + return props; + } + } + /// /// Named milestone or achievement reached by the player. Track via /// . diff --git a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs index ee85afaa8..c59527143 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs @@ -169,6 +169,61 @@ public void Purchase_WithoutValue_ThrowsOnToProperties() Assert.That(ex!.Message, Does.Contain("Value")); } + [Test] + public void AchievementUnlocked_EventName_IsAchievementUnlocked() + { + Assert.AreEqual("achievement_unlocked", new AchievementUnlocked().EventName); + } + + [Test] + public void AchievementUnlocked_WithoutAchievementId_ThrowsOnToProperties() + { + var evt = new AchievementUnlocked { AchievementName = "100 Enemies Defeated" }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("AchievementId")); + } + + [Test] + public void AchievementUnlocked_WithoutAchievementName_ThrowsOnToProperties() + { + var evt = new AchievementUnlocked { AchievementId = "ach_enemies_100" }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("AchievementName")); + } + + [Test] + public void AchievementUnlocked_ProducesCorrectProperties() + { + var evt = new AchievementUnlocked + { + AchievementId = "ach_enemies_100", + AchievementName = "100 Enemies Defeated", + AchievementType = Immutable.Audience.AchievementType.Mastery, + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("ach_enemies_100", props["achievement_id"]); + Assert.AreEqual("100 Enemies Defeated", props["achievement_name"]); + Assert.AreEqual("mastery", props["achievement_type"]); + } + + [Test] + public void AchievementUnlocked_OptionalFieldsOmitted_WhenNull() + { + var props = new AchievementUnlocked + { + AchievementId = "ach_enemies_100", + AchievementName = "100 Enemies Defeated", + }.ToProperties(); + + Assert.IsTrue(props.ContainsKey("achievement_id")); + Assert.IsTrue(props.ContainsKey("achievement_name")); + Assert.IsFalse(props.ContainsKey("achievement_type")); + } + [Test] public void MilestoneReached_ProducesCorrectProperties() {