diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 7dbe38cd6..5ba512b02 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -105,10 +105,22 @@ internal async Task SendBatchAsync(CancellationToken ct = default) { // 4xx: server rejected the payload. Drop it (retry won't help) and // reset backoff — server is healthy, our data was the problem. + // Pull the response body so the studio has something actionable: + // the status code alone just says "something is wrong"; the body + // names which field or event broke validation. Truncated to keep + // logs sane if the backend ever returns a long diagnostic. + string? body = null; + try { body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); } + catch { /* Content read failed (disposed, network) — fall through with body=null. */ } + _store.Delete(batch); ResetBackoff(); - NotifyError(AudienceErrorCode.ValidationRejected, - $"Batch rejected with {statusCode}"); + + const int maxBodyLen = 500; + var message = string.IsNullOrEmpty(body) + ? $"Batch rejected with {statusCode}" + : $"Batch rejected with {statusCode}: {(body!.Length > maxBodyLen ? body.Substring(0, maxBodyLen) + "…" : body)}"; + NotifyError(AudienceErrorCode.ValidationRejected, message); } else { @@ -176,7 +188,7 @@ private void ResetBackoff() // unreadable; the caller treats null as "nothing to send". private static string? BuildPayload(IReadOnlyList paths) { - var sb = new StringBuilder("{\"batch\":["); + var sb = new StringBuilder("{\"messages\":["); var count = 0; for (var i = 0; i < paths.Count; i++) diff --git a/src/Packages/Audience/Samples/QuickStart/AudienceDemo.cs b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.cs new file mode 100644 index 000000000..9978cbed5 --- /dev/null +++ b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.cs @@ -0,0 +1,365 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Immutable.Audience.Samples.QuickStart +{ + public sealed class AudienceDemo : MonoBehaviour + { + [Header("Publishable key")] + [Tooltip("Your publishable key. Test keys start with pk_imapik-test-.")] + public string PublishableKey = "pk_imapik-test-REPLACE_ME"; + + [Header("Environment")] + [Tooltip("Which Immutable backend to send events to. Sandbox is the safe default " + + "for development; switch to Production explicitly when shipping to live " + + "players. Dev is reserved for Immutable engineers.")] + public AudienceEnvironment Environment = AudienceEnvironment.Sandbox; + + [Header("Starting consent")] + [Tooltip("Starting consent level. Studios normally collect this from the player.")] + public ConsentLevel StartingConsent = ConsentLevel.Anonymous; + + [Header("Distribution platform")] + [Tooltip("Optional — use DistributionPlatforms.Steam / .Epic / .GOG / .Itch / .Standalone for autocomplete, " + + "or any custom string. Sent as a property on game_launch. " + + "Defaults to Standalone so first-run sample data does not falsely tag every integrator as Steam.")] + public string DistributionPlatform = DistributionPlatforms.Standalone; + + [Tooltip("Enable ambient [ImmutableAudience] log lines.")] + public bool DebugLogging = true; + + public void InitSdk() + { + ImmutableAudience.Init(new AudienceConfig + { + PublishableKey = PublishableKey, + Environment = Environment, + Consent = StartingConsent, + DistributionPlatform = DistributionPlatform, + Debug = DebugLogging, + OnError = err => Debug.LogWarning($"[AudienceDemo] SDK error: {err.Code} — {err.Message}"), + }); + } + + public void ShutdownSdk() => ImmutableAudience.Shutdown(); + + // async void swallows exceptions; try/catch routes them through OnError instead. + public async void FlushNow() + { + try + { + await ImmutableAudience.FlushAsync(); + } + catch (System.Exception ex) + { + Debug.LogWarning($"[AudienceDemo] FlushAsync threw: {ex.Message}"); + } + } + + public void RequestGdprErasure() => ImmutableAudience.DeleteData(); + + public void FireProgressionStart() => ImmutableAudience.Track(new Progression + { + Status = ProgressionStatus.Start, + World = "overworld", + Level = "stone_age", + }); + + public void FireProgressionComplete() => ImmutableAudience.Track(new Progression + { + Status = ProgressionStatus.Complete, + World = "overworld", + Level = "stone_age", + Score = 1500, + DurationSec = 120f, + }); + + public void FireResourceEarn() => ImmutableAudience.Track(new Resource + { + Flow = ResourceFlow.Source, + Currency = "gold", + Amount = 100, + ItemType = "monster_kill", + ItemId = "zombie", + }); + + public void FireResourceSpend() => ImmutableAudience.Track(new Resource + { + Flow = ResourceFlow.Sink, + Currency = "gold", + Amount = 50, + ItemType = "weapon", + ItemId = "diamond_sword", + }); + + // Production: use the payment provider's stable order id, not a fresh GUID. + public void FirePurchase() => ImmutableAudience.Track(new Purchase + { + Currency = "USD", + Value = 9.99m, + ItemId = "skin_pack_knight", + ItemName = "Knight Skin Pack", + Quantity = 1, + TransactionId = System.Guid.NewGuid().ToString(), + }); + + public void FireMilestone() => ImmutableAudience.Track(new MilestoneReached + { + Name = "dragon_defeated", + }); + + public void FireCustomEvent() => ImmutableAudience.Track("crafting_started", new Dictionary + { + ["recipe_id"] = "diamond_sword", + ["station"] = "crafting_table", + ["player_level"] = 20, + }); + + public void IdentifyAsSteam() => + ImmutableAudience.Identify("76561198012345", IdentityType.Steam); + + public void AliasSteamToPassport() => ImmutableAudience.Alias( + fromId: "76561198012345", fromType: IdentityType.Steam, + toId: "user_abc", toType: IdentityType.Passport); + + public void ResetIdentity() => ImmutableAudience.Reset(); + + public void ConsentNone() => ImmutableAudience.SetConsent(ConsentLevel.None); + public void ConsentAnonymous() => ImmutableAudience.SetConsent(ConsentLevel.Anonymous); + public void ConsentFull() => ImmutableAudience.SetConsent(ConsentLevel.Full); + + private void OnGUI() + { + const float padding = 8f; + const float buttonHeight = 44f; + const float maxPanelWidth = 800f; + var panelWidth = Mathf.Min(Screen.width - padding * 2, maxPanelWidth); + var panelX = (Screen.width - panelWidth) * 0.5f; + + EnsureStyles(); + + var init = ImmutableAudience.Initialized; + var consent = ImmutableAudience.CurrentConsent; + var canTrack = init && consent != ConsentLevel.None; + var canIdentify = init && consent == ConsentLevel.Full; + + GUILayout.BeginArea(new Rect(panelX, padding, panelWidth, Screen.height - padding * 2)); + _scroll = GUILayout.BeginScrollView(_scroll); + + GUILayout.Label("Immutable Audience — QuickStart", _titleStyle); + GUILayout.Label( + "Press a button to send a sample event. The panel below shows what the SDK is doing. " + + "Check the Unity Console for log output.", + _introStyle); + + DrawStatusPanel(); + + DrawSection("SDK lifecycle", + "Start, stop, and flush the SDK. Press Start the SDK first — every " + + "other button stays disabled until the SDK is initialised."); + TwoColumnButtons(buttonHeight, + ("Start the SDK\n(Init)", InitSdk, !init), + ("Turn off the SDK\n(Shutdown)", ShutdownSdk, init), + ("Send queued events now\n(FlushAsync)", FlushNow, init)); + + DrawSection("Typed events", + "Standard event types Immutable's dashboards chart automatically: " + + "player progression, currency in/out, purchases, achievements."); + TwoColumnButtons(buttonHeight, + ("Player started a level\n(Progression.Start)", FireProgressionStart, canTrack), + ("Player finished a level\n(Progression.Complete)", FireProgressionComplete, canTrack), + ("Player earned currency\n(Resource.Source)", FireResourceEarn, canTrack), + ("Player spent currency\n(Resource.Sink)", FireResourceSpend, canTrack), + ("Player made a purchase\n(Purchase)", FirePurchase, canTrack), + ("Player reached a milestone\n(MilestoneReached)", FireMilestone, canTrack)); + + DrawSection("Custom event", + "Send any event you want. You pick the name and the data — Immutable stores both."); + TwoColumnButtons(buttonHeight, + ("Send a custom event\n(Track(\"crafting_started\"))", FireCustomEvent, canTrack)); + + DrawSection("Identity", + "Tell Immutable who's playing. Identify links events to a player. " + + "Alias merges two accounts into one. Reset clears the link."); + TwoColumnButtons(buttonHeight, + ("Identify player by Steam ID\n(Identify)", IdentifyAsSteam, canIdentify), + ("Link Steam → Passport\n(Alias)", AliasSteamToPassport, canIdentify), + ("⚠ Forget who's playing\n(Reset)", ResetIdentity, init)); + + DrawSection("Privacy consent", + "What can Immutable track? None: nothing. Anonymous: counts only, no player id. " + + "Full: keep the player id alongside events."); + TwoColumnButtons(buttonHeight, + ("Stop tracking\n(SetConsent(None))", ConsentNone, init && consent != ConsentLevel.None), + ("Track anonymously\n(SetConsent(Anonymous))", ConsentAnonymous, init && consent != ConsentLevel.Anonymous), + ("Track with player ID\n(SetConsent(Full))", ConsentFull, init && consent != ConsentLevel.Full)); + + DrawSection("Advanced", + "Danger zone. Deleting a player's data asks Immutable to erase " + + "everything the backend has stored for that player — GDPR / " + + "right-to-be-forgotten territory, not recoverable."); + TwoColumnButtons(buttonHeight, + ("⚠ Delete this player's data (GDPR)\n(DeleteData)", RequestGdprErasure, init)); + + GUILayout.EndScrollView(); + GUILayout.EndArea(); + } + + private Vector2 _scroll; + + private static GUIStyle _titleStyle; + private static GUIStyle _introStyle; + private static GUIStyle _sectionHeaderStyle; + private static GUIStyle _sectionDescStyle; + private static GUIStyle _statusBoxStyle; + private static GUIStyle _statusLabelStyle; + private static GUIStyle _statusValueStyle; + private static GUIStyle _statusValueWarnStyle; + private static GUIStyle _copyButtonStyle; + + private static void EnsureStyles() + { + if (_titleStyle != null) return; + + _titleStyle = new GUIStyle(GUI.skin.label) + { + fontStyle = FontStyle.Bold, + fontSize = 15, + }; + _introStyle = new GUIStyle(GUI.skin.label) + { + wordWrap = true, + fontStyle = FontStyle.Italic, + }; + _sectionHeaderStyle = new GUIStyle(GUI.skin.label) + { + fontStyle = FontStyle.Bold, + fontSize = 13, + margin = new RectOffset(0, 0, 6, 2), + }; + _sectionDescStyle = new GUIStyle(GUI.skin.label) + { + wordWrap = true, + fontStyle = FontStyle.Italic, + margin = new RectOffset(0, 0, 0, 4), + }; + _statusBoxStyle = new GUIStyle(GUI.skin.box) + { + padding = new RectOffset(8, 8, 6, 6), + }; + _statusLabelStyle = new GUIStyle(GUI.skin.label) + { + fontStyle = FontStyle.Bold, + }; + _statusValueStyle = new GUIStyle(GUI.skin.label) + { + wordWrap = true, + }; + _statusValueWarnStyle = new GUIStyle(GUI.skin.label) + { + wordWrap = true, + normal = { textColor = new Color(1f, 0.55f, 0.4f) }, + }; + _copyButtonStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 10, + padding = new RectOffset(4, 4, 2, 2), + margin = new RectOffset(4, 0, 2, 0), + }; + } + + private void DrawSection(string title, string description) + { + GUILayout.Label(title, _sectionHeaderStyle); + GUILayout.Label(description, _sectionDescStyle); + } + + private void DrawStatusPanel() + { + GUILayout.Space(4); + GUILayout.BeginVertical(_statusBoxStyle); + + GUILayout.Label("SDK status", _sectionHeaderStyle); + + DrawStatusRow("Initialized", ImmutableAudience.Initialized ? "yes" : "no"); + DrawStatusRow("Environment", ImmutableAudience.CurrentEnvironment.ToString()); + DrawStatusRow("Consent", ImmutableAudience.CurrentConsent.ToString()); + DrawStatusRow("Pub key", FormatPublishableKey(out var pubKeyIsWarning), pubKeyIsWarning, + copyValue: pubKeyIsWarning ? null : PublishableKey); + DrawStatusRow("User ID", ImmutableAudience.UserId ?? "(none)", + copyValue: ImmutableAudience.UserId); + DrawStatusRow("Anon ID", ImmutableAudience.AnonymousId ?? "(none — needs consent above None)", + copyValue: ImmutableAudience.AnonymousId); + DrawStatusRow("Session ID", ImmutableAudience.SessionId ?? "(none)", + copyValue: ImmutableAudience.SessionId); + DrawStatusRow("Queued", ImmutableAudience.QueueSize.ToString()); + + GUILayout.EndVertical(); + GUILayout.Space(4); + } + + private static void DrawStatusRow(string label, string value, bool warn = false, string copyValue = null) + { + GUILayout.BeginHorizontal(); + GUILayout.Label(label, _statusLabelStyle, GUILayout.Width(100)); + GUILayout.Label(value, warn ? _statusValueWarnStyle : _statusValueStyle); + if (!string.IsNullOrEmpty(copyValue)) + { + if (GUILayout.Button("Copy", _copyButtonStyle, GUILayout.Width(50))) + { + GUIUtility.systemCopyBuffer = copyValue; + } + } + GUILayout.EndHorizontal(); + } + + private string FormatPublishableKey(out bool isWarning) + { + if (string.IsNullOrEmpty(PublishableKey)) + { + isWarning = true; + return "⚠ (not set — set in Inspector)"; + } + if (PublishableKey.EndsWith("REPLACE_ME")) + { + isWarning = true; + return "⚠ " + PublishableKey + " — set your real key in the Inspector"; + } + isWarning = false; + return PublishableKey; + } + + private static void TwoColumnButtons(float buttonHeight, params (string label, System.Action action, bool enabled)[] buttons) + { + for (var i = 0; i < buttons.Length; i += 2) + { + GUILayout.BeginHorizontal(); + DrawCellButton(buttons[i], buttonHeight); + if (i + 1 < buttons.Length) + { + DrawCellButton(buttons[i + 1], buttonHeight); + } + else + { + GUILayout.Box(GUIContent.none, GUIStyle.none, + GUILayout.Height(buttonHeight), + GUILayout.ExpandWidth(true)); + } + GUILayout.EndHorizontal(); + } + } + + private static void DrawCellButton((string label, System.Action action, bool enabled) button, float buttonHeight) + { + var prev = GUI.enabled; + GUI.enabled = prev && button.enabled; + if (GUILayout.Button(button.label, + GUILayout.Height(buttonHeight), + GUILayout.ExpandWidth(true))) + { + button.action(); + } + GUI.enabled = prev; + } + } +} diff --git a/src/Packages/Audience/Samples/QuickStart/AudienceDemo.cs.meta b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.cs.meta new file mode 100644 index 000000000..0819c70f6 --- /dev/null +++ b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45f9cfcf4409944b9beb0feafcf79b8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Samples/QuickStart/AudienceDemo.unity b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.unity new file mode 100644 index 000000000..2f96eb0a7 --- /dev/null +++ b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.unity @@ -0,0 +1,257 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 9 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GIWorkflowMode: 1 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_FinalGather: 0 + m_FinalGatherFiltering: 1 + m_FinalGatherRayCount: 256 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 2 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + accuratePlacement: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &32966353 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 32966355} + - component: {fileID: 32966354} + m_Layer: 0 + m_Name: AudienceDemo + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &32966354 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 32966353} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 45f9cfcf4409944b9beb0feafcf79b8b, type: 3} + m_Name: + m_EditorClassIdentifier: + PublishableKey: pk_imapik-test-REPLACE_ME + Environment: 0 + StartingConsent: 1 + DistributionPlatform: standalone + DebugLogging: 1 +--- !u!4 &32966355 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 32966353} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 1 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1432870243 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1432870246} + - component: {fileID: 1432870245} + - component: {fileID: 1432870244} + m_Layer: 0 + m_Name: Camera + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!81 &1432870244 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1432870243} + m_Enabled: 1 +--- !u!20 &1432870245 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1432870243} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 2 + m_BackGroundColor: {r: 0, g: 0, b: 0, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_FocalLength: 50 + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &1432870246 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1432870243} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 0 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/src/Packages/Audience/Samples/QuickStart/AudienceDemo.unity.meta b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.unity.meta new file mode 100644 index 000000000..652a77676 --- /dev/null +++ b/src/Packages/Audience/Samples/QuickStart/AudienceDemo.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1e64444dd4f204a44995cdeacc89ce08 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Samples/QuickStart/Immutable.Audience.Samples.QuickStart.asmdef b/src/Packages/Audience/Samples/QuickStart/Immutable.Audience.Samples.QuickStart.asmdef new file mode 100644 index 000000000..b9ef3019a --- /dev/null +++ b/src/Packages/Audience/Samples/QuickStart/Immutable.Audience.Samples.QuickStart.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Immutable.Audience.Samples.QuickStart", + "rootNamespace": "Immutable.Audience.Samples.QuickStart", + "references": ["Immutable.Audience.Runtime"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/src/Packages/Audience/Samples/QuickStart/Immutable.Audience.Samples.QuickStart.asmdef.meta b/src/Packages/Audience/Samples/QuickStart/Immutable.Audience.Samples.QuickStart.asmdef.meta new file mode 100644 index 000000000..bc8b8cf04 --- /dev/null +++ b/src/Packages/Audience/Samples/QuickStart/Immutable.Audience.Samples.QuickStart.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 387908c858c224aaba8287f525af19a7 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index 3aa3aab34..2cfa8ac1b 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -87,7 +87,7 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() Assert.AreEqual("gzip", capturedContentEncoding); var decompressed = DecompressGzip(capturedBody); - StringAssert.StartsWith("{\"batch\":[", decompressed); + StringAssert.StartsWith("{\"messages\":[", decompressed); StringAssert.EndsWith("]}", decompressed); StringAssert.Contains("\"eventName\":\"test\"", decompressed); } @@ -116,7 +116,7 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding Assert.AreEqual("pk_imapik-test-key1", capturedKey); Assert.AreEqual("application/json", capturedContentType); Assert.AreEqual(0, capturedContentEncodingCount, "no Content-Encoding header is permitted in v1"); - StringAssert.StartsWith("{\"batch\":[", capturedBody); + StringAssert.StartsWith("{\"messages\":[", capturedBody); StringAssert.EndsWith("]}", capturedBody); StringAssert.Contains("\"eventName\":\"test\"", capturedBody); } @@ -186,7 +186,8 @@ public async Task SendBatchAsync_4xx_DeletesFilesAndResetsBackoff() { _store.Write("{\"type\":\"track\"}"); - var handler = new MockHandler(HttpStatusCode.BadRequest, ""); + var handler = new MockHandler(HttpStatusCode.BadRequest, + "{\"error\":\"invalid eventName format at /batch/0/eventName\"}"); AudienceError reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", onError: e => reportedError = e, handler: handler); @@ -197,6 +198,9 @@ public async Task SendBatchAsync_4xx_DeletesFilesAndResetsBackoff() Assert.IsFalse(transport.IsInBackoffWindow); Assert.IsNotNull(reportedError); Assert.AreEqual(AudienceErrorCode.ValidationRejected, reportedError.Code); + // Backend-supplied diagnostic must reach the studio, otherwise a 400 + // collapses to "something broke, good luck" with no actionable signal. + StringAssert.Contains("invalid eventName format", reportedError.Message); } [Test] diff --git a/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs index 074dd21f8..1957f7b22 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs @@ -29,7 +29,7 @@ public void Compress_ProducesValidGzip_ThatDecompressesToOriginal() public void Compress_OutputIsSmallerThanInput_ForRealisticPayload() { // Repeated field names compress well in JSON batches. - var sb = new StringBuilder("{\"batch\":["); + var sb = new StringBuilder("{\"messages\":["); for (var i = 0; i < 20; i++) { if (i > 0) sb.Append(',');