Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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:
Expand All @@ -148,6 +162,21 @@ internal readonly struct EventSpec
};
}

private static AchievementType? ParseAchievementType(Dictionary<string, object> 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<string, object> props)
{
var s = OptionalString(props, "flow");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextField>(SampleAppUi.TypedEventField("achievement_unlocked", "achievementId")).value = "ach_enemies_100";
root.Q<TextField>(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.
Expand Down
2 changes: 1 addition & 1 deletion src/Packages/Audience/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
93 changes: 93 additions & 0 deletions src/Packages/Audience/Runtime/Events/TypedEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,99 @@ public Dictionary<string, object> ToProperties()
}
}

/// <summary>
/// Category of an achievement.
/// </summary>
public enum AchievementType
{
/// <summary>
/// Introductory achievements tied to first-time player actions.
/// </summary>
Onboarding,

/// <summary>
/// Achievements tied to advancing through story or level content.
/// </summary>
Progression,

/// <summary>
/// Skill- or challenge-based achievements.
/// </summary>
Mastery,

/// <summary>
/// Multiplayer or community achievements.
/// </summary>
Social,

/// <summary>
/// Completionist achievements for gathering or finding items.
/// </summary>
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"),
};
}

/// <summary>
/// Player unlocked an achievement. Track via
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
/// </summary>
public class AchievementUnlocked : IEvent
{
/// <summary>
/// Required. Stable identifier for the achievement (for example,
/// <c>ach_enemies_100</c>).
/// </summary>
public string? AchievementId { get; set; }

/// <summary>
/// Required. Display name of the achievement (for example,
/// <c>100 Enemies Defeated</c>).
/// </summary>
public string? AchievementName { get; set; }

/// <summary>
/// Optional. Category of the achievement.
/// </summary>
public AchievementType? AchievementType { get; set; }

/// <inheritdoc/>
public string EventName => "achievement_unlocked";

/// <inheritdoc/>
public Dictionary<string, object> 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<string, object>
{
["achievement_id"] = AchievementId,
["achievement_name"] = AchievementName
};

if (AchievementType.HasValue) props["achievement_type"] = AchievementType.Value.ToLowercaseString();

return props;
}
}

/// <summary>
/// Named milestone or achievement reached by the player. Track via
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
Expand Down
55 changes: 55 additions & 0 deletions src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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()
{
Expand Down
Loading