diff --git a/docs/bot-api-docs b/docs/bot-api-docs
index 0bdd8491..fb9c511c 160000
--- a/docs/bot-api-docs
+++ b/docs/bot-api-docs
@@ -1 +1 @@
-Subproject commit 0bdd84916d8528aeee7f8cf05b1ba822cb50457a
+Subproject commit fb9c511cf50f77f1591bb97f551ac9e8d1b294c7
diff --git a/src/Argon.Api/Grains/CosmeticsGrain.cs b/src/Argon.Api/Grains/CosmeticsGrain.cs
index e308b27f..5d1be9ed 100644
--- a/src/Argon.Api/Grains/CosmeticsGrain.cs
+++ b/src/Argon.Api/Grains/CosmeticsGrain.cs
@@ -22,8 +22,8 @@ namespace Argon.Grains;
/// the moment the soonest grant anything worn depends on runs out. The read path never asks — see
/// .
///
-/// Every kind this build ships is worn one at a time, so everything goes in slot 0. The column
-/// stays for the kinds that will be worn several at once.
+/// A kind worn one at a time goes in slot 0. One worn several at once — badges — takes slots
+/// from 0 in the order the wearer gave them, and putting the kind on again replaces all of them.
///
public sealed class CosmeticsGrain(
IDbContextFactory context,
@@ -81,7 +81,9 @@ public async Task GetMyCosmeticsAsync()
public async Task EquipAsync(IWornCosmetic cosmetic)
=> cosmetic switch
{
- WornItem item => await EquipItemAsync(item.itemId),
+ WornItem item => await EquipItemsAsync([item.itemId], null),
+ WornItems items => await EquipItemsAsync([.. items.itemIds], null),
+ WornScene scene => await EquipItemsAsync([scene.itemId], ProfileSceneKind.TuningOf(scene.reach), ProfileSceneKind.Key),
WornNickname nickname => NicknameStyleKind.FromWire(nickname) is { } look
? await ComposeAsync(NicknameStyleKind.Key, look.Options, look.Tuning)
: Fail(CosmeticError.VALUE_OUT_OF_RANGE),
@@ -97,8 +99,9 @@ public async Task UnequipAsync(string kindKey)
await using var ctx = await context.CreateDbContextAsync();
+ // Every slot: a kind worn several at once comes off whole, the way it went on.
var removed = await ctx.CosmeticEquips
- .Where(row => row.UserId == UserId && row.KindKey == kindKey && row.SlotIndex == Slot)
+ .Where(row => row.UserId == UserId && row.KindKey == kindKey)
.ExecuteDeleteAsync();
if (removed is 0)
@@ -210,50 +213,113 @@ bool MayStillWear(Guid itemId)
return true;
}
- private async Task EquipItemAsync(Guid cosmeticId)
+ ///
+ /// Puts on catalogue rows of one kind, in slots from 0 in the order given, replacing whatever that
+ /// kind had: one row for a kind worn one at a time, up to its slot count for one worn several at
+ /// once.
+ ///
+ ///
+ /// A kind its wearer tunes stores what this equip says about it, and an equip that says
+ /// nothing clears it: what a profile carries back is exactly what was put on. Only a scene's
+ /// reach is tuning on a kind with rows, so holds the rows to the kind
+ /// the tuning was written for.
+ ///
+ private async Task EquipItemsAsync(IReadOnlyList itemIds, object? tuning, string? kindKey = null)
{
+ // An empty list names no kind, and the same row twice is one badge shown twice.
+ if (itemIds.Count is 0 || itemIds.Distinct().Count() != itemIds.Count)
+ return Fail(CosmeticError.VALUE_OUT_OF_RANGE);
+
await using var ctx = await context.CreateDbContextAsync();
- var item = await ctx.Cosmetics
+ var items = await ctx.Cosmetics
.AsNoTracking()
- .FirstOrDefaultAsync(row => row.Id == cosmeticId);
+ .Where(row => itemIds.Contains(row.Id))
+ .ToDictionaryAsync(row => row.Id);
- if (item is null)
+ if (items.Count != itemIds.Count)
return Fail(CosmeticError.NOT_FOUND);
- if (await CheckKindAsync(item.KindKey) is { } refused)
+ var first = items[itemIds[0]];
+
+ if (kindKey is not null && first.KindKey != kindKey)
+ return Fail(CosmeticError.NOT_FOUND);
+
+ // One kind per call: the slots being replaced are that kind's.
+ if (items.Values.Any(item => item.KindKey != first.KindKey))
+ return Fail(CosmeticError.VALUE_OUT_OF_RANGE);
+
+ if (await CheckKindAsync(first.KindKey) is { } refused)
return refused;
- var kind = registry.Find(item.KindKey)!;
+ var kind = registry.Find(first.KindKey)!;
+ var now = DateTimeOffset.UtcNow;
// A bare kind is composed, not equipped, and an option is only ever chosen on somebody's axis.
- if (kind.IsBare || kind.IsCompositional || !CosmeticAvailability.IsServable(item, DateTimeOffset.UtcNow))
+ if (kind.IsBare || kind.IsCompositional || items.Values.Any(item => !CosmeticAvailability.IsServable(item, now)))
return Fail(CosmeticError.ITEM_UNAVAILABLE);
- var grants = await ActiveGrantsAsync(ctx);
-
- if (!CosmeticWear.MayWear(item, kind, await HasPremiumAsync(ctx), grants.ContainsKey(item.Id)))
- return Fail(CosmeticError.NOT_OWNED);
+ if (itemIds.Count > kind.MaxSlots)
+ return Fail(CosmeticError.VALUE_OUT_OF_RANGE);
- var row = await SlotRowAsync(ctx, item.KindKey);
+ string? tuningJson = null;
- if (row is null)
+ if (tuning is not null)
{
- ctx.CosmeticEquips.Add(new CosmeticEquipEntity
+ // Held to the kind's own schema before it is stored, the same check a stored document gets.
+ // A kind with no tuning schema is refused by ValidateTuning itself.
+ tuningJson = CosmeticJson.Default.Write(tuning);
+
+ if (IsEmptyTuning(tuningJson) || !kind.ValidateTuning(tuningJson).IsValid)
+ return Fail(CosmeticError.VALUE_OUT_OF_RANGE);
+
+ // A reach is only the wearer's to choose where the row says so. Stored beside a fixed one,
+ // it would be sent to everybody looking and contradict the row.
+ if (!ProfileSceneKind.LeavesReachToWearer(first.Payload))
{
- UserId = UserId,
- KindKey = item.KindKey,
- SlotIndex = Slot,
- CosmeticItemId = item.Id,
- UpdatedAt = DateTimeOffset.UtcNow
- });
+ return Fail(CosmeticError.VALUE_OUT_OF_RANGE);
+ }
}
- else
+
+ var premium = await HasPremiumAsync(ctx);
+ var grants = await ActiveGrantsAsync(ctx);
+
+ if (items.Values.Any(item => !CosmeticWear.MayWear(item, kind, premium, grants.ContainsKey(item.Id))))
+ return Fail(CosmeticError.NOT_OWNED);
+
+ var rows = await ctx.CosmeticEquips
+ .Where(row => row.UserId == UserId && row.KindKey == kind.Key)
+ .ToListAsync();
+
+ for (var slot = 0; slot < itemIds.Count; slot++)
{
+ var row = rows.FirstOrDefault(existing => existing.SlotIndex == slot);
+
+ if (row is null)
+ {
+ row = new CosmeticEquipEntity
+ {
+ UserId = UserId,
+ KindKey = kind.Key,
+ SlotIndex = slot
+ };
+
+ ctx.CosmeticEquips.Add(row);
+ }
+
// What was chosen on the slot's axes belongs to the slot, not to the item in it, so
// swapping the item keeps it.
- row.CosmeticItemId = item.Id;
- row.UpdatedAt = DateTimeOffset.UtcNow;
+ row.CosmeticItemId = itemIds[slot];
+ row.UpdatedAt = now;
+
+ if (kind.Tuning is not null)
+ row.Tuning = tuningJson;
+ }
+
+ foreach (var row in rows)
+ {
+ if (row.SlotIndex >= itemIds.Count)
+ ctx.CosmeticEquips.Remove(row);
}
await ctx.SaveChangesAsync();
diff --git a/src/Argon.Core/Features/Cosmetics/CosmeticAssetMedia.cs b/src/Argon.Core/Features/Cosmetics/CosmeticAssetMedia.cs
index 7d75bd1c..753f420e 100644
--- a/src/Argon.Core/Features/Cosmetics/CosmeticAssetMedia.cs
+++ b/src/Argon.Core/Features/Cosmetics/CosmeticAssetMedia.cs
@@ -18,17 +18,21 @@ public static bool Accepts(CosmeticAssetKind kind, string? contentType)
return kind switch
{
- CosmeticAssetKind.Image => contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase),
- CosmeticAssetKind.Font => IsFont(contentType),
- _ => false
+ CosmeticAssetKind.Image => contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase),
+ CosmeticAssetKind.SpriteSheet => contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase),
+ CosmeticAssetKind.Video => contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase),
+ CosmeticAssetKind.Font => IsFont(contentType),
+ _ => false
};
}
public static string Describe(CosmeticAssetKind kind) => kind switch
{
- CosmeticAssetKind.Image => "an image",
- CosmeticAssetKind.Font => "a font file",
- _ => kind.ToString()
+ CosmeticAssetKind.Image => "an image",
+ CosmeticAssetKind.SpriteSheet => "a sprite sheet image",
+ CosmeticAssetKind.Video => "a video",
+ CosmeticAssetKind.Font => "a font file",
+ _ => kind.ToString()
};
private static bool IsFont(string contentType)
diff --git a/src/Argon.Core/Features/Cosmetics/CosmeticOrbitSatellites.cs b/src/Argon.Core/Features/Cosmetics/CosmeticOrbitSatellites.cs
new file mode 100644
index 00000000..5d546e52
--- /dev/null
+++ b/src/Argon.Core/Features/Cosmetics/CosmeticOrbitSatellites.cs
@@ -0,0 +1,605 @@
+namespace Argon.Features.Cosmetics;
+
+using ion.runtime;
+
+///
+/// What an orbit's geometry is held to, so that a catalogue row cannot arrange itself over somebody
+/// else's screen.
+///
+///
+/// An avatar's ornament is drawn wherever an avatar is — a message row, a member list, a mention —
+/// and unlike a frame it has no card whose width to belong to. Everything here is a percentage of
+/// the face's own size for exactly that reason: the same row has to read at 96 pixels and at 22.
+///
+public static class CosmeticOrbitLimits
+{
+ ///
+ /// How many figures one face may carry.
+ ///
+ ///
+ /// Four was chosen for one large figure and its companions, and it is the wrong shape of
+ /// limit: a swarm of small things crawling over a face is a different cosmetic from a raven
+ /// circling one, and four of them is not a swarm. What actually has to be bounded is the cost —
+ /// each figure is its own handful of animations, two of which are paint — and eight of something
+ /// 20 per cent of a face is cheaper to look at than four of something the size of one.
+ ///
+ /// Eight rather than any other number because the console's own list editor holds eight,
+ /// and a payload that accepts more than the form can hold is a row nobody can finish editing.
+ ///
+ public const int MaxSatellites = 8;
+
+ /// How far from the centre of the face a figure may travel, as a percentage of it.
+ public const double MaxRadiusPct = 200d;
+
+ /// How big a figure may be drawn, as a percentage of the face.
+ public const double MaxSizePct = 200d;
+
+ ///
+ /// How far past the edge of the face the whole arrangement may reach, as a percentage of it.
+ ///
+ ///
+ /// The one ceiling that is not about any single number. A modest radius and a large
+ /// figure reach as far as a large radius and a modest figure, and the thing that lands on a
+ /// neighbouring message is the sum. So the sum is what is checked.
+ ///
+ public const double MaxReachPct = 150d;
+
+ /// Fast enough to read as running, slow enough not to be a strobe.
+ public const int MinPeriodMs = 240;
+
+ public const int MaxPeriodMs = 120_000;
+
+ public const int MaxSpriteFrames = 64;
+
+ /// How far a figure may be scaled by the near and far ends of its travel.
+ public const double MinDepthScale = 0.1d;
+
+ public const double MaxDepthScale = 4d;
+
+ /// How far a figure may blur at the far point, as a percentage of the face.
+ public const double MaxBlurPct = 12d;
+
+ /// How far a figure may rise and fall as it runs, as a percentage of the face.
+ public const double MaxBobPct = 50d;
+
+ /// Above this a decoration is a drawing rather than a face with a decoration on it.
+ public const int MaxMinSizePx = 512;
+}
+
+///
+/// A figure whose picture is a strip of frames rather than one drawing.
+///
+///
+/// Its own type rather than the frame's, deliberately. A kind is one file on each side and
+/// deleting that file removes the kind; a kind that reached into another kind's payload types would
+/// take that property away from both of them.
+///
+/// No data annotations here: Validator.TryValidateObject does not walk into a nested
+/// object, so a [Range] would be a rule that looks enforced and is not.
+///
+public sealed class CosmeticOrbitSprite
+{
+ public int? Frames { get; set; }
+
+ public int? Columns { get; set; }
+
+ public int? Fps { get; set; }
+
+ ///
+ /// The frame to stand on when nothing is moving.
+ ///
+ ///
+ /// Named rather than assumed to be the first, because a strip's first frame is often the one
+ /// mid-stride. Under a reduced-motion preference, and on every face too small to run an orbit
+ /// on, this frame is the whole cosmetic — so it is the author's choice and not the renderer's.
+ ///
+ public int? Still { get; set; }
+}
+
+///
+/// One figure travelling a ring around a face.
+///
+///
+/// A ring on a sphere, described by where its plane lies rather than by a shape on the
+/// screen. An ellipse and a phase would draw the same picture and would not say which half of
+/// it is nearer — and which half is nearer is the whole of it, because that is what decides when
+/// the face hides the figure and when the figure covers the face.
+///
+/// So the path is a circle of about the centre of the face, its plane
+/// tipped out of the screen by and leaned by . Everything
+/// the client draws — the ellipse, the depth, which half passes behind — falls out of those three.
+///
+///
+/// One flat type with a discriminator, as a frame's parts are, so that a second sort of thing
+/// in the ring can be added later without inventing a rule for interleaving two lists.
+///
+/// No data annotations here on purpose, for the same reason as the sprite above — every
+/// number is checked by hand in .
+///
+public sealed class CosmeticOrbitSatellite
+{
+ /// satellite, and so far only that.
+ public string? Type { get; set; }
+
+ /// The asset slot holding this figure's picture.
+ public string? Slot { get; set; }
+
+ public CosmeticOrbitSprite? Sprite { get; set; }
+
+ /// How far from the centre of the face it travels, as a percentage of the face.
+ ///
+ /// Fifty is the edge of the face. Below that the figure spends its near half over the face
+ /// and its far half entirely hidden, which is a thing to do deliberately and not by accident.
+ ///
+ ///
+ /// Past fifty plus half its own width is the number that matters, and it is not obvious
+ /// why. The figure crosses from behind the face to in front of it at the two points where it is
+ /// furthest to the side — and if any of it is over the face at that moment, that part of it
+ /// appears in one frame. Clear of the face there, and the crossing cannot be seen at all.
+ ///
+ public double? RadiusPct { get; set; }
+
+ ///
+ /// How far the ring is tipped out of the screen, in degrees.
+ ///
+ ///
+ /// Zero is a halo lying flat against the screen: a full circle, nothing ever behind
+ /// anything. Ninety is edge-on: the figure runs a straight line across the face, half of it
+ /// behind.
+ ///
+ /// Both halves of the range are worth having, and they are different cosmetics. A shallow
+ /// tilt is an ellipse tall enough to clear the head — a figure circling a face. Past sixty the
+ /// ellipse is flatter than the face, so the figure crosses the face itself on its near half and
+ /// goes behind the head on its far one: on the avatar rather than around it, which is the whole
+ /// reason for a kind that can put something behind a head.
+ ///
+ public double? TiltDeg { get; set; }
+
+ /// How far the ring is leaned over on the screen, in degrees.
+ public double? YawDeg { get; set; }
+
+ /// How far that lean rocks either side of itself, in degrees.
+ ///
+ /// What turns one circle into a sphere. A figure on a fixed ring walks the same
+ /// line for ever. Rocking the ring sweeps that line across the face and back, so one figure
+ /// covers the whole of it — and it leans with the ring, which is what anything walking on a
+ /// curved surface does.
+ ///
+ /// A rock rather than a turn all the way round: a ring that kept going would carry the
+ /// figure upside down and hold it there for a quarter of every sweep.
+ ///
+ public double? YawSwingDeg { get; set; }
+
+ /// How long one rock takes.
+ public int? YawSwingMs { get; set; }
+
+ /// How long one circuit takes.
+ public int? PeriodMs { get; set; }
+
+ ///
+ /// How far along the circuit it starts.
+ ///
+ ///
+ /// The difference between two figures and one figure drawn twice. Two satellites sharing
+ /// a period and a phase are one picture moving; giving each a phase of its own is two creatures.
+ ///
+ public int? PhaseMs { get; set; }
+
+ /// Which way round it goes.
+ public bool? Reverse { get; set; }
+
+ ///
+ /// Where on the circuit it stands when nothing is moving, in degrees.
+ ///
+ ///
+ /// Ninety puts it at the nearest point, in front and below the face, which is the pose that
+ /// reads as a figure rather than as a smudge beside a head. It is what a reduced-motion
+ /// preference sees, and what every face too small to animate shows — so it is worth choosing.
+ ///
+ public double? StillDeg { get; set; }
+
+ /// How wide the figure is drawn, as a percentage of the face.
+ public double? WidthPct { get; set; }
+
+ /// How tall the figure is drawn, as a percentage of the face.
+ public double? HeightPct { get; set; }
+
+ public double? Opacity { get; set; }
+
+ /// How far the figure is tilted on the spot, in degrees — leaning into the turn.
+ public double? LeanDeg { get; set; }
+
+ ///
+ /// Whether the picture is mirrored to face the way it is going.
+ ///
+ ///
+ /// For anything with a front. A cat drawn facing right runs backwards for half of every circuit
+ /// without this, and there is no arrangement of the other numbers that fixes it.
+ ///
+ public bool? FaceTravel { get; set; }
+
+ /// How it turns round when it does: mirror or spin.
+ ///
+ /// Which one is right follows from how the picture was drawn, not from taste. A creature
+ /// seen from the side turns round by being mirrored. One seen from above does not — mirrored, a
+ /// spider pointing down a vertical ring still points down, and it crawls backwards for half of
+ /// every lap. spin turns it through half a circle, which points it back the way it came.
+ ///
+ public string? Turn { get; set; }
+
+ /// How big the figure is at the nearest point of its travel, as a multiplier.
+ public double? NearScale { get; set; }
+
+ /// How big it is at the farthest point.
+ ///
+ /// Near and far rather than a camera distance, because these are the two numbers an author can
+ /// actually see. The client moves between them along the circuit, and the ring's own width
+ /// follows — which is what a ring seen in perspective does.
+ ///
+ public double? FarScale { get; set; }
+
+ /// How much of its brightness it loses at the farthest point, from 0 to 1.
+ public double? Dim { get; set; }
+
+ /// How much of its opacity it loses at the farthest point, from 0 to 1.
+ public double? Haze { get; set; }
+
+ /// How far out of focus it goes at the farthest point, as a percentage of the face.
+ public double? BlurPct { get; set; }
+
+ ///
+ /// Whether the face hides it while it passes behind.
+ ///
+ ///
+ /// On for anything solid, and the reason this kind exists at all. Off for a thing that is meant
+ /// to be a light rather than an object — a will-o'-the-wisp reads as a ghost precisely because
+ /// the head does not stop it.
+ ///
+ public bool? Occlude { get; set; }
+
+ /// How far it rises and falls as it goes, as a percentage of the face.
+ public double? BobPct { get; set; }
+
+ ///
+ /// How long one rise and fall takes.
+ ///
+ ///
+ /// Deliberately its own period rather than a fraction of the circuit. A bounce that divides the
+ /// lap evenly lands in the same place every time round, and the eye finds that immediately.
+ ///
+ public int? BobPeriodMs { get; set; }
+}
+
+///
+/// Figures travelling a ring around a face, passing behind it and in front of it.
+///
+///
+/// Not an avatar decoration with more fields. A decoration is one picture composited
+/// with the face at a size — there is no arrangement of an inset and a flag that puts half of a
+/// picture behind a head and the other half in front of it, because the picture is one layer and a
+/// layer is on one side. This is a list of bodies with positions in three dimensions, and the thing
+/// it can express that nothing else can is occlusion.
+///
+/// Which is also why it is a kind of its own and not a reshape of that one: both are worth
+/// wearing, they stack, and a ring of thorns that never moves should not have to carry twenty
+/// numbers about depth to say so.
+///
+public sealed class AvatarOrbitPayload : IValidatableCosmeticPayload, ICosmeticWirePayload
+{
+ public CosmeticOrbitSatellite[]? Satellites { get; set; }
+
+ ///
+ /// The size of face below which the whole arrangement stands still, in pixels.
+ ///
+ ///
+ /// Zero, and nothing, unless somebody decides otherwise: an arrangement moves wherever it
+ /// is drawn. At or below the number the client draws the frame at stillDeg and runs
+ /// nothing at all.
+ ///
+ /// It is here because a member list draws hundreds of faces at once. Each figure
+ /// costs a handful of composited animations, two of which — the strip and the blur — are paint
+ /// rather than transform, so a window full of people wearing this is a list that never settles.
+ /// The sizes it decides between are the product's own: a message head is 36 pixels, a member row
+ /// 34, and the smallest face anywhere 22.
+ ///
+ /// A number rather than a rule in the client, because where the line falls depends on what
+ /// was drawn: one bright shape survives smaller than a bird with two wings. And defaulting it to
+ /// nothing rather than to something, because a renderer that quietly stopped animating would be
+ /// a cosmetic that is live, correct and invisible — the failure
+ /// refuses for a kind's own switch, for the same
+ /// reason.
+ ///
+ public int? MinSizePx { get; set; }
+
+ ///
+ /// The orbit as Ion carries it: one typed case per figure, every number as the row said it.
+ ///
+ public ICosmeticPayload ToWire() => OrbitWire.PayloadOf(this);
+
+ public void Validate(ICosmeticPayloadReport report)
+ {
+ if (Satellites is not { Length: > 0 })
+ {
+ report.Error("satellites needs at least one figure for there to be an orbit");
+ return;
+ }
+
+ if (Satellites.Length > CosmeticOrbitLimits.MaxSatellites)
+ report.Error($"satellites is capped at {CosmeticOrbitLimits.MaxSatellites}, which is what the console's own list holds");
+
+ Bound(report, MinSizePx, "minSizePx", 0, CosmeticOrbitLimits.MaxMinSizePx);
+
+ for (var index = 0; index < Satellites.Length; index++)
+ {
+ if (Satellites[index] is not { } satellite)
+ {
+ report.Error($"satellites[{index}] is null");
+ continue;
+ }
+
+ ValidateSatellite(report, satellite, index);
+ }
+ }
+
+ private static void ValidateSatellite(ICosmeticPayloadReport report, CosmeticOrbitSatellite satellite, int index)
+ {
+ var at = $"satellites[{index}]";
+
+ if (satellite.Type is not "satellite")
+ {
+ report.Error($"{at}.type is 'satellite'");
+ return;
+ }
+
+ RequireSlot(report, satellite, at);
+
+ var radius = Require(report, satellite.RadiusPct, $"{at}.radiusPct", 0d, CosmeticOrbitLimits.MaxRadiusPct);
+ var width = Require(report, satellite.WidthPct, $"{at}.widthPct", 1d, CosmeticOrbitLimits.MaxSizePct);
+ var height = Require(report, satellite.HeightPct, $"{at}.heightPct", 1d, CosmeticOrbitLimits.MaxSizePct);
+
+ Require(report, satellite.PeriodMs, $"{at}.periodMs", CosmeticOrbitLimits.MinPeriodMs, CosmeticOrbitLimits.MaxPeriodMs);
+
+ Bound(report, satellite.TiltDeg, $"{at}.tiltDeg", 0d, 90d);
+ Bound(report, satellite.YawDeg, $"{at}.yawDeg", -180d, 180d);
+ Bound(report, satellite.YawSwingDeg, $"{at}.yawSwingDeg", 0d, 180d);
+
+ Bound(report, satellite.YawSwingMs, $"{at}.yawSwingMs",
+ CosmeticOrbitLimits.MinPeriodMs, CosmeticOrbitLimits.MaxPeriodMs);
+
+ Bound(report, satellite.PhaseMs, $"{at}.phaseMs", -CosmeticOrbitLimits.MaxPeriodMs, CosmeticOrbitLimits.MaxPeriodMs);
+ Bound(report, satellite.StillDeg, $"{at}.stillDeg", 0d, 360d);
+
+ Bound(report, satellite.Opacity, $"{at}.opacity", 0.05d, 1d);
+ Bound(report, satellite.LeanDeg, $"{at}.leanDeg", -180d, 180d);
+
+ if (satellite.Turn is { Length: > 0 } and not ("mirror" or "spin"))
+ report.Error($"{at}.turn is 'mirror' or 'spin'");
+
+ var near = Bound(report, satellite.NearScale, $"{at}.nearScale",
+ CosmeticOrbitLimits.MinDepthScale, CosmeticOrbitLimits.MaxDepthScale);
+
+ var far = Bound(report, satellite.FarScale, $"{at}.farScale",
+ CosmeticOrbitLimits.MinDepthScale, CosmeticOrbitLimits.MaxDepthScale);
+
+ Bound(report, satellite.Dim, $"{at}.dim", 0d, 1d);
+ Bound(report, satellite.Haze, $"{at}.haze", 0d, 1d);
+ Bound(report, satellite.BlurPct, $"{at}.blurPct", 0d, CosmeticOrbitLimits.MaxBlurPct);
+
+ var bob = Bound(report, satellite.BobPct, $"{at}.bobPct", 0d, CosmeticOrbitLimits.MaxBobPct);
+
+ Bound(report, satellite.BobPeriodMs, $"{at}.bobPeriodMs",
+ CosmeticOrbitLimits.MinPeriodMs, CosmeticOrbitLimits.MaxPeriodMs);
+
+ ValidateSprite(report, satellite.Sprite, $"{at}.sprite");
+
+ ReportReach(report, at, satellite, radius, width, height, bob, Math.Max(near ?? 1d, far ?? 1d));
+ }
+
+ ///
+ /// How far past the edge of the face the whole figure gets, at its largest.
+ ///
+ ///
+ /// The diagonal only for a figure that turns, because only a box that turns ever presents a
+ /// corner. One that lies square to the screen never reaches past half its own side, and charging
+ /// it for a corner it does not have put half again as much air around every ornament that only
+ /// ever mirrors. The same arithmetic the client does to work out how much room to reserve, kept
+ /// here because this is where a row is refused rather than quietly clipped.
+ ///
+ private static void ReportReach(
+ ICosmeticPayloadReport report,
+ string at,
+ CosmeticOrbitSatellite satellite,
+ double? radius,
+ double? width,
+ double? height,
+ double? bob,
+ double scale)
+ {
+ if (radius is not { } distance || width is not { } across || height is not { } down)
+ return;
+
+ var turns = satellite.FaceTravel is not false && satellite.Turn is "spin";
+ var leans = satellite.LeanDeg is not (null or 0d);
+
+ var half = turns || leans
+ ? Math.Sqrt((across * across) + (down * down)) / 2d
+ : Math.Max(across, down) / 2d;
+
+ var reach = ((distance + half + (bob ?? 0d)) * scale) - 50d;
+
+ if (reach > CosmeticOrbitLimits.MaxReachPct)
+ {
+ report.Error(
+ $"{at} reaches {reach:0} per cent of the face past its edge, and " +
+ $"{CosmeticOrbitLimits.MaxReachPct:0} is as far as an ornament goes");
+ }
+ }
+
+ private static void ValidateSprite(ICosmeticPayloadReport report, CosmeticOrbitSprite? sprite, string at)
+ {
+ if (sprite is null)
+ return;
+
+ var frames = Require(report, sprite.Frames, $"{at}.frames", 2, CosmeticOrbitLimits.MaxSpriteFrames);
+ var columns = Require(report, sprite.Columns, $"{at}.columns", 1, CosmeticOrbitLimits.MaxSpriteFrames);
+
+ Bound(report, sprite.Fps, $"{at}.fps", 1, 60);
+
+ if (frames is not { } frameCount || columns is not { } columnCount)
+ return;
+
+ // The strip is read as a grid, so a row that runs out halfway would play a blank frame.
+ if (columnCount > frameCount || frameCount % columnCount is not 0)
+ report.Error($"{at}.frames must divide evenly into rows of {columnCount}");
+
+ if (sprite.Still is { } still && (still < 0 || still >= frameCount))
+ report.Error($"{at}.still names frame {still}, and the strip has {frameCount}");
+ }
+
+ private static void RequireSlot(ICosmeticPayloadReport report, CosmeticOrbitSatellite satellite, string at)
+ {
+ if (satellite.Slot is not { Length: > 0 } slot)
+ {
+ report.Error($"{at}.slot names no file, and a satellite is a picture");
+ return;
+ }
+
+ if (!CosmeticAssetSlots.TryParseName(slot, out var named))
+ {
+ report.Error($"{at}.slot '{slot}' is not an asset slot");
+ return;
+ }
+
+ // The slots AvatarOrbitKind declares. A figure is a strip that stands on its own still frame,
+ // so there is no poster, and a slot the kind has no upload for is a figure that draws nothing.
+ if (named is not (CosmeticAssetSlot.Primary or CosmeticAssetSlot.Secondary or CosmeticAssetSlot.Tertiary or CosmeticAssetSlot.Quaternary))
+ {
+ report.Error($"{at}.slot '{slot}' is not a slot an orbit has a file in");
+ }
+ }
+
+ private static double? Require(ICosmeticPayloadReport report, double? value, string at, double low, double high)
+ {
+ if (value is null)
+ {
+ report.Error($"{at} is missing");
+ return null;
+ }
+
+ if (value < low || value > high)
+ {
+ report.Error($"{at} is {value}, and the range is {low} to {high}");
+ return null;
+ }
+
+ return value;
+ }
+
+ private static int? Require(ICosmeticPayloadReport report, int? value, string at, int low, int high)
+ {
+ if (value is null)
+ {
+ report.Error($"{at} is missing");
+ return null;
+ }
+
+ if (value < low || value > high)
+ {
+ report.Error($"{at} is {value}, and the range is {low} to {high}");
+ return null;
+ }
+
+ return value;
+ }
+
+ private static void Bound(ICosmeticPayloadReport report, int? value, string at, int low, int high)
+ {
+ if (value is { } number && (number < low || number > high))
+ report.Error($"{at} is {number}, and the range is {low} to {high}");
+ }
+
+ private static double? Bound(ICosmeticPayloadReport report, double? value, string at, double low, double high)
+ {
+ if (value is not { } number)
+ return null;
+
+ if (number < low || number > high)
+ {
+ report.Error($"{at} is {number}, and the range is {low} to {high}");
+ return null;
+ }
+
+ return number;
+ }
+}
+
+///
+/// A validated orbit, turned into the typed figures Ion carries.
+///
+///
+/// Only ever handed a payload that passed , so a field
+/// the validation requires is present here and the null-forgiving reads below are what that check
+/// bought.
+///
+/// What a figure leaves out arrives absent rather than as a default. Every default is the
+/// renderer's — where a figure stands still, how far it dims — and the client already holds them, so
+/// the wire says only what the row said.
+///
+internal static class OrbitWire
+{
+ public static PayloadAvatarOrbit PayloadOf(AvatarOrbitPayload payload)
+ {
+ var bodies = new List(payload.Satellites!.Length);
+
+ foreach (var satellite in payload.Satellites)
+ {
+ bodies.Add(BodyOf(satellite));
+ }
+
+ return new PayloadAvatarOrbit(new IonArray(bodies), (ushort?)payload.MinSizePx);
+ }
+
+ private static IOrbitBody BodyOf(CosmeticOrbitSatellite satellite) => satellite.Type switch
+ {
+ "satellite" => new OrbitSatellite(
+ CosmeticAssetSlots.ToWire(Enum.Parse(satellite.Slot!)),
+ SpriteOf(satellite.Sprite),
+ satellite.RadiusPct!.Value,
+ satellite.TiltDeg,
+ satellite.YawDeg,
+ satellite.YawSwingDeg,
+ satellite.YawSwingMs,
+ satellite.PeriodMs!.Value,
+ satellite.PhaseMs,
+ satellite.Reverse,
+ satellite.StillDeg,
+ satellite.WidthPct!.Value,
+ satellite.HeightPct!.Value,
+ satellite.Opacity,
+ satellite.LeanDeg,
+ satellite.FaceTravel,
+ TurnOf(satellite.Turn),
+ satellite.NearScale,
+ satellite.FarScale,
+ satellite.Dim,
+ satellite.Haze,
+ satellite.BlurPct,
+ satellite.Occlude,
+ satellite.BobPct,
+ satellite.BobPeriodMs),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(satellite), satellite.Type, "an orbit body this build has no case for")
+ };
+
+ private static OrbitSprite? SpriteOf(CosmeticOrbitSprite? sprite)
+ => sprite is null
+ ? null
+ : new OrbitSprite((ushort)sprite.Frames!.Value, (ushort)sprite.Columns!.Value, (ushort?)sprite.Fps, (ushort?)sprite.Still);
+
+ private static OrbitTurn? TurnOf(string? turn) => turn switch
+ {
+ "mirror" => OrbitTurn.Mirror,
+ "spin" => OrbitTurn.Spin,
+ _ => null
+ };
+}
diff --git a/src/Argon.Core/Features/Cosmetics/CosmeticSceneActors.cs b/src/Argon.Core/Features/Cosmetics/CosmeticSceneActors.cs
new file mode 100644
index 00000000..78ad2174
--- /dev/null
+++ b/src/Argon.Core/Features/Cosmetics/CosmeticSceneActors.cs
@@ -0,0 +1,1806 @@
+namespace Argon.Features.Cosmetics;
+
+using ion.runtime;
+
+///
+/// What a scene's geometry is held to.
+///
+///
+/// A scene is bounded by the card it plays on, so most of these are about cost rather than reach:
+/// how many things may move at once, and how far a number may be from something a person drew.
+///
+public static class CosmeticSceneLimits
+{
+ ///
+ /// Enough for the busiest reference — a figure, a second figure, a prop and a scatter — and few
+ /// enough that a card is still a card.
+ ///
+ public const int MaxActors = 8;
+
+ ///
+ /// Copies one emitter may put on a card.
+ ///
+ ///
+ /// Each is an element with four animations on it, so this is the one number here that is a
+ /// budget rather than a taste. Forty-eight falling flakes on a card is already more than any of
+ /// the references use.
+ ///
+ public const int MaxEmitterCount = 48;
+
+ /// Stops in one envelope. Past this it is not a curve, it is a track.
+ public const int MaxStops = 8;
+
+ public const double MaxSizePct = 400d;
+ public const double MaxOffsetPct = 400d;
+ public const double MaxOutsetPct = 25d;
+ public const double MaxScale = 8d;
+ public const double MaxRotateDeg = 3600d;
+
+ ///
+ /// How far a drop shadow is thrown, and how far it is spread, in per cent of the card's width.
+ ///
+ ///
+ /// The reach is capped rather than the cost. A shadow half a card away is further than any light
+ /// source a card can imply; the expense is the filter itself, which is why the field is opt-in
+ /// rather than defaulted.
+ ///
+ public const double MaxShadowOffsetPct = 50d;
+
+ public const double MaxShadowBlurPct = 50d;
+
+ ///
+ /// Pitch amplitude of a fluttering copy. Ninety degrees is edge-on, past which it is simply the
+ /// other face of the same drawing.
+ ///
+ public const double MaxFlutterDeg = 90d;
+
+ /// The rise and fall of a wingbeat or an edge-on pass, in per cent of the card's width.
+ public const double MaxBobPct = 25d;
+
+ /// How far a sprite's path bows off its own chord.
+ public const double MaxBowPct = 200d;
+
+ /// A sprite's lean into its own arc. Half a turn each way covers every lean there is.
+ public const double MaxBankDeg = 180d;
+
+ ///
+ /// How far a rooted thing leans — the wobble on the way up and the breathing afterwards.
+ ///
+ public const double MaxGrowDeg = 45d;
+
+ /// The strip a retreat keeps along a side or the foot, in per cent of the card's width. Half from each side is the whole card.
+ public const double MaxRetreatStripPct = 50d;
+
+ /// How soft the edge of a kept strip is. Past a quarter of the card it is a fade, not an edge.
+ public const double MaxRetreatSoftPct = 25d;
+
+ public const int MaxSlice = 512;
+ public const int MaxSpriteFrames = 64;
+ public const int MaxAtlasPx = 4096;
+
+ public const int MaxPeriodMs = 600_000;
+ public const int MinPeriodMs = 240;
+
+ /// Below this width a card is a tile in a picker, and a scene on it is a smear.
+ public const int MaxMinWidthPx = 512;
+}
+
+///
+/// One stop of an envelope: where along the actor's own cycle, and what the channel is worth there.
+///
+///
+/// An envelope is a shape, not a track. It says how a single number moves between its
+/// own smallest and largest value over one cycle — nothing here names a property, a unit or a
+/// duration, because those belong to the channel it is attached to.
+///
+/// No data annotations on purpose: see CosmeticFramePart. Every number below is checked
+/// by hand in .
+///
+public sealed class CosmeticSceneStop
+{
+ /// Where along the cycle, 0 to 1.
+ public double? At { get; set; }
+
+ /// What the channel is worth here.
+ public double? V { get; set; }
+}
+
+///
+/// A rectangle of the scene's atlas, and how to walk it if it is a strip.
+///
+///
+/// One file per scene rather than one per actor. Asset slots are a closed set of six shared
+/// by every kind, and the busiest reference wants five different drawings — so the drawings are
+/// rectangles of one picture, which is also one request instead of five.
+///
+public sealed class CosmeticSceneAtlas
+{
+ public int? X { get; set; }
+ public int? Y { get; set; }
+ public int? W { get; set; }
+ public int? H { get; set; }
+
+ /// Frames in the strip. Absent or one — an ordinary picture.
+ public int? Frames { get; set; }
+
+ public int? Columns { get; set; }
+ public int? Fps { get; set; }
+
+ ///
+ /// The frame to stand on when movement is off. Named rather than assumed to be the first, for
+ /// the same reason a frame's sprite names it: a strip's first frame is often mid-stride.
+ ///
+ public int? Still { get; set; }
+}
+
+/// The pixel size of the sheet every atlas rectangle is cut from.
+/// No data annotations, for the reason stated on .
+public sealed class CosmeticSceneSheet
+{
+ public int? W { get; set; }
+ public int? H { get; set; }
+}
+
+///
+/// Where on the stage something is, as a point of the card plus a nudge.
+///
+///
+/// The nudge is a percentage of the card's width, and so is every other size here. A
+/// card is 384px wide in a popover and 320px in settings, and its height is whatever its owner's
+/// bio, roles and board come to — up to sixteen rows of them. A percentage of the height would
+/// stretch a figure by however much somebody else's board is stretching the card.
+///
+/// The anchor is where the actor's centre goes.
+///
+public sealed class CosmeticScenePoint
+{
+ public string? Anchor { get; set; }
+
+ /// Per cent of the card's width, right positive.
+ public double? DxPct { get; set; }
+
+ /// Per cent of the card's width, down positive.
+ public double? DyPct { get; set; }
+}
+
+///
+/// The permanent lean a grown thing keeps once it has finished growing.
+///
+///
+/// The cheapest biology there is. A tree that finishes growing and then stands perfectly
+/// rigid reads as a sticker of a tree. Two numbers — how far it leans and how long a lean takes —
+/// are the whole of the difference.
+///
+public sealed class CosmeticSceneSway
+{
+ public double? Deg { get; set; }
+ public int? Ms { get; set; }
+}
+
+///
+/// How something grows into place: extended out of a root, and uncovered as it goes.
+///
+///
+/// The reveal was once the whole of it, because the thing this exists for is flowers growing:
+/// a band that scaled up would inflate, and a band that is uncovered grows.
+///
+/// A mask alone turned out to be a wipe — it uncovers a picture that is already at full size,
+/// so a tree at half growth is the bottom half of an adult tree rather than a small whole tree. So
+/// the reveal is now one of four channels about the point names: extension out of
+/// , a decaying while it extends, the reveal itself,
+/// and for ever afterwards. Equal ends install no reveal, and the grow is then
+/// its extension, wobble, duration and breathing.
+///
+public sealed class CosmeticSceneGrow
+{
+ public double? FromPct { get; set; }
+ public double? ToPct { get; set; }
+ public int? DurationMs { get; set; }
+
+ /// How long it stands finished before going round again.
+ public int? HoldMs { get; set; }
+
+ public string? Repeat { get; set; }
+
+ ///
+ /// How small it starts, as a fraction of its finished size.
+ ///
+ ///
+ /// 1 restores a pure reveal — the old behaviour, kept reachable because a band swept in
+ /// from its own edge is still the right thing for a border.
+ ///
+ public double? FromScale { get; set; }
+
+ /// y, x or both — which axis extends ahead of the other.
+ public string? LeadAxis { get; set; }
+
+ /// A rotation that decays over the extension. The decay shape is code; this is its amplitude.
+ public double? WobbleDeg { get; set; }
+
+ ///
+ /// How soft the reveal's edge is, 0 to 1.
+ ///
+ ///
+ /// A hard mask edge on a radial reveal is an iris ring crossing the picture. Softening it is what
+ /// makes the same animation read as something spreading rather than something being uncovered.
+ ///
+ public double? Softness { get; set; }
+
+ public CosmeticSceneSway? Sway { get; set; }
+
+ ///
+ /// Where the reveal starts: edge, or one of the nine anchor names.
+ ///
+ ///
+ /// The difference between a shutter and a thing that grows. Swept in from the side it lies
+ /// on, a band arrives in layers — which is what a blind does. Spread out of a point, the same
+ /// picture reads as something creeping over the card from where it is rooted, because that is
+ /// what growth looks like: it radiates from one place.
+ ///
+ /// edge, the default, sweeps from the edge the actor lies on. An anchor makes it radial
+ /// from that point of the stage.
+ ///
+ public string? From { get; set; }
+}
+
+///
+/// A shadow thrown by the actor's own drawing.
+///
+///
+/// A filter over the alpha, not a rectangle under the box. It follows the shape that is
+/// actually drawn, so a tree throws a tree-shaped shadow and a petal a petal-shaped one — which is
+/// the only version of this worth having on a picture cut out of a sheet.
+///
+/// Opt-in, and absent by default, because a filter is a layer: it is the one thing here whose cost
+/// is paid per actor per card, on a member list drawing dozens of cards at once.
+///
+public sealed class CosmeticSceneShadow
+{
+ /// Per cent of the card's width, right positive.
+ public double? DxPct { get; set; }
+
+ /// Per cent of the card's width, down positive.
+ public double? DyPct { get; set; }
+
+ public double? BlurPct { get; set; }
+
+ public double? Alpha { get; set; }
+}
+
+///
+/// The actor withdrawing from the card's reading zone, and holding there.
+///
+///
+/// What it keeps is what covers no text. After , over
+/// , the actor withdraws from everything below the glass line and inside the
+/// strips, and holds there; what is above the glass line, along the strips and past the card's edge
+/// stays. So a picture may take the whole card while it plays and still leave the card readable
+/// once it has.
+///
+/// No data annotations, for the reason stated on .
+///
+public sealed class CosmeticSceneRetreat
+{
+ /// When it starts, from the card's opening.
+ public int? AtMs { get; set; }
+
+ /// How long the withdrawal takes.
+ public int? DurationMs { get; set; }
+
+ /// The strip along each side it keeps, in per cent of the card's width.
+ public double? EdgePct { get; set; }
+
+ /// The strip along the stage's bottom edge it keeps, in per cent of the card's width.
+ public double? FootPct { get; set; }
+
+ /// How soft the strips' edges are, in per cent of the card's width.
+ public double? SoftPct { get; set; }
+
+ ///
+ /// How much of it stays over the reading zone, 0 to 1.
+ ///
+ ///
+ /// Below 1 by refusal rather than by range: a whole passes every range and changes nothing,
+ /// which on a card is a retreat somebody set and will wait for.
+ ///
+ public double? Remain { get; set; }
+}
+
+///
+/// One thing on a scene.
+///
+///
+/// One flat type with a discriminator, rather than one per sort. The same reason a
+/// frame's parts are one list: the order they are listed in is the order they are drawn in, and that
+/// order is a property of the scene. Typed lists would have to invent a rule for interleaving them.
+///
+/// The cost is that every actor carries every field, so a band could be handed a
+/// durationMs. refuses that by name rather than ignoring
+/// it — a field that does nothing is an operator who believes it does something.
+///
+/// No data annotations here, for the reason stated on .
+///
+public sealed class CosmeticSceneActor
+{
+ /// wash, sprite, band, wrap or emitter.
+ public string? Type { get; set; }
+
+ /// Which uploaded file this actor draws. Named as the asset slot is.
+ public string? Slot { get; set; }
+
+ /// atlas — a rectangle of the sheet; file — the slot's whole picture.
+ public string? Source { get; set; }
+
+ public CosmeticSceneAtlas? Atlas { get; set; }
+
+ ///
+ /// A self-animating file starts afresh every time the card is shown.
+ ///
+ ///
+ /// Only a file has a clock of its own. A rectangle of the sheet is moved by this code's
+ /// animations, which begin with the card whatever this says, so it is refused there.
+ ///
+ public bool? Replay { get; set; }
+
+ ///
+ /// Which band of the card this is drawn in: deep, over or front.
+ ///
+ ///
+ /// Depth is data here rather than a layer on the kind. Whether a figure passes in front
+ /// of a worn frame's thorns or behind them is a property of that figure — a snowman rolling over
+ /// the card and the flakes falling behind its glass are one scene and two answers. A kind-wide
+ /// layer could only give one.
+ ///
+ public string? Depth { get; set; }
+
+ /// Parts of the card that hide this actor. Only avatar today.
+ public string[]? Occlude { get; set; }
+
+ public int? DelayMs { get; set; }
+
+ public CosmeticSceneStop[]? Opacity { get; set; }
+
+ public CosmeticSceneShadow? Shadow { get; set; }
+
+ /// Withdrawing from the card's reading zone after a while, and holding there. Every sort but a scatter.
+ public CosmeticSceneRetreat? Retreat { get; set; }
+
+ // wash
+
+ /// cover, contain or stretch.
+ public string? Fit { get; set; }
+
+ public int? PeriodMs { get; set; }
+
+ // sprite and emitter
+
+ /// Per cent of the card's width.
+ public double? SizePct { get; set; }
+
+ public int? DurationMs { get; set; }
+
+ ///
+ /// A rise and fall, in per cent of the card's width.
+ ///
+ ///
+ /// One name, two periods, and neither of them is here. On a sprite it rides the wingbeat,
+ /// whose period is the atlas' own frames / fps so that it cannot drift out of step with
+ /// the drawing. On an emitter it is the dip at each edge-on pass of the flutter, which comes
+ /// twice a swing. Both are read off something the actor already carries, so neither is data.
+ ///
+ public double? BobPct { get; set; }
+
+ // wash, sprite and emitter
+
+ ///
+ /// loop, once or pingPong.
+ ///
+ ///
+ /// once is what makes a scene that ends. A card somebody opens can play a thing
+ /// through and then simply be decorated — the branch finished growing, the petals came to rest —
+ /// and that is a different sort of cosmetic from weather, which is the only sort a looping
+ /// actor can be. It lives on the actor rather than on the scene because the two mix: petals that
+ /// settle, over a sky that keeps moving.
+ ///
+ public string? Repeat { get; set; }
+
+ // sprite
+
+ public CosmeticScenePoint? From { get; set; }
+ public CosmeticScenePoint? To { get; set; }
+
+ /// linear, in, out or inOut.
+ public string? Ease { get; set; }
+
+ /// none or mirror. A figure with no front takes none.
+ public string? Turn { get; set; }
+
+ public CosmeticSceneStop[]? Rotate { get; set; }
+
+ /// How far the path arcs off the straight line between its two anchors.
+ public double? BowPct { get; set; }
+
+ ///
+ /// auto, x or y — which axis the bow displaces.
+ ///
+ ///
+ /// Named because the perpendicular is not computable. An anchor's Y is in cqh and
+ /// its X in cqw, so the true normal to the chord needs the card's aspect ratio, which
+ /// nothing knows at draw time. auto takes the larger anchor delta, which is right for
+ /// most paths and wrong for a diagonal on an unusually tall card; this is the override.
+ ///
+ public string? BowAxis { get; set; }
+
+ /// How far it leans into its own arc: nose up on entry, level at the apex, down on exit.
+ public double? BankDeg { get; set; }
+
+ // sprite and wash
+
+ public CosmeticSceneStop[]? Scale { get; set; }
+
+ ///
+ /// How far past the card's edge the actor may show, in per cent of the card's width. Zero clips
+ /// at the card.
+ ///
+ public double? SpillPct { get; set; }
+
+ // band and emitter
+
+ /// top, right, bottom or left.
+ public string? Edge { get; set; }
+
+ // band and wrap
+
+ /// Where to cut the source picture, in its own pixels: top, right, bottom, left.
+ public int[]? Slice { get; set; }
+
+ /// How far past the stage's own edge it reaches.
+ public double? OutsetPct { get; set; }
+
+ /// stretch, repeat, round or space.
+ public string? Tile { get; set; }
+
+ public CosmeticSceneGrow? Grow { get; set; }
+
+ // band
+
+ /// How thick the band is, as a per cent of the card's width.
+ public double? ThicknessPct { get; set; }
+
+ ///
+ /// How wide the pieces at the two ends of a band are drawn, in per cent of the card's width.
+ ///
+ ///
+ /// Stated rather than taken from the slice. The slice is in the source picture's pixels
+ /// and this is on the card, and nothing knows how big the source is until it has loaded — which
+ /// is after the band has been drawn. It defaults to the band's own thickness, which is right for
+ /// a border cut from a square tile and wrong for anything else.
+ ///
+ public double? CornerPct { get; set; }
+
+ // wrap
+
+ ///
+ /// How thick the band is on each side, in per cent of the card's width: top, right, bottom, left.
+ ///
+ ///
+ /// This is where a wrap's shape comes from. All the way round, down two sides only, open
+ /// at the top — every one of those is which of these four numbers are not zero. There is no shape
+ /// field, because a shape is not a thing to choose from a list. (The same sentence is true of
+ /// ProfileFramePayload's surround, and for the same reason.)
+ ///
+ /// Per cent of the WIDTH on every side, including the top and bottom ones: a card's height is
+ /// whatever its owner's bio and board come to, and a thickness that followed it would be a
+ /// different cosmetic on every profile.
+ ///
+ public double[]? WidthPct { get; set; }
+
+ // emitter
+
+ public int? Count { get; set; }
+
+ ///
+ /// What the scatter is worked out from.
+ ///
+ ///
+ /// The scatter is deterministic and this is why. Everybody looking at the same profile
+ /// sees the same flakes in the same places, every preview of the row matches the card, and a
+ /// screenshot test is reproducible. Math.random() would cost all three for nothing.
+ ///
+ public int? Seed { get; set; }
+
+ public double? SizeVarPct { get; set; }
+ public int? DurationVarMs { get; set; }
+
+ /// How far a copy may wander across its own travel.
+ public double? DriftPct { get; set; }
+
+ public double? SwayPct { get; set; }
+ public int? SwayPeriodMs { get; set; }
+
+ ///
+ /// How much the sway's period varies from copy to copy.
+ ///
+ ///
+ /// The unison killer. One period shared by every copy makes the whole field pulse
+ /// together, which is the single loudest tell that a scatter is forty copies of one animation
+ /// rather than forty things falling.
+ ///
+ public int? SwayVarMs { get; set; }
+
+ public double? SpinDeg { get; set; }
+
+ ///
+ /// Pitch amplitude of the flutter. Zero is the flat drift a scatter had before there was one.
+ ///
+ ///
+ /// A thing in the fluttering regime is fastest sideways exactly when it is edge-on and stalls
+ /// face-on, so the pitch runs ninety degrees out of phase with the sway rather than alongside
+ /// it. The phase is code; this is how far it goes. The tilt axis is drawn per copy, so tumbling
+ /// needs no field of its own — it is what an off-horizontal axis already looks like.
+ ///
+ public double? FlutterDeg { get; set; }
+
+ ///
+ /// How strongly a copy's size drives its speed and its opacity.
+ ///
+ ///
+ /// The size draw already varies per copy, so depth costs no second draw: a larger copy is nearer,
+ /// and so falls faster and sits denser. Zero leaves a field of copies that are all the same
+ /// distance away, which is what makes one read as a sheet.
+ ///
+ public double? DepthSpreadPct { get; set; }
+}
+
+///
+/// What the wearer decided about a scene, which is one thing: how much of their card it plays on.
+///
+///
+/// Offered only when the row says choice. A scene drawn for the part of a card above the
+/// board looks broken on a card the board has stretched, and the person who drew it is the one who
+/// knows which of those it is.
+///
+public sealed class ProfileSceneTuning : IValidatableCosmeticPayload
+{
+ public string? Reach { get; set; }
+
+ public void Validate(ICosmeticPayloadReport report)
+ {
+ if (Reach is { Length: > 0 } and not ("content" or "card"))
+ report.Error("reach is either 'content' or 'card'");
+ }
+}
+
+///
+/// Things moving on somebody's whole profile card: a list of actors, drawn in the order they are
+/// listed within each band of depth.
+///
+///
+/// Everything an actor carries is a number, a name from a closed list, or the name of an
+/// uploaded file — which is what keeps a new scene a row somebody creates in the console rather
+/// than a release. The client holds the vocabulary: what a band is, how a path is walked,
+/// what deep means. This holds the amounts.
+///
+/// The card's own height is not one of those amounts and cannot be: it is whatever the wearer's
+/// bio, roles and board come to. Every size here is a per cent of the card's width, and
+/// everything else is pinned to an edge.
+///
+public sealed class ProfileScenePayload : IValidatableCosmeticPayload, ICosmeticWirePayload
+{
+ public string? Reach { get; set; }
+
+ public string? DefaultReach { get; set; }
+
+ /// Below this card width the scene is not drawn at all.
+ public int? MinWidthPx { get; set; }
+
+ ///
+ /// The pixel size of the sheet in Primary.
+ ///
+ ///
+ /// Declared rather than measured, because the thing that needs it is CSS. Cutting one
+ /// rectangle out of a sheet is a background size and a background offset, and both are ratios
+ /// between the sheet and the cell — a number the browser cannot be asked for before it has
+ /// loaded the picture, and the renderer draws before that. The same reason a nine-slice's
+ /// slice is in the source's own pixels.
+ ///
+ public CosmeticSceneSheet? Sheet { get; set; }
+
+ public CosmeticSceneActor[]? Actors { get; set; }
+
+ ///
+ /// The scene as Ion carries it: one typed case per sort of actor, so a field one sort refuses is
+ /// a field its case does not have.
+ ///
+ public ICosmeticPayload ToWire() => SceneWire.PayloadOf(this);
+
+ // `shadow` and `replay` are in none of these on purpose, and so are registered in none of them:
+ // every sort of actor draws something, so every sort of actor may throw a shadow of it; and
+ // whether there is a clock to restart is a question of the source, not of the sort, so `replay`
+ // is refused where it applies rather than by name.
+ private static readonly SortField[] WashFields =
+ [
+ new("fit", actor => actor.Fit is not null),
+ new("periodMs", actor => actor.PeriodMs is not null)
+ ];
+
+ private static readonly SortField[] SpriteFields =
+ [
+ new("from", actor => actor.From is not null),
+ new("to", actor => actor.To is not null),
+ new("ease", actor => actor.Ease is not null),
+ new("turn", actor => actor.Turn is not null),
+ new("rotate", actor => actor.Rotate is not null),
+ new("bowPct", actor => actor.BowPct is not null),
+ new("bowAxis", actor => actor.BowAxis is not null),
+ new("bankDeg", actor => actor.BankDeg is not null)
+ ];
+
+ private static readonly SortField[] BandFields =
+ [
+ new("thicknessPct", actor => actor.ThicknessPct is not null),
+ new("cornerPct", actor => actor.CornerPct is not null)
+ ];
+
+ private static readonly SortField[] WrapFields = [new("widthPct", actor => actor.WidthPct is not null)];
+
+ private static readonly SortField[] EmitterFields =
+ [
+ new("count", actor => actor.Count is not null),
+ new("seed", actor => actor.Seed is not null),
+ new("sizeVarPct", actor => actor.SizeVarPct is not null),
+ new("durationVarMs", actor => actor.DurationVarMs is not null),
+ new("driftPct", actor => actor.DriftPct is not null),
+ new("swayPct", actor => actor.SwayPct is not null),
+ new("swayPeriodMs", actor => actor.SwayPeriodMs is not null),
+ new("swayVarMs", actor => actor.SwayVarMs is not null),
+ new("spinDeg", actor => actor.SpinDeg is not null),
+ new("flutterDeg", actor => actor.FlutterDeg is not null),
+ new("depthSpreadPct", actor => actor.DepthSpreadPct is not null)
+ ];
+
+ ///
+ /// What the two nine-slices both carry.
+ ///
+ ///
+ /// A wrap is the same picture cut the same way and revealed the same way; all that differs is
+ /// which sides of the card it is laid along. So the cut, the reach past the edge, how the middle
+ /// is tiled and how it is uncovered are one group, and what is left to each of them is its own
+ /// shape: a band's thickness on the one side it lies on, a wrap's four.
+ ///
+ /// What the two nine-slices share: where to cut the picture and how to lay it.
+ private static readonly SortField[] SliceFields =
+ [
+ new("slice", actor => actor.Slice is not null),
+ new("outsetPct", actor => actor.OutsetPct is not null),
+ new("tile", actor => actor.Tile is not null)
+ ];
+
+ ///
+ /// Being revealed into place — a band, a wrap, and a picture over the whole card.
+ ///
+ ///
+ /// Not a nine-slice's private business. It started as one, and the first thing anybody
+ /// asked for that the engine could not draw was a tree: one picture of a whole card, spreading
+ /// out of its own root. Growing into place is orthogonal to what shape the thing is, so it
+ /// belongs to the three actors that stand still. A sprite and a scatter are already going
+ /// somewhere; being uncovered as well is two answers to one question.
+ ///
+ private static readonly SortField[] GrowFields = [new("grow", actor => actor.Grow is not null)];
+
+ /// What a sprite and an emitter both carry, and nothing else does.
+ ///
+ /// bobPct is here rather than on either of them. Both sorts have one and they mean
+ /// different things by it — a wingbeat's rise and fall on the one, the dip at each edge-on pass
+ /// on the other — but a field named in both and
+ /// is foreign to both, so each sort would refuse its own field.
+ /// A shared meaning is not what puts a name in this group; a shared set of owners is.
+ ///
+ private static readonly SortField[] TravelFields =
+ [
+ new("sizePct", actor => actor.SizePct is not null),
+ new("durationMs", actor => actor.DurationMs is not null),
+ new("bobPct", actor => actor.BobPct is not null)
+ ];
+
+ /// What a band and an emitter both carry. A wrap has no one edge to lie on.
+ private static readonly SortField[] EdgeFields = [new("edge", actor => actor.Edge is not null)];
+
+ /// What a wash and a sprite both carry.
+ private static readonly SortField[] ScaleFields = [new("scale", actor => actor.Scale is not null)];
+
+ ///
+ /// How an actor's own cycle repeats — everything but the two nine-slices.
+ ///
+ ///
+ /// Shared, because a scene that ends is a scene, not a special case. This started out as a
+ /// sprite's alone, and a scene whose whole idea was "it plays through once and leaves the card
+ /// looking like something" could not be written: its washes and its scatters would go round
+ /// forever underneath the parts that had finished. A band and a wrap have no repeat only
+ /// because theirs is inside grow, where the hold lives with it.
+ ///
+ private static readonly SortField[] CycleFields = [new("repeat", actor => actor.Repeat is not null)];
+
+ ///
+ /// Showing past the card's edge — a picture over the whole card, and a figure crossing it.
+ ///
+ ///
+ /// The two nine-slices already reach past the edge by outsetPct, and a scatter's spill is
+ /// not drawn; a name that does nothing is refused rather than kept, for the reason the type gives.
+ ///
+ private static readonly SortField[] SpillFields = [new("spillPct", actor => actor.SpillPct is not null)];
+
+ ///
+ /// Withdrawing from the reading zone — everything but a scatter.
+ ///
+ ///
+ /// A scatter is its copies, each already on a cycle of its own, and one that should stop
+ /// covering the card plays once — which repeat already says.
+ ///
+ private static readonly SortField[] RetreatFields = [new("retreat", actor => actor.Retreat is not null)];
+
+ public void Validate(ICosmeticPayloadReport report)
+ {
+ if (Reach is not ("content" or "card" or "choice"))
+ {
+ report.Error("reach is 'content', 'card' or 'choice'");
+ }
+ else if (Reach is "choice")
+ {
+ if (DefaultReach is not ("content" or "card"))
+ report.Error("defaultReach is 'content' or 'card' when reach is 'choice'");
+ }
+ else if (DefaultReach is not null)
+ {
+ // Refused rather than ignored: a field nothing reads is a setting somebody will fill in
+ // and then wonder why nothing happened.
+ report.Error("defaultReach is only read when reach is 'choice'");
+ }
+
+ Bound(report, MinWidthPx, "minWidthPx", 0, CosmeticSceneLimits.MaxMinWidthPx);
+
+ if (Actors is not { Length: > 0 })
+ {
+ report.Error("actors needs at least one actor for there to be a scene");
+ return;
+ }
+
+ if (Actors.Length > CosmeticSceneLimits.MaxActors)
+ report.Error($"actors is capped at {CosmeticSceneLimits.MaxActors}");
+
+ var sheet = ValidateSheet(report);
+
+ for (var index = 0; index < Actors.Length; index++)
+ {
+ if (Actors[index] is not { } actor)
+ {
+ report.Error($"actors[{index}] is null");
+ continue;
+ }
+
+ ValidateActor(report, actor, index, sheet);
+ }
+ }
+
+ ///
+ /// The sheet is declared exactly when something cuts a rectangle out of it.
+ ///
+ ///
+ /// Both halves are refusals rather than defaults. A scene reading the sheet without one has no
+ /// ratio to cut with and would draw whatever the browser made of a missing number; a scene
+ /// carrying one that nothing reads is two dimensions somebody measured and will expect to see
+ /// used, which is the same trap as a defaultReach nothing consults.
+ ///
+ private SheetSize? ValidateSheet(ICosmeticPayloadReport report)
+ {
+ var read = false;
+
+ foreach (var actor in Actors!)
+ {
+ // A null actor reads nothing: it is reported where the actors are walked one by one.
+ if (actor is { Source: null or "atlas" })
+ {
+ read = true;
+ break;
+ }
+ }
+
+ if (!read)
+ {
+ if (Sheet is not null)
+ report.Error("sheet is only read when an actor reads a rectangle out of it");
+
+ return null;
+ }
+
+ if (Sheet is null)
+ {
+ report.Error("sheet is missing, and an actor reads a rectangle out of it");
+ return null;
+ }
+
+ var width = Require(report, Sheet.W, "sheet.w", 1, CosmeticSceneLimits.MaxAtlasPx);
+ var height = Require(report, Sheet.H, "sheet.h", 1, CosmeticSceneLimits.MaxAtlasPx);
+
+ return width is { } across && height is { } down ? new SheetSize(across, down) : null;
+ }
+
+ /// A sheet that passed : both sides there and in range.
+ private readonly record struct SheetSize(int Width, int Height);
+
+ private static void ValidateActor(
+ ICosmeticPayloadReport report,
+ CosmeticSceneActor actor,
+ int index,
+ SheetSize? sheet)
+ {
+ var at = $"actors[{index}]";
+
+ switch (actor.Type)
+ {
+ case "wash":
+ RefuseStrayFields(report, actor, at, SpriteFields, BandFields, WrapFields, EmitterFields, TravelFields, EdgeFields, SliceFields);
+ ValidateWash(report, actor, at);
+ break;
+
+ case "sprite":
+ RefuseStrayFields(report, actor, at, WashFields, BandFields, WrapFields, EmitterFields, EdgeFields, SliceFields, GrowFields);
+ ValidateSprite(report, actor, at);
+ break;
+
+ case "band":
+ RefuseStrayFields(report, actor, at, WashFields, SpriteFields, WrapFields, EmitterFields, TravelFields, ScaleFields, CycleFields, SpillFields);
+ ValidateBand(report, actor, at);
+ break;
+
+ case "wrap":
+ RefuseStrayFields(report, actor, at, WashFields, SpriteFields, BandFields, EmitterFields, TravelFields, ScaleFields, CycleFields, EdgeFields, SpillFields);
+ ValidateWrap(report, actor, at);
+ break;
+
+ case "emitter":
+ RefuseStrayFields(report, actor, at, WashFields, SpriteFields, BandFields, WrapFields, ScaleFields, SliceFields, GrowFields, SpillFields, RetreatFields);
+ ValidateEmitter(report, actor, at);
+ break;
+
+ default:
+ report.Error($"{at}.type is 'wash', 'sprite', 'band', 'wrap' or 'emitter'");
+ return;
+ }
+
+ ValidateCommon(report, actor, at, sheet);
+ }
+
+ private static void ValidateWash(ICosmeticPayloadReport report, CosmeticSceneActor actor, string at)
+ {
+ if (actor.Fit is { Length: > 0 } and not ("cover" or "contain" or "stretch"))
+ report.Error($"{at}.fit is 'cover', 'contain' or 'stretch'");
+
+ Bound(report, actor.PeriodMs, $"{at}.periodMs", CosmeticSceneLimits.MinPeriodMs, CosmeticSceneLimits.MaxPeriodMs);
+ Bound(report, actor.SpillPct, $"{at}.spillPct", 0d, CosmeticSceneLimits.MaxOutsetPct);
+
+ // The spill box is the card plus the hang, and `cover` is the one fit that fills it with the
+ // picture as drawn: `contain` leaves the hang empty and `stretch` fills it with distortion.
+ if (actor.SpillPct is > 0d && actor.Fit is { Length: > 0 } and not "cover")
+ report.Error($"{at}.spillPct needs fit 'cover': a picture that hangs past the card has to cover it first");
+
+ ValidateEnvelope(report, actor.Scale, $"{at}.scale", 0.1d, CosmeticSceneLimits.MaxScale);
+ ValidateGrow(report, actor.Grow, $"{at}.grow");
+ }
+
+ private static void ValidateSprite(ICosmeticPayloadReport report, CosmeticSceneActor actor, string at)
+ {
+ RequireDouble(report, actor.SizePct, $"{at}.sizePct", 1d, CosmeticSceneLimits.MaxSizePct);
+ Require(report, actor.DurationMs, $"{at}.durationMs", CosmeticSceneLimits.MinPeriodMs, CosmeticSceneLimits.MaxPeriodMs);
+
+ ValidatePoint(report, actor.From, $"{at}.from");
+ ValidatePoint(report, actor.To, $"{at}.to");
+
+ if (actor.Ease is { Length: > 0 } and not ("linear" or "in" or "out" or "inOut"))
+ report.Error($"{at}.ease is 'linear', 'in', 'out' or 'inOut'");
+
+ if (actor.Turn is { Length: > 0 } and not ("none" or "mirror"))
+ report.Error($"{at}.turn is 'none' or 'mirror'");
+
+ if (actor.BowAxis is { Length: > 0 } and not ("auto" or "x" or "y"))
+ report.Error($"{at}.bowAxis is 'auto', 'x' or 'y'");
+
+ Bound(report, actor.BowPct, $"{at}.bowPct", -CosmeticSceneLimits.MaxBowPct, CosmeticSceneLimits.MaxBowPct);
+ Bound(report, actor.BankDeg, $"{at}.bankDeg", -CosmeticSceneLimits.MaxBankDeg, CosmeticSceneLimits.MaxBankDeg);
+ Bound(report, actor.BobPct, $"{at}.bobPct", 0d, CosmeticSceneLimits.MaxBobPct);
+ Bound(report, actor.SpillPct, $"{at}.spillPct", 0d, CosmeticSceneLimits.MaxOutsetPct);
+
+ ValidateEnvelope(report, actor.Scale, $"{at}.scale", 0.1d, CosmeticSceneLimits.MaxScale);
+ ValidateEnvelope(report, actor.Rotate, $"{at}.rotate", -CosmeticSceneLimits.MaxRotateDeg, CosmeticSceneLimits.MaxRotateDeg);
+ }
+
+ private static void ValidateBand(ICosmeticPayloadReport report, CosmeticSceneActor actor, string at)
+ {
+ if (!IsEdge(actor.Edge))
+ report.Error($"{at}.edge is 'top', 'right', 'bottom' or 'left'");
+
+ // border-image-source takes a whole picture and cannot be handed a rectangle of one, so a
+ // band carries its own file. Refused rather than drawn some other way: a band that quietly
+ // stopped being a nine-slice would distort its own ends on every card of a different width.
+ if (actor.Source is not "file")
+ report.Error($"{at} is a nine-slice band, so it needs a file of its own rather than a rectangle of the sheet");
+
+ ValidateSides(report, actor.Slice, $"{at}.slice", 0, CosmeticSceneLimits.MaxSlice);
+
+ RequireDouble(report, actor.ThicknessPct, $"{at}.thicknessPct", 1d, 100d);
+ Bound(report, actor.CornerPct, $"{at}.cornerPct", 0d, 100d);
+ Bound(report, actor.OutsetPct, $"{at}.outsetPct", 0d, CosmeticSceneLimits.MaxOutsetPct);
+
+ if (actor.Tile is { Length: > 0 } and not ("stretch" or "repeat" or "round" or "space"))
+ report.Error($"{at}.tile is 'stretch', 'repeat', 'round' or 'space'");
+
+ ValidateGrow(report, actor.Grow, $"{at}.grow");
+ }
+
+ private static void ValidateWrap(ICosmeticPayloadReport report, CosmeticSceneActor actor, string at)
+ {
+ // The same refusal a band gets, for the same reason: border-image-source takes a whole
+ // picture, and a rectangle of a sheet is not one.
+ if (actor.Source is not "file")
+ report.Error($"{at} is a nine-slice wrap, so it needs a file of its own rather than a rectangle of the sheet");
+
+ ValidateSides(report, actor.Slice, $"{at}.slice", 0, CosmeticSceneLimits.MaxSlice);
+ ValidateSidesDouble(report, actor.WidthPct, $"{at}.widthPct", 0d, 100d);
+
+ // Four sides of nothing is a wrap somebody meant to shape and left empty — it publishes, it
+ // renders, and it draws no pixels at all, which on a card is indistinguishable from a file
+ // that failed to load.
+ if (actor.WidthPct is { Length: 4 } widths && widths[0] + widths[1] + widths[2] + widths[3] is 0d)
+ report.Error($"{at}.widthPct is zero on every side, so the wrap is drawn nowhere");
+
+ Bound(report, actor.OutsetPct, $"{at}.outsetPct", 0d, CosmeticSceneLimits.MaxOutsetPct);
+
+ if (actor.Tile is { Length: > 0 } and not ("stretch" or "repeat" or "round" or "space"))
+ report.Error($"{at}.tile is 'stretch', 'repeat', 'round' or 'space'");
+
+ ValidateGrow(report, actor.Grow, $"{at}.grow");
+ }
+
+ private static void ValidateEmitter(ICosmeticPayloadReport report, CosmeticSceneActor actor, string at)
+ {
+ if (!IsEdge(actor.Edge))
+ report.Error($"{at}.edge is 'top', 'right', 'bottom' or 'left'");
+
+ // Every copy has to sit at a different point of the same cycle, and the only way to do that
+ // is a negative delay on an animation this code controls. A self-animating file plays to its
+ // own clock, so forty copies of one would fall in step.
+ if (actor.Source is "file")
+ report.Error($"{at} is an emitter and reads the atlas; a self-animating file cannot be spread out");
+
+ var count = Require(report, actor.Count, $"{at}.count", 1, CosmeticSceneLimits.MaxEmitterCount);
+
+ Bound(report, actor.Seed, $"{at}.seed", 0, 65_535);
+
+ var size = RequireDouble(report, actor.SizePct, $"{at}.sizePct", 1d, CosmeticSceneLimits.MaxSizePct);
+ var duration = Require(report, actor.DurationMs, $"{at}.durationMs", CosmeticSceneLimits.MinPeriodMs, CosmeticSceneLimits.MaxPeriodMs);
+
+ Bound(report, actor.SizeVarPct, $"{at}.sizeVarPct", 0d, CosmeticSceneLimits.MaxSizePct);
+ Bound(report, actor.DurationVarMs, $"{at}.durationVarMs", 0, CosmeticSceneLimits.MaxPeriodMs);
+ Bound(report, actor.DriftPct, $"{at}.driftPct", 0d, 200d);
+ Bound(report, actor.SwayPct, $"{at}.swayPct", 0d, 100d);
+ Bound(report, actor.SwayPeriodMs, $"{at}.swayPeriodMs", CosmeticSceneLimits.MinPeriodMs, 120_000);
+ Bound(report, actor.SwayVarMs, $"{at}.swayVarMs", 0, 120_000);
+ Bound(report, actor.SpinDeg, $"{at}.spinDeg", 0d, CosmeticSceneLimits.MaxRotateDeg);
+ Bound(report, actor.FlutterDeg, $"{at}.flutterDeg", 0d, CosmeticSceneLimits.MaxFlutterDeg);
+ Bound(report, actor.BobPct, $"{at}.bobPct", 0d, CosmeticSceneLimits.MaxBobPct);
+ Bound(report, actor.DepthSpreadPct, $"{at}.depthSpreadPct", 0d, 100d);
+
+ // Spread wider than the middle and the smallest copies come out at nothing, which is a scene
+ // with holes in it rather than a scene with variety.
+ if (size is { } middle && actor.SizeVarPct is { } spread && spread > middle)
+ report.Error($"{at}.sizeVarPct is wider than sizePct, so some copies come out at no size at all");
+
+ if (duration is { } span && actor.DurationVarMs is { } vary && vary > span)
+ report.Error($"{at}.durationVarMs is wider than durationMs, so some copies never finish");
+
+ // The same trap one period in. Read off the actor rather than a checked value because the
+ // period is optional: a spread against a period nobody set is a spread of nothing.
+ if (actor.SwayPeriodMs is { } swing && actor.SwayVarMs is { } scatter && scatter > swing)
+ report.Error($"{at}.swayVarMs is wider than swayPeriodMs, so some copies come out with no sway at all");
+
+ _ = count;
+ }
+
+ private static void ValidateCommon(
+ ICosmeticPayloadReport report,
+ CosmeticSceneActor actor,
+ string at,
+ SheetSize? sheet)
+ {
+ var source = actor.Source ?? "atlas";
+
+ if (source is not ("atlas" or "file"))
+ {
+ report.Error($"{at}.source is 'atlas' or 'file'");
+ }
+ else if (source is "atlas")
+ {
+ if (actor.Atlas is null)
+ report.Error($"{at}.atlas is missing, and this actor reads the sheet");
+ else
+ ValidateAtlas(report, actor.Atlas, $"{at}.atlas", sheet);
+ }
+ else if (actor.Atlas is not null)
+ {
+ report.Error($"{at} draws a whole file and has no atlas rectangle");
+ }
+
+ // Refused where it applies rather than by name: whether there is a clock to restart is a
+ // question of the source, not of the sort. `false` passes everywhere, because the console's
+ // toggle writes it.
+ if (source is "atlas" && actor.Replay is true)
+ report.Error($"{at}.replay is only read for a file; a rectangle of the sheet has no clock of its own");
+
+ // An emitter is left out: it is refused a file by name in ValidateEmitter, and a second
+ // refusal for the same actor would only say the same thing twice.
+ if (source is "file" && actor.Type is not "emitter" && actor.Slot is not { Length: > 0 })
+ {
+ report.Error($"{at}.slot names no file, and this actor draws a whole file");
+ }
+
+ if (actor.Slot is { Length: > 0 } slot && !CosmeticAssetSlots.TryParseName(slot, out _))
+ report.Error($"{at}.slot '{slot}' is not an asset slot");
+
+ if (actor.Depth is { Length: > 0 } and not ("deep" or "over" or "front"))
+ report.Error($"{at}.depth is 'deep', 'over' or 'front'");
+
+ if (actor.Occlude is { Length: > 0 } occlude)
+ {
+ for (var index = 0; index < occlude.Length; index++)
+ {
+ if (occlude[index] is not { } part)
+ {
+ report.Error($"{at}.occlude[{index}] is null");
+ }
+ else if (part is not "avatar")
+ {
+ report.Error($"{at}.occlude names '{part}', and the only part of a card that hides anything is 'avatar'");
+ }
+ }
+ }
+
+ Bound(report, actor.DelayMs, $"{at}.delayMs", -120_000, 120_000);
+
+ // Checked here rather than three times over: a band is the only actor without one, and it is
+ // refused a `repeat` by name before this runs.
+ if (actor.Repeat is { Length: > 0 } and not ("loop" or "once" or "pingPong"))
+ report.Error($"{at}.repeat is 'loop', 'once' or 'pingPong'");
+
+ ValidateEnvelope(report, actor.Opacity, $"{at}.opacity", 0d, 1d);
+
+ // Checked here for the same reason `repeat` is: every sort of actor draws something, so
+ // every sort of actor may throw a shadow of it, and no sort is refused one by name.
+ ValidateShadow(report, actor.Shadow, $"{at}.shadow");
+
+ // And here, because the one sort that has no retreat is refused one by name before this runs.
+ ValidateRetreat(report, actor.Retreat, $"{at}.retreat");
+ }
+
+ ///
+ /// An envelope: two or more stops, running from the start of the cycle to the end of it.
+ ///
+ ///
+ /// Both ends are required and the order is strict. The client turns these into a CSS
+ /// easing function, which is a shape over a whole cycle — a list that starts at 0.3 or doubles
+ /// back has no meaning there, and would come out as whatever the browser made of it.
+ ///
+ private static void ValidateEnvelope(
+ ICosmeticPayloadReport report,
+ CosmeticSceneStop[]? stops,
+ string at,
+ double low,
+ double high)
+ {
+ if (stops is null)
+ return;
+
+ if (stops.Length < 2)
+ {
+ report.Error($"{at} needs at least two stops; one stop is a constant, and a constant is the field's own value");
+ return;
+ }
+
+ if (stops.Length > CosmeticSceneLimits.MaxStops)
+ {
+ report.Error($"{at} is capped at {CosmeticSceneLimits.MaxStops} stops");
+ return;
+ }
+
+ var previous = double.NegativeInfinity;
+
+ for (var index = 0; index < stops.Length; index++)
+ {
+ var where = $"{at}[{index}]";
+
+ if (stops[index] is not { } stop)
+ {
+ report.Error($"{where} is null");
+ return;
+ }
+
+ if (stop.At is not { } position)
+ {
+ report.Error($"{where}.at is missing");
+ return;
+ }
+
+ if (position < 0d || position > 1d)
+ {
+ report.Error($"{where}.at is {position}, and the range is 0 to 1");
+ return;
+ }
+
+ if (position <= previous)
+ {
+ report.Error($"{where}.at is {position}, which does not come after {previous}");
+ return;
+ }
+
+ previous = position;
+
+ if (stop.V is not { } value)
+ {
+ report.Error($"{where}.v is missing");
+ return;
+ }
+
+ if (value < low || value > high)
+ report.Error($"{where}.v is {value}, and the range is {low} to {high}");
+ }
+
+ if (stops[0].At != 0d)
+ report.Error($"{at} starts at {stops[0].At}, and an envelope covers the whole cycle from 0");
+
+ if (stops[^1].At != 1d)
+ report.Error($"{at} ends at {stops[^1].At}, and an envelope covers the whole cycle to 1");
+ }
+
+ private static void ValidatePoint(ICosmeticPayloadReport report, CosmeticScenePoint? point, string at)
+ {
+ if (point is null)
+ {
+ report.Error($"{at} is missing");
+ return;
+ }
+
+ if (!IsAnchor(point.Anchor))
+ {
+ report.Error($"{at}.anchor is one of topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight");
+ return;
+ }
+
+ Bound(report, point.DxPct, $"{at}.dxPct", -CosmeticSceneLimits.MaxOffsetPct, CosmeticSceneLimits.MaxOffsetPct);
+ Bound(report, point.DyPct, $"{at}.dyPct", -CosmeticSceneLimits.MaxOffsetPct, CosmeticSceneLimits.MaxOffsetPct);
+ }
+
+ private static void ValidateGrow(ICosmeticPayloadReport report, CosmeticSceneGrow? grow, string at)
+ {
+ if (grow is null)
+ return;
+
+ var from = RequireDouble(report, grow.FromPct, $"{at}.fromPct", 0d, 100d);
+ var to = RequireDouble(report, grow.ToPct, $"{at}.toPct", 0d, 100d);
+
+ Require(report, grow.DurationMs, $"{at}.durationMs", CosmeticSceneLimits.MinPeriodMs, CosmeticSceneLimits.MaxPeriodMs);
+ Bound(report, grow.HoldMs, $"{at}.holdMs", 0, CosmeticSceneLimits.MaxPeriodMs);
+
+ if (grow.Repeat is { Length: > 0 } and not ("loop" or "once" or "pingPong"))
+ report.Error($"{at}.repeat is 'loop', 'once' or 'pingPong'");
+
+ if (grow.From is not null && grow.From is not "edge" && !IsAnchor(grow.From))
+ report.Error($"{at}.from is 'edge' or one of topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight");
+
+ if (grow.LeadAxis is { Length: > 0 } and not ("y" or "x" or "both"))
+ report.Error($"{at}.leadAxis is 'y', 'x' or 'both'");
+
+ Bound(report, grow.FromScale, $"{at}.fromScale", 0.01d, 1d);
+ Bound(report, grow.WobbleDeg, $"{at}.wobbleDeg", 0d, CosmeticSceneLimits.MaxGrowDeg);
+ Bound(report, grow.Softness, $"{at}.softness", 0d, 1d);
+
+ ValidateSway(report, grow.Sway, $"{at}.sway");
+
+ // Equal ends are a grow with no reveal in it, which is what a picture drawn whole wants.
+ if (from is { } start && to is { } end && start > end)
+ report.Error($"{at}.fromPct is {start} and toPct is {end}; growth runs outwards");
+ }
+
+ ///
+ /// The lean a grown thing keeps afterwards: how far, and how long a lean takes.
+ ///
+ ///
+ /// Both are required once the object is there, for the reason gives:
+ /// a sway of an amplitude and no period, or a period and no amplitude, is not half a sway.
+ ///
+ private static void ValidateSway(ICosmeticPayloadReport report, CosmeticSceneSway? sway, string at)
+ {
+ if (sway is null)
+ return;
+
+ RequireDouble(report, sway.Deg, $"{at}.deg", 0d, CosmeticSceneLimits.MaxGrowDeg);
+ Require(report, sway.Ms, $"{at}.ms", CosmeticSceneLimits.MinPeriodMs, CosmeticSceneLimits.MaxPeriodMs);
+ }
+
+ ///
+ /// A retreat is when and how long, and then what it keeps.
+ ///
+ ///
+ /// The two times are required and the strips default to nothing, because a retreat that keeps
+ /// nothing is still a retreat: the picture goes and the text is read. What is refused is the
+ /// opposite — a remain of a whole passes the range and leaves the card exactly as covered
+ /// as it was, which is the same trap as a wrap of four zero widths.
+ ///
+ private static void ValidateRetreat(ICosmeticPayloadReport report, CosmeticSceneRetreat? retreat, string at)
+ {
+ if (retreat is null)
+ return;
+
+ Require(report, retreat.AtMs, $"{at}.atMs", 0, CosmeticSceneLimits.MaxPeriodMs);
+ Require(report, retreat.DurationMs, $"{at}.durationMs", CosmeticSceneLimits.MinPeriodMs, CosmeticSceneLimits.MaxPeriodMs);
+
+ Bound(report, retreat.EdgePct, $"{at}.edgePct", 0d, CosmeticSceneLimits.MaxRetreatStripPct);
+ Bound(report, retreat.FootPct, $"{at}.footPct", 0d, CosmeticSceneLimits.MaxRetreatStripPct);
+ Bound(report, retreat.SoftPct, $"{at}.softPct", 0d, CosmeticSceneLimits.MaxRetreatSoftPct);
+
+ if (retreat.Remain is { } remain && remain >= 1d)
+ report.Error($"{at}.remain is {remain}; a retreat that leaves everything behind is not a retreat");
+ else
+ Bound(report, retreat.Remain, $"{at}.remain", 0d, 1d);
+ }
+
+ ///
+ /// A shadow is four numbers or it is absent.
+ ///
+ ///
+ /// Every member required rather than defaulted. The four are one description of one light:
+ /// a throw with no blur is a hard double of the drawing, and a blur with no alpha is a shadow at
+ /// whatever opacity the renderer picked. Defaults here would be the renderer choosing where the
+ /// sun is, which is exactly the decision the person who drew the actor is making.
+ ///
+ private static void ValidateShadow(ICosmeticPayloadReport report, CosmeticSceneShadow? shadow, string at)
+ {
+ if (shadow is null)
+ return;
+
+ RequireDouble(report, shadow.DxPct, $"{at}.dxPct", -CosmeticSceneLimits.MaxShadowOffsetPct, CosmeticSceneLimits.MaxShadowOffsetPct);
+ RequireDouble(report, shadow.DyPct, $"{at}.dyPct", -CosmeticSceneLimits.MaxShadowOffsetPct, CosmeticSceneLimits.MaxShadowOffsetPct);
+ RequireDouble(report, shadow.BlurPct, $"{at}.blurPct", 0d, CosmeticSceneLimits.MaxShadowBlurPct);
+ RequireDouble(report, shadow.Alpha, $"{at}.alpha", 0d, 1d);
+ }
+
+ private static void ValidateAtlas(
+ ICosmeticPayloadReport report,
+ CosmeticSceneAtlas atlas,
+ string at,
+ SheetSize? sheet)
+ {
+ Bound(report, atlas.X, $"{at}.x", 0, CosmeticSceneLimits.MaxAtlasPx);
+ Bound(report, atlas.Y, $"{at}.y", 0, CosmeticSceneLimits.MaxAtlasPx);
+
+ var width = Require(report, atlas.W, $"{at}.w", 1, CosmeticSceneLimits.MaxAtlasPx);
+ var height = Require(report, atlas.H, $"{at}.h", 1, CosmeticSceneLimits.MaxAtlasPx);
+
+ if (atlas.Frames is null)
+ {
+ if (atlas.Columns is not null || atlas.Fps is not null || atlas.Still is not null)
+ report.Error($"{at} names no frames, so it is one picture and has no columns, fps or still");
+
+ ValidateFitsTheSheet(report, atlas, at, sheet, width, height, 1, 1);
+ return;
+ }
+
+ var frames = Require(report, atlas.Frames, $"{at}.frames", 1, CosmeticSceneLimits.MaxSpriteFrames);
+ var columns = Require(report, atlas.Columns, $"{at}.columns", 1, CosmeticSceneLimits.MaxSpriteFrames);
+
+ Bound(report, atlas.Fps, $"{at}.fps", 1, 60);
+
+ if (frames is not { } frameCount || columns is not { } columnCount)
+ return;
+
+ if (columnCount > frameCount || frameCount % columnCount is not 0)
+ report.Error($"{at}.frames must divide evenly into rows of {columnCount}");
+ else
+ ValidateFitsTheSheet(report, atlas, at, sheet, width, height, columnCount, frameCount / columnCount);
+
+ if (atlas.Still is { } still && (still < 0 || still >= frameCount))
+ report.Error($"{at}.still names frame {still}, and the strip has {frameCount}");
+ }
+
+ ///
+ /// Every cell of a rectangle lies inside the sheet it is cut from.
+ ///
+ ///
+ /// The whole strip, not the first cell. A rectangle that fits and a strip of four that
+ /// runs off the end are the same four numbers in the console, and the second publishes a
+ /// cosmetic whose later frames are empty sheet — a figure that blinks out rather than a number
+ /// somebody typed one digit too large. This is the mistake operators actually make, and nothing
+ /// downstream can tell it from a drawing with gaps in it.
+ ///
+ private static void ValidateFitsTheSheet(
+ ICosmeticPayloadReport report,
+ CosmeticSceneAtlas atlas,
+ string at,
+ SheetSize? sheet,
+ int? width,
+ int? height,
+ int columns,
+ int rows)
+ {
+ if (sheet is not { } bounds)
+ return;
+
+ if (width is not { } cell || height is not { } row)
+ return;
+
+ var right = (atlas.X ?? 0) + (columns * cell);
+
+ if (right > bounds.Width)
+ report.Error($"{at} runs {right - bounds.Width}px past the right edge of the sheet");
+
+ var bottom = (atlas.Y ?? 0) + (rows * row);
+
+ if (bottom > bounds.Height)
+ report.Error($"{at} runs {bottom - bounds.Height}px past the bottom of the sheet");
+ }
+
+ private static void RefuseStrayFields(
+ ICosmeticPayloadReport report,
+ CosmeticSceneActor actor,
+ string at,
+ params SortField[][] foreign)
+ {
+ foreach (var group in foreign)
+ {
+ foreach (var field in group)
+ {
+ if (field.IsFilled(actor))
+ report.Error($"{at} is a {actor.Type} and has no '{field.Name}'");
+ }
+ }
+ }
+
+ /// A field only some sorts of actor carry: its name in the document, and whether an actor has it.
+ private sealed record SortField(string Name, Func IsFilled);
+
+ private static bool IsAnchor(string? anchor) => anchor is
+ "topLeft" or "top" or "topRight"
+ or "left" or "center" or "right"
+ or "bottomLeft" or "bottom" or "bottomRight";
+
+ private static bool IsEdge(string? edge) => edge is "top" or "right" or "bottom" or "left";
+
+ private static void ValidateSides(
+ ICosmeticPayloadReport report,
+ int[]? sides,
+ string at,
+ int low,
+ int high)
+ {
+ if (sides is null)
+ {
+ report.Error($"{at} is missing; it is four numbers — top, right, bottom, left");
+ return;
+ }
+
+ if (sides.Length is not 4)
+ {
+ report.Error($"{at} is four numbers — top, right, bottom, left — and has {sides.Length}");
+ return;
+ }
+
+ for (var side = 0; side < 4; side++)
+ {
+ if (sides[side] < low || sides[side] > high)
+ report.Error($"{at}[{side}] is {sides[side]}, and the range is {low} to {high}");
+ }
+ }
+
+ ///
+ /// The same as , for the four sides a wrap measures in per cent of a
+ /// card's width.
+ ///
+ private static void ValidateSidesDouble(
+ ICosmeticPayloadReport report,
+ double[]? sides,
+ string at,
+ double low,
+ double high)
+ {
+ if (sides is null)
+ {
+ report.Error($"{at} is missing; it is four numbers — top, right, bottom, left");
+ return;
+ }
+
+ if (sides.Length is not 4)
+ {
+ report.Error($"{at} is four numbers — top, right, bottom, left — and has {sides.Length}");
+ return;
+ }
+
+ for (var side = 0; side < 4; side++)
+ {
+ if (sides[side] < low || sides[side] > high)
+ report.Error($"{at}[{side}] is {sides[side]}, and the range is {low} to {high}");
+ }
+ }
+
+ private static int? Require(ICosmeticPayloadReport report, int? value, string at, int low, int high)
+ {
+ if (value is null)
+ {
+ report.Error($"{at} is missing");
+ return null;
+ }
+
+ if (value < low || value > high)
+ {
+ report.Error($"{at} is {value}, and the range is {low} to {high}");
+ return null;
+ }
+
+ return value;
+ }
+
+ ///
+ /// The same as , for the
+ /// fractional sizes a scene measures in per cent of a card's width.
+ ///
+ private static double? RequireDouble(ICosmeticPayloadReport report, double? value, string at, double low, double high)
+ {
+ if (value is null)
+ {
+ report.Error($"{at} is missing");
+ return null;
+ }
+
+ if (value < low || value > high)
+ {
+ report.Error($"{at} is {value}, and the range is {low} to {high}");
+ return null;
+ }
+
+ return value;
+ }
+
+ private static void Bound(ICosmeticPayloadReport report, int? value, string at, int low, int high)
+ {
+ if (value is { } number && (number < low || number > high))
+ report.Error($"{at} is {number}, and the range is {low} to {high}");
+ }
+
+ private static void Bound(ICosmeticPayloadReport report, double? value, string at, double low, double high)
+ {
+ if (value is { } number && (number < low || number > high))
+ report.Error($"{at} is {number}, and the range is {low} to {high}");
+ }
+}
+
+///
+/// A validated scene, turned into the typed actors Ion carries.
+///
+///
+/// Only ever handed a payload that passed , so a field
+/// the validation requires is present here, every word is one of its closed list, and the
+/// null-forgiving reads below are what that check bought.
+///
+/// The sort is the case. The stored document is one flat type with a discriminator, and the
+/// validation refuses a field that belongs to another sort by name; on the wire that refusal is the
+/// type's, because a case has only the fields its sort may carry.
+///
+/// What an actor leaves out arrives absent rather than as a default. Several of the defaults are
+/// not constants — a band's corner is its own thickness — and all of them are the renderer's, so the
+/// wire says only what the row said.
+///
+internal static class SceneWire
+{
+ public static PayloadProfileScene PayloadOf(ProfileScenePayload payload)
+ {
+ var actors = new List(payload.Actors!.Length);
+
+ foreach (var actor in payload.Actors)
+ {
+ actors.Add(ActorOf(actor));
+ }
+
+ return new PayloadProfileScene(
+ ReachRuleOf(payload.Reach!),
+ ReachOf(payload.DefaultReach),
+ (ushort?)payload.MinWidthPx,
+ payload.Sheet is { } sheet ? new SceneSheet((ushort)sheet.W!.Value, (ushort)sheet.H!.Value) : null,
+ new IonArray(actors));
+ }
+
+ private static ISceneActor ActorOf(CosmeticSceneActor actor)
+ {
+ var slot = actor.Slot is { Length: > 0 } named
+ ? CosmeticAssetSlots.ToWire(Enum.Parse(named))
+ : (AssetSlot?)null;
+
+ var source = SourceOf(actor.Source);
+ var atlas = AtlasOf(actor.Atlas);
+ var depth = DepthOf(actor.Depth);
+ var occlude = OccludersOf(actor.Occlude);
+ var opacity = EnvelopeOf(actor.Opacity);
+ var shadow = ShadowOf(actor.Shadow);
+
+ return actor.Type switch
+ {
+ "wash" => new SceneWash(slot, source, atlas, actor.Replay, depth, occlude, actor.DelayMs, opacity, shadow,
+ FitOf(actor.Fit),
+ actor.PeriodMs,
+ RepeatOf(actor.Repeat),
+ EnvelopeOf(actor.Scale),
+ actor.SpillPct,
+ GrowOf(actor.Grow),
+ RetreatOf(actor.Retreat)),
+
+ "sprite" => new SceneSprite(slot, source, atlas, actor.Replay, depth, occlude, actor.DelayMs, opacity, shadow,
+ actor.SizePct!.Value,
+ actor.DurationMs!.Value,
+ actor.BobPct,
+ RepeatOf(actor.Repeat),
+ PointOf(actor.From!),
+ PointOf(actor.To!),
+ EaseOf(actor.Ease),
+ TurnOf(actor.Turn),
+ EnvelopeOf(actor.Rotate),
+ actor.BowPct,
+ BowAxisOf(actor.BowAxis),
+ actor.BankDeg,
+ EnvelopeOf(actor.Scale),
+ actor.SpillPct,
+ RetreatOf(actor.Retreat)),
+
+ "band" => new SceneBand(slot, source, atlas, actor.Replay, depth, occlude, actor.DelayMs, opacity, shadow,
+ EdgeOf(actor.Edge!),
+ SliceOf(actor.Slice!),
+ actor.ThicknessPct!.Value,
+ actor.CornerPct,
+ actor.OutsetPct,
+ TileOf(actor.Tile),
+ GrowOf(actor.Grow),
+ RetreatOf(actor.Retreat)),
+
+ "wrap" => new SceneWrap(slot, source, atlas, actor.Replay, depth, occlude, actor.DelayMs, opacity, shadow,
+ SliceOf(actor.Slice!),
+ new SceneWidths(actor.WidthPct![0], actor.WidthPct[1], actor.WidthPct[2], actor.WidthPct[3]),
+ actor.OutsetPct,
+ TileOf(actor.Tile),
+ GrowOf(actor.Grow),
+ RetreatOf(actor.Retreat)),
+
+ "emitter" => new SceneEmitter(slot, source, atlas, actor.Replay, depth, occlude, actor.DelayMs, opacity, shadow,
+ EdgeOf(actor.Edge!),
+ (ushort)actor.Count!.Value,
+ (ushort?)actor.Seed,
+ actor.SizePct!.Value,
+ actor.DurationMs!.Value,
+ actor.BobPct,
+ RepeatOf(actor.Repeat),
+ actor.SizeVarPct,
+ actor.DurationVarMs,
+ actor.DriftPct,
+ actor.SwayPct,
+ actor.SwayPeriodMs,
+ actor.SwayVarMs,
+ actor.SpinDeg,
+ actor.FlutterDeg,
+ actor.DepthSpreadPct),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(actor), actor.Type, "a scene actor this build has no case for")
+ };
+ }
+
+ private static SceneAtlas? AtlasOf(CosmeticSceneAtlas? atlas)
+ => atlas is null
+ ? null
+ : new SceneAtlas(
+ (ushort?)atlas.X,
+ (ushort?)atlas.Y,
+ (ushort)atlas.W!.Value,
+ (ushort)atlas.H!.Value,
+ (ushort?)atlas.Frames,
+ (ushort?)atlas.Columns,
+ (ushort?)atlas.Fps,
+ (ushort?)atlas.Still);
+
+ private static IonArray? EnvelopeOf(CosmeticSceneStop[]? stops)
+ {
+ if (stops is null)
+ return null;
+
+ var wire = new List(stops.Length);
+
+ foreach (var stop in stops)
+ {
+ wire.Add(new SceneStop(stop.At!.Value, stop.V!.Value));
+ }
+
+ return new IonArray(wire);
+ }
+
+ private static IonArray? OccludersOf(string[]? parts)
+ {
+ if (parts is null)
+ return null;
+
+ var wire = new List(parts.Length);
+
+ foreach (var part in parts)
+ {
+ if (part is "avatar")
+ wire.Add(SceneOccluder.Avatar);
+ }
+
+ return new IonArray(wire);
+ }
+
+ private static SceneShadow? ShadowOf(CosmeticSceneShadow? shadow)
+ => shadow is null
+ ? null
+ : new SceneShadow(shadow.DxPct!.Value, shadow.DyPct!.Value, shadow.BlurPct!.Value, shadow.Alpha!.Value);
+
+ private static SceneRetreat? RetreatOf(CosmeticSceneRetreat? retreat)
+ => retreat is null
+ ? null
+ : new SceneRetreat(
+ retreat.AtMs!.Value,
+ retreat.DurationMs!.Value,
+ retreat.EdgePct,
+ retreat.FootPct,
+ retreat.SoftPct,
+ retreat.Remain);
+
+ private static SceneGrow? GrowOf(CosmeticSceneGrow? grow)
+ => grow is null
+ ? null
+ : new SceneGrow(
+ grow.FromPct!.Value,
+ grow.ToPct!.Value,
+ grow.DurationMs!.Value,
+ grow.HoldMs,
+ RepeatOf(grow.Repeat),
+ grow.FromScale,
+ LeadAxisOf(grow.LeadAxis),
+ grow.WobbleDeg,
+ grow.Softness,
+ grow.Sway is { } sway ? new SceneSway(sway.Deg!.Value, sway.Ms!.Value) : null,
+ GrowOriginOf(grow.From));
+
+ private static ScenePoint PointOf(CosmeticScenePoint point)
+ => new(AnchorOf(point.Anchor!), point.DxPct, point.DyPct);
+
+ private static SceneSlice SliceOf(int[] sides)
+ => new((ushort)sides[0], (ushort)sides[1], (ushort)sides[2], (ushort)sides[3]);
+
+ private static SceneReachRule ReachRuleOf(string reach) => reach switch
+ {
+ "content" => SceneReachRule.Content,
+ "card" => SceneReachRule.Card,
+ "choice" => SceneReachRule.Choice,
+ _ => throw new ArgumentOutOfRangeException(nameof(reach), reach, null)
+ };
+
+ private static SceneReach? ReachOf(string? reach) => reach switch
+ {
+ "content" => SceneReach.Content,
+ "card" => SceneReach.Card,
+ _ => null
+ };
+
+ private static SceneSource? SourceOf(string? source) => source switch
+ {
+ "atlas" => SceneSource.Atlas,
+ "file" => SceneSource.File,
+ _ => null
+ };
+
+ private static SceneDepth? DepthOf(string? depth) => depth switch
+ {
+ "deep" => SceneDepth.Deep,
+ "over" => SceneDepth.Over,
+ "front" => SceneDepth.Front,
+ _ => null
+ };
+
+ private static SceneFit? FitOf(string? fit) => fit switch
+ {
+ "cover" => SceneFit.Cover,
+ "contain" => SceneFit.Contain,
+ "stretch" => SceneFit.Stretch,
+ _ => null
+ };
+
+ private static SceneRepeat? RepeatOf(string? repeat) => repeat switch
+ {
+ "loop" => SceneRepeat.Loop,
+ "once" => SceneRepeat.Once,
+ "pingPong" => SceneRepeat.PingPong,
+ _ => null
+ };
+
+ private static SceneEase? EaseOf(string? ease) => ease switch
+ {
+ "linear" => SceneEase.Linear,
+ "in" => SceneEase.In,
+ "out" => SceneEase.Out,
+ "inOut" => SceneEase.InOut,
+ _ => null
+ };
+
+ private static SceneTurn? TurnOf(string? turn) => turn switch
+ {
+ "none" => SceneTurn.None,
+ "mirror" => SceneTurn.Mirror,
+ _ => null
+ };
+
+ private static SceneBowAxis? BowAxisOf(string? axis) => axis switch
+ {
+ "auto" => SceneBowAxis.Auto,
+ "x" => SceneBowAxis.X,
+ "y" => SceneBowAxis.Y,
+ _ => null
+ };
+
+ private static SceneLeadAxis? LeadAxisOf(string? axis) => axis switch
+ {
+ "y" => SceneLeadAxis.Y,
+ "x" => SceneLeadAxis.X,
+ "both" => SceneLeadAxis.Both,
+ _ => null
+ };
+
+ private static SceneTile? TileOf(string? tile) => tile switch
+ {
+ "stretch" => SceneTile.Stretch,
+ "repeat" => SceneTile.Repeat,
+ "round" => SceneTile.Round,
+ "space" => SceneTile.Space,
+ _ => null
+ };
+
+ private static SceneEdge EdgeOf(string edge) => edge switch
+ {
+ "top" => SceneEdge.Top,
+ "right" => SceneEdge.Right,
+ "bottom" => SceneEdge.Bottom,
+ "left" => SceneEdge.Left,
+ _ => throw new ArgumentOutOfRangeException(nameof(edge), edge, null)
+ };
+
+ private static SceneAnchor AnchorOf(string anchor) => anchor switch
+ {
+ "topLeft" => SceneAnchor.TopLeft,
+ "top" => SceneAnchor.Top,
+ "topRight" => SceneAnchor.TopRight,
+ "left" => SceneAnchor.Left,
+ "center" => SceneAnchor.Center,
+ "right" => SceneAnchor.Right,
+ "bottomLeft" => SceneAnchor.BottomLeft,
+ "bottom" => SceneAnchor.Bottom,
+ "bottomRight" => SceneAnchor.BottomRight,
+ _ => throw new ArgumentOutOfRangeException(nameof(anchor), anchor, null)
+ };
+
+ private static SceneGrowOrigin? GrowOriginOf(string? origin) => origin switch
+ {
+ null => null,
+ "edge" => SceneGrowOrigin.Edge,
+ "topLeft" => SceneGrowOrigin.TopLeft,
+ "top" => SceneGrowOrigin.Top,
+ "topRight" => SceneGrowOrigin.TopRight,
+ "left" => SceneGrowOrigin.Left,
+ "center" => SceneGrowOrigin.Center,
+ "right" => SceneGrowOrigin.Right,
+ "bottomLeft" => SceneGrowOrigin.BottomLeft,
+ "bottom" => SceneGrowOrigin.Bottom,
+ "bottomRight" => SceneGrowOrigin.BottomRight,
+ _ => throw new ArgumentOutOfRangeException(nameof(origin), origin, null)
+ };
+}
diff --git a/src/Argon.Core/Features/Cosmetics/CosmeticTaxonomy.cs b/src/Argon.Core/Features/Cosmetics/CosmeticTaxonomy.cs
index f0ce0ee7..f1f59dfc 100644
--- a/src/Argon.Core/Features/Cosmetics/CosmeticTaxonomy.cs
+++ b/src/Argon.Core/Features/Cosmetics/CosmeticTaxonomy.cs
@@ -44,7 +44,40 @@ public enum RenderPrimitive
FrameAssembly,
/// The font, colour and motion of written text.
- TextStyle
+ TextStyle,
+
+ VideoLayer,
+ IconBadge,
+
+ ///
+ /// Bodies travelling a ring around an avatar, drawn with a near half and a far half.
+ ///
+ ///
+ /// The thing no other member can express is occlusion. Every primitive above draws
+ /// a layer, and a layer is on one side of what it decorates — there is no arrangement of an
+ /// inset, an opacity and a z-order that puts half of a picture behind a head and the other half
+ /// in front of it, because the picture is one layer and the head is one box.
+ ///
+ /// Not , which is also a list of parts: its parts are pinned to
+ /// the edges of a card and move a little way from where they were pinned. These have positions
+ /// in three dimensions and the arithmetic is the primitive.
+ ///
+ OrbitStage,
+
+ ///
+ /// Actors moving across a whole profile card: a picture, a figure travelling a path, a band
+ /// along one edge, or a scatter of one sprite.
+ ///
+ ///
+ /// What no other member can express is a list of moving things with a depth. A
+ /// is a set of pieces pinned to a card's edges and standing still. It
+ /// cannot put one figure in front of the card's own text and another behind its glass, and it has
+ /// nowhere to put a path.
+ ///
+ /// Unlike a frame, it is bounded by the card: a scene clips. A thing that both moves and
+ /// leaves the card is a thing that moves over somebody else's messages.
+ ///
+ SceneStage
}
///
@@ -90,7 +123,9 @@ public enum CosmeticEntitlement
public enum CosmeticAssetKind
{
Image,
- Font
+ Font,
+ Video,
+ SpriteSheet
}
///
@@ -121,7 +156,17 @@ public enum CosmeticAssetSlot
///
Secondary,
Tertiary,
- Quaternary
+ Quaternary,
+
+ ///
+ /// The still drawn in place of something that moves when motion is off.
+ ///
+ ///
+ /// A role rather than an ordinal, unlike the three above: it means the same thing on every kind
+ /// that has one, and no payload can say it instead — a self-animating file cannot be paused, only
+ /// replaced. Appended, so the ordinals before it stay what the wire already carries.
+ ///
+ Poster
}
///
@@ -135,6 +180,7 @@ public static class CosmeticAssetSlots
CosmeticAssetSlot.Secondary => "secondary",
CosmeticAssetSlot.Tertiary => "tertiary",
CosmeticAssetSlot.Quaternary => "quaternary",
+ CosmeticAssetSlot.Poster => "poster",
_ => throw new ArgumentOutOfRangeException(nameof(slot))
};
@@ -154,12 +200,36 @@ public static bool TryParse(string? key, out CosmeticAssetSlot slot)
return false;
}
+ ///
+ /// The slot a payload names by its member name, spelled exactly as the enum spells it.
+ ///
+ ///
+ /// Not , which also reads a number — "4" is
+ /// — and a comma-joined list of names, and neither is a slot
+ /// anybody chose.
+ ///
+ public static bool TryParseName(string? name, out CosmeticAssetSlot slot)
+ {
+ foreach (var candidate in Enum.GetValues())
+ {
+ if (candidate.ToString() == name)
+ {
+ slot = candidate;
+ return true;
+ }
+ }
+
+ slot = default;
+ return false;
+ }
+
public static AssetSlot ToWire(CosmeticAssetSlot slot) => slot switch
{
CosmeticAssetSlot.Primary => AssetSlot.Primary,
CosmeticAssetSlot.Secondary => AssetSlot.Secondary,
CosmeticAssetSlot.Tertiary => AssetSlot.Tertiary,
CosmeticAssetSlot.Quaternary => AssetSlot.Quaternary,
+ CosmeticAssetSlot.Poster => AssetSlot.Poster,
_ => throw new ArgumentOutOfRangeException(nameof(slot))
};
}
diff --git a/src/Argon.Core/Features/Cosmetics/CosmeticWornProjection.cs b/src/Argon.Core/Features/Cosmetics/CosmeticWornProjection.cs
index c0dffb4d..021fb188 100644
--- a/src/Argon.Core/Features/Cosmetics/CosmeticWornProjection.cs
+++ b/src/Argon.Core/Features/Cosmetics/CosmeticWornProjection.cs
@@ -83,9 +83,15 @@ public static Dictionary ChoicesOf(CosmeticEquipEntity row)
{
if (!kind.IsBare)
{
- return row.CosmeticItemId is { } itemId && servable.ContainsKey(itemId)
- ? new WornItem(itemId)
- : null;
+ if (row.CosmeticItemId is not { } itemId || !servable.ContainsKey(itemId))
+ return null;
+
+ return kind.Key switch
+ {
+ ProfileSceneKind.Key => ProfileSceneKind.ToWire(itemId, ReachOf(row, servable[itemId])),
+
+ _ => new WornItem(itemId)
+ };
}
// A bare kind has no row to point at. One that does was written by something other than this
@@ -116,6 +122,19 @@ public static Dictionary ChoicesOf(CosmeticEquipEntity row)
};
}
+ ///
+ /// The reach a scene's wearer chose, while the row still leaves it to them.
+ ///
+ ///
+ /// A row re-authored to a fixed reach outranks a choice made before it was. The choice stays in
+ /// the worn row rather than being sent, so offering the choice again brings it back — the same rule
+ /// as anything else that stops resolving. The row is only read when there is a choice to weigh.
+ ///
+ private static ProfileSceneTuning? ReachOf(CosmeticEquipEntity row, CosmeticItemEntity scene)
+ => TuningOf(row) is { } tuning && ProfileSceneKind.LeavesReachToWearer(scene.Payload)
+ ? tuning
+ : null;
+
private static T? TuningOf(CosmeticEquipEntity row) where T : class
=> row.Tuning is { } json && CosmeticJson.Default.TryRead(json, out var tuning, out _) ? tuning : null;
}
diff --git a/src/Argon.Core/Features/Cosmetics/Kinds/AvatarOrbitKind.cs b/src/Argon.Core/Features/Cosmetics/Kinds/AvatarOrbitKind.cs
new file mode 100644
index 00000000..6b53f07d
--- /dev/null
+++ b/src/Argon.Core/Features/Cosmetics/Kinds/AvatarOrbitKind.cs
@@ -0,0 +1,36 @@
+namespace Argon.Features.Cosmetics.Kinds;
+
+///
+/// Figures that travel a ring around an avatar, passing behind the face and back in front of it.
+///
+///
+/// Stacks with rather than replacing it: a ring of thorns
+/// around a face and a cat running round it are different things and people will wear both.
+///
+/// Every slot is a strip, and only the first is required. A satellite is always a picture —
+/// unlike a frame, none of its parts can be painted from numbers — so a row with no file at all
+/// would publish and draw nothing, which is the one refusal worth having here.
+///
+public sealed class AvatarOrbitKind : ICosmeticKind
+{
+ ///
+ /// The same ceiling a frame's parts get. A running figure is a strip of eight to sixteen
+ /// drawings, and a strip that needs more than this is art for a screen rather than for a face.
+ ///
+ private const long MaxFigureBytes = 512 * 1024;
+
+ public static void Describe(ICosmeticKindDescriptor d)
+ => d.Keyed("avatar.orbit")
+ .Describing("Figures travelling a ring around the avatar, hidden by the face as they pass behind it")
+ .Rendering(RenderPrimitive.OrbitStage)
+ .On(CosmeticSurface.Avatar)
+ .Layer(250)
+ .Stacking(StackingRule.Single)
+ .Scoped(CosmeticScope.Global | CosmeticScope.PerSpace)
+ .Payload()
+ .RequiresAsset(CosmeticAssetSlot.Primary, CosmeticAssetKind.SpriteSheet, MaxFigureBytes)
+ .AllowsAsset(CosmeticAssetSlot.Secondary, CosmeticAssetKind.SpriteSheet, MaxFigureBytes)
+ .AllowsAsset(CosmeticAssetSlot.Tertiary, CosmeticAssetKind.SpriteSheet, MaxFigureBytes)
+ .AllowsAsset(CosmeticAssetSlot.Quaternary, CosmeticAssetKind.SpriteSheet, MaxFigureBytes)
+ .Entitlement(CosmeticEntitlement.UltimaOrOwned);
+}
diff --git a/src/Argon.Core/Features/Cosmetics/Kinds/BadgeKind.cs b/src/Argon.Core/Features/Cosmetics/Kinds/BadgeKind.cs
new file mode 100644
index 00000000..9fa566a4
--- /dev/null
+++ b/src/Argon.Core/Features/Cosmetics/Kinds/BadgeKind.cs
@@ -0,0 +1,37 @@
+namespace Argon.Features.Cosmetics.Kinds;
+
+public sealed class BadgePayload : ICosmeticWirePayload
+{
+ ///
+ /// Translation key for the tooltip. A key rather than a string because a badge outlives the
+ /// language it was created in — the three badges this replaces are hardcoded English today.
+ ///
+ [Required]
+ [StringLength(128, MinimumLength = 1)]
+ public string TooltipKey { get; set; } = null!;
+
+ /// Optional ARGB tint applied to a monochrome icon. Null leaves the asset as authored.
+ public int? Tint { get; set; }
+
+ public ICosmeticPayload ToWire() => new PayloadBadge(TooltipKey, Tint);
+}
+
+///
+/// The small icons under a display name. Ordered, because the row is read left to right and the
+/// order is the person's, not the catalogue's.
+///
+public sealed class BadgeKind : ICosmeticKind
+{
+ public static void Describe(ICosmeticKindDescriptor d)
+ => d.Keyed("profile.badge")
+ .Describing("Icon shown beside the display name, with a tooltip")
+ .Rendering(RenderPrimitive.IconBadge)
+ .On(CosmeticSurface.ProfileCard | CosmeticSurface.OwnProfile)
+ .Layer(300)
+ .Stacking(StackingRule.Ordered)
+ .MaxSlots(8)
+ .Scoped(CosmeticScope.Global | CosmeticScope.PerSpace)
+ .Payload()
+ .RequiresAsset(CosmeticAssetSlot.Primary, CosmeticAssetKind.Image, 256 * 1024)
+ .Entitlement(CosmeticEntitlement.Owned);
+}
diff --git a/src/Argon.Core/Features/Cosmetics/Kinds/ProfileBackgroundKind.cs b/src/Argon.Core/Features/Cosmetics/Kinds/ProfileBackgroundKind.cs
new file mode 100644
index 00000000..de371fc6
--- /dev/null
+++ b/src/Argon.Core/Features/Cosmetics/Kinds/ProfileBackgroundKind.cs
@@ -0,0 +1,36 @@
+namespace Argon.Features.Cosmetics.Kinds;
+
+public sealed class ProfileBackgroundPayload : ICosmeticWirePayload
+{
+ /// Whether the clip restarts when it ends. Off means one pass and a held last frame.
+ public bool Loop { get; set; } = true;
+
+ ///
+ /// How strongly the card's own colour is laid over the clip, 0 for none and 1 for opaque. The
+ /// card's text sits on top of this, so a background that ships a dark clip and no tint is how a
+ /// profile becomes unreadable in light theme.
+ ///
+ [Range(0d, 1d)]
+ public double TintOpacity { get; set; } = 0.35d;
+
+ public ICosmeticPayload ToWire() => new PayloadProfileBackground(Loop, TintOpacity);
+}
+
+///
+/// The full-bleed clip behind the profile card — the five bundled backgrounds today.
+///
+public sealed class ProfileBackgroundKind : ICosmeticKind
+{
+ public static void Describe(ICosmeticKindDescriptor d)
+ => d.Keyed("profile.background")
+ .Describing("Full-bleed animated background behind the profile card")
+ .Rendering(RenderPrimitive.VideoLayer)
+ .On(CosmeticSurface.ProfileCard | CosmeticSurface.OwnProfile)
+ .Layer(100)
+ .Stacking(StackingRule.Single)
+ .Scoped(CosmeticScope.Global | CosmeticScope.PerSpace)
+ .Payload()
+ .RequiresAsset(CosmeticAssetSlot.Primary, CosmeticAssetKind.Video, 8 * 1024 * 1024)
+ .AllowsAsset(CosmeticAssetSlot.Poster, CosmeticAssetKind.Image, 1024 * 1024)
+ .Entitlement(CosmeticEntitlement.UltimaOrOwned);
+}
diff --git a/src/Argon.Core/Features/Cosmetics/Kinds/ProfileSceneKind.cs b/src/Argon.Core/Features/Cosmetics/Kinds/ProfileSceneKind.cs
new file mode 100644
index 00000000..602e70a8
--- /dev/null
+++ b/src/Argon.Core/Features/Cosmetics/Kinds/ProfileSceneKind.cs
@@ -0,0 +1,90 @@
+namespace Argon.Features.Cosmetics.Kinds;
+
+///
+/// Things moving across somebody's whole profile card.
+///
+///
+/// Its own kind rather than a richer card effect: an effect is one picture laid on a card,
+/// and there is no number you can add to one picture that makes it two things moving on different
+/// paths at different depths.
+///
+/// Its layer is only a sort order. Where a scene is actually drawn is decided per
+/// actor, in the payload — in front of a worn frame, over the card's own text, or behind its glass.
+/// A kind-wide layer could give one answer, and the references need three at once.
+///
+/// The sheet is required and everything else is not. An actor is always a picture, so a row
+/// with no file publishes and draws nothing, which is the one refusal worth having here — the same
+/// line draws. A scene made of one painting still puts that painting
+/// in the sheet: an atlas of one frame is a picture.
+///
+public sealed class ProfileSceneKind : ICosmeticKind
+{
+ public const string Key = "profile.scene";
+
+ ///
+ /// One sheet holds every drawing in the scene, so this is larger than a frame's part and is
+ /// still the whole cosmetic rather than one piece of it.
+ ///
+ private const long MaxSheetBytes = 2 * 1024 * 1024;
+
+ ///
+ /// A self-animating file — an APNG, a GIF, an animated SVG — for the one or two actors that are
+ /// a drawing rather than a strip.
+ ///
+ private const long MaxFigureBytes = 1024 * 1024;
+
+ public static void Describe(ICosmeticKindDescriptor d)
+ => d.Keyed(Key)
+ .Describing("Actors moving across the whole profile card, with a depth each")
+ .Rendering(RenderPrimitive.SceneStage)
+ .On(CosmeticSurface.ProfileCard | CosmeticSurface.OwnProfile)
+ .Layer(750)
+ .Stacking(StackingRule.Single)
+ .Scoped(CosmeticScope.Global | CosmeticScope.PerSpace)
+ .Payload()
+ .Tuning()
+ .RequiresAsset(CosmeticAssetSlot.Primary, CosmeticAssetKind.SpriteSheet, MaxSheetBytes)
+ .AllowsAsset(CosmeticAssetSlot.Secondary, CosmeticAssetKind.Image, MaxFigureBytes)
+ .AllowsAsset(CosmeticAssetSlot.Tertiary, CosmeticAssetKind.Image, MaxFigureBytes)
+ .AllowsAsset(CosmeticAssetSlot.Quaternary, CosmeticAssetKind.Image, MaxFigureBytes)
+
+ // The still a self-animating actor is replaced by when motion is off. A strip has its own
+ // `still` frame and needs none of this; an APNG cannot be paused, so the only way to hold
+ // one still is to draw something else.
+ .AllowsAsset(CosmeticAssetSlot.Poster, CosmeticAssetKind.Image, MaxFigureBytes)
+ .Entitlement(CosmeticEntitlement.UltimaOrOwned);
+
+ ///
+ /// A worn scene as the wire carries it: the row, and the reach its wearer chose when they chose
+ /// one. A scene with no choice made is an ordinary worn item.
+ ///
+ public static IWornCosmetic ToWire(Guid itemId, ProfileSceneTuning? tuning)
+ => tuning?.Reach switch
+ {
+ "content" => new WornScene(itemId, SceneReach.Content),
+ "card" => new WornScene(itemId, SceneReach.Card),
+ _ => new WornItem(itemId)
+ };
+
+ ///
+ /// Whether a scene row leaves its reach to the wearer, which is the only time a worn reach is read.
+ ///
+ ///
+ /// A row whose reach is fixed says how far it plays, and a wearer's reach beside it would be a
+ /// second answer that viewers are sent and the row contradicts.
+ ///
+ public static bool LeavesReachToWearer(string payloadJson)
+ => CosmeticJson.Default.TryRead(payloadJson, out var payload, out _)
+ && payload.Reach is "choice";
+
+ /// What a wearer's choice of reach is stored as.
+ public static ProfileSceneTuning TuningOf(SceneReach reach) => new()
+ {
+ Reach = reach switch
+ {
+ SceneReach.Content => "content",
+ SceneReach.Card => "card",
+ _ => null
+ }
+ };
+}
diff --git a/src/Argon.Ion b/src/Argon.Ion
index 35ed03d6..c2557a19 160000
--- a/src/Argon.Ion
+++ b/src/Argon.Ion
@@ -1 +1 @@
-Subproject commit 35ed03d6adb9fcee8f139025eccd5ec4b2f0f9fb
+Subproject commit c2557a19b1fd638ca9575681e59ad45f1b043e6f
diff --git a/tests/ArgonComplexTest/CosmeticEquipTests.cs b/tests/ArgonComplexTest/CosmeticEquipTests.cs
index bb8a2895..114e5560 100644
--- a/tests/ArgonComplexTest/CosmeticEquipTests.cs
+++ b/tests/ArgonComplexTest/CosmeticEquipTests.cs
@@ -235,7 +235,7 @@ await Assert.MultipleAsync(async () =>
// An option is only ever chosen on somebody's axis, never worn on its own.
Assert.That(((FailedEquip)await session.Cosmetics.Equip(new WornItem(face.Id), ct)).error, Is.EqualTo(CosmeticError.ITEM_UNAVAILABLE));
- Assert.That(((FailedEquip)await session.Cosmetics.Unequip("profile.background", ct)).error, Is.EqualTo(CosmeticError.UNKNOWN_KIND));
+ Assert.That(((FailedEquip)await session.Cosmetics.Unequip("profile.aurora", ct)).error, Is.EqualTo(CosmeticError.UNKNOWN_KIND));
});
}
@@ -576,4 +576,174 @@ await db.CosmeticOwnerships
Assert.That(DateTimeOffset.UtcNow, Is.GreaterThanOrEqualTo(newEnd));
});
}
+
+ private const string Orbit =
+ """
+ {"satellites":[{"type":"satellite","slot":"Primary","radiusPct":62,"widthPct":30,"heightPct":24,"periodMs":6000}]}
+ """;
+
+ private const string ChoiceScene =
+ """
+ {"reach":"choice","defaultReach":"content","sheet":{"w":64,"h":64},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64}}
+ ]}
+ """;
+
+ [Test, CancelAfter(120_000)]
+ public async Task The_catalogue_carries_every_kind_typed(CancellationToken ct = default)
+ {
+ var session = await CreateSessionAsync(ct);
+
+ var background = await PublishAsync("profile.background", "aurora", """{"loop":false}""",
+ assets: new() { ["primary"] = "clip", ["poster"] = "still" }, ct: ct);
+ var badge = await PublishAsync("profile.badge", "staff", """{"tooltipKey":"badge_staff","tint":-1}""", ct: ct);
+ var orbit = await PublishAsync("avatar.orbit", "cat", Orbit, ct: ct);
+ var scene = await PublishAsync("profile.scene", "sakura", ChoiceScene, ct: ct);
+
+ var catalogue = await session.Cosmetics.GetCatalogue(ct);
+
+ CatalogueCosmetic Row(CosmeticItemEntity item) => catalogue.items.Single(row => row.cosmeticId == item.Id);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(catalogue.enabledKinds,
+ Is.SupersetOf(new[] { "profile.background", "profile.badge", "avatar.orbit", "profile.scene" }));
+
+ Assert.That(Row(background).payload, Is.EqualTo(new PayloadProfileBackground(false, 0.35d)));
+ Assert.That(Row(background).assets.Select(asset => asset.slot),
+ Is.EquivalentTo(new[] { AssetSlot.Primary, AssetSlot.Poster }));
+
+ Assert.That(Row(badge).payload, Is.EqualTo(new PayloadBadge("badge_staff", -1)));
+
+ var figure = (OrbitSatellite)((PayloadAvatarOrbit)Row(orbit).payload).satellites.Single();
+ Assert.That(figure.radiusPct, Is.EqualTo(62d));
+
+ var stage = (PayloadProfileScene)Row(scene).payload;
+ Assert.That(stage.reach, Is.EqualTo(SceneReachRule.Choice));
+ Assert.That(stage.actors.Single(), Is.InstanceOf());
+ });
+ }
+
+ /// Badges are worn several at once, in the wearer's order, and come off together.
+ [Test, CancelAfter(120_000)]
+ public async Task Badges_are_worn_in_the_order_given_and_replaced_as_a_row(CancellationToken ct = default)
+ {
+ var (wearer, onlooker, spaceId) = await TwoInASpaceAsync(ct);
+
+ var badges = new List();
+
+ for (var at = 0; at < 9; at++)
+ {
+ badges.Add(await PublishAsync("profile.badge", $"badge-{at}", """{"tooltipKey":"badge"}""", ct: ct));
+ }
+
+ var three = badges.Take(3).Select(badge => badge.Id).ToArray();
+
+ Assert.That(await wearer.Cosmetics.Equip(new WornItems(new IonArray(three)), ct), Is.InstanceOf());
+ Assert.That(Items(await SeenBy(onlooker, spaceId, wearer.UserId, ct)), Is.EqualTo(three));
+
+ // Reordered and shortened: the row is what was given, nothing left over from before.
+ var two = new[] { three[2], three[0] };
+
+ Assert.That(await wearer.Cosmetics.Equip(new WornItems(new IonArray(two)), ct), Is.InstanceOf());
+ Assert.That(Items(await SeenBy(onlooker, spaceId, wearer.UserId, ct)), Is.EqualTo(two));
+
+ // One badge put on the ordinary way is the whole row.
+ Assert.That(await wearer.Cosmetics.Equip(new WornItem(badges[5].Id), ct), Is.InstanceOf());
+ Assert.That(Items(await SeenBy(onlooker, spaceId, wearer.UserId, ct)), Is.EqualTo(new[] { badges[5].Id }));
+
+ var nine = await wearer.Cosmetics.Equip(new WornItems(new IonArray(badges.Select(badge => badge.Id).ToArray())), ct);
+ var twice = await wearer.Cosmetics.Equip(new WornItems(new IonArray([three[0], three[0]])), ct);
+ var none = await wearer.Cosmetics.Equip(new WornItems(IonArray.Empty), ct);
+ var halo = await PublishAsync("avatar.decoration", "halo", """{"insetPct":10,"beneath":false}""", ct: ct);
+ var twoKinds = await wearer.Cosmetics.Equip(new WornItems(new IonArray([three[0], halo.Id])), ct);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That((nine as FailedEquip)?.error, Is.EqualTo(CosmeticError.VALUE_OUT_OF_RANGE), "past the kind's eight slots");
+ Assert.That((twice as FailedEquip)?.error, Is.EqualTo(CosmeticError.VALUE_OUT_OF_RANGE), "one badge twice");
+ Assert.That((none as FailedEquip)?.error, Is.EqualTo(CosmeticError.VALUE_OUT_OF_RANGE), "a row that names no kind");
+ Assert.That((twoKinds as FailedEquip)?.error, Is.EqualTo(CosmeticError.VALUE_OUT_OF_RANGE), "two kinds in one row");
+ });
+
+ Assert.That(Items(await SeenBy(onlooker, spaceId, wearer.UserId, ct)), Is.EqualTo(new[] { badges[5].Id }),
+ "a refused row changed what was worn");
+
+ Assert.That(await wearer.Cosmetics.Equip(new WornItems(new IonArray(two)), ct), Is.InstanceOf());
+ Assert.That(await wearer.Cosmetics.Unequip("profile.badge", ct), Is.InstanceOf());
+ Assert.That(await SeenBy(onlooker, spaceId, wearer.UserId, ct), Is.Empty);
+ }
+
+ /// A scene's reach is the wearer's, carried on the profile exactly as it was put on.
+ [Test, CancelAfter(120_000)]
+ public async Task A_scene_is_worn_with_the_reach_its_wearer_chose(CancellationToken ct = default)
+ {
+ var (wearer, onlooker, spaceId) = await TwoInASpaceAsync(ct);
+
+ var scene = await PublishAsync("profile.scene", "sakura", ChoiceScene, ct: ct);
+ var orbit = await PublishAsync("avatar.orbit", "cat", Orbit, ct: ct);
+
+ Assert.That(await wearer.Cosmetics.Equip(new WornScene(scene.Id, SceneReach.Card), ct), Is.InstanceOf());
+ Assert.That(await SeenBy(onlooker, spaceId, wearer.UserId, ct),
+ Is.EqualTo(new IWornCosmetic[] { new WornScene(scene.Id, SceneReach.Card) }));
+
+ // Put on with no reach, it defers to the row's default again rather than keeping the old choice.
+ Assert.That(await wearer.Cosmetics.Equip(new WornItem(scene.Id), ct), Is.InstanceOf());
+ Assert.That(await SeenBy(onlooker, spaceId, wearer.UserId, ct), Is.EqualTo(new IWornCosmetic[] { new WornItem(scene.Id) }));
+
+ var notAScene = await wearer.Cosmetics.Equip(new WornScene(orbit.Id, SceneReach.Card), ct);
+
+ Assert.That((notAScene as FailedEquip)?.error, Is.EqualTo(CosmeticError.NOT_FOUND));
+ }
+
+ ///
+ /// The reach enum is open on the wire, so a newer client can name one this build has no word for.
+ /// Stored, it would be no choice at all — the scene quietly worn at the row's default — so it is
+ /// refused, and what was on stays on.
+ ///
+ [Test, CancelAfter(120_000)]
+ public async Task A_scene_put_on_with_a_reach_this_build_has_no_word_for_is_refused(CancellationToken ct = default)
+ {
+ var (wearer, onlooker, spaceId) = await TwoInASpaceAsync(ct);
+
+ var scene = await PublishAsync("profile.scene", "sakura", ChoiceScene, ct: ct);
+
+ Assert.That(await wearer.Cosmetics.Equip(new WornScene(scene.Id, SceneReach.Content), ct), Is.InstanceOf());
+
+ var unknown = await wearer.Cosmetics.Equip(new WornScene(scene.Id, (SceneReach)9), ct);
+
+ Assert.That((unknown as FailedEquip)?.error, Is.EqualTo(CosmeticError.VALUE_OUT_OF_RANGE));
+ Assert.That(await SeenBy(onlooker, spaceId, wearer.UserId, ct),
+ Is.EqualTo(new IWornCosmetic[] { new WornScene(scene.Id, SceneReach.Content) }), "a refused reach changed what was worn");
+ }
+
+ ///
+ /// A row that fixes its own reach leaves the wearer nothing to choose. Stored, a reach beside it
+ /// would be sent to everybody looking and contradict the row, so it is refused — the same reach
+ /// that a row offering the choice accepts — and what was on stays on.
+ ///
+ [Test, CancelAfter(120_000)]
+ public async Task A_scene_whose_row_fixes_its_reach_is_refused_a_reach_of_the_wearers(CancellationToken ct = default)
+ {
+ var (wearer, onlooker, spaceId) = await TwoInASpaceAsync(ct);
+
+ var chosen = await PublishAsync("profile.scene", "sakura", ChoiceScene, ct: ct);
+ var fixedAt =await PublishAsync("profile.scene", "snow", """
+ {"reach":"content","sheet":{"w":64,"h":64},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64}}
+ ]}
+ """, ct: ct);
+
+ Assert.That(await wearer.Cosmetics.Equip(new WornScene(chosen.Id, SceneReach.Card), ct), Is.InstanceOf());
+
+ var refused = await wearer.Cosmetics.Equip(new WornScene(fixedAt.Id, SceneReach.Content), ct);
+
+ Assert.That((refused as FailedEquip)?.error, Is.EqualTo(CosmeticError.VALUE_OUT_OF_RANGE));
+ Assert.That(await SeenBy(onlooker, spaceId, wearer.UserId, ct),
+ Is.EqualTo(new IWornCosmetic[] { new WornScene(chosen.Id, SceneReach.Card) }), "a refused reach changed what was worn");
+
+ // Put on as the row has it, the same scene goes on.
+ Assert.That(await wearer.Cosmetics.Equip(new WornItem(fixedAt.Id), ct), Is.InstanceOf());
+ Assert.That(await SeenBy(onlooker, spaceId, wearer.UserId, ct), Is.EqualTo(new IWornCosmetic[] { new WornItem(fixedAt.Id) }));
+ }
}
diff --git a/tests/ArgonSharedLogicTest/CosmeticAssetMediaTests.cs b/tests/ArgonSharedLogicTest/CosmeticAssetMediaTests.cs
new file mode 100644
index 00000000..0f40180b
--- /dev/null
+++ b/tests/ArgonSharedLogicTest/CosmeticAssetMediaTests.cs
@@ -0,0 +1,64 @@
+namespace ArgonSharedLogicTest;
+
+using Argon.Features.Cosmetics;
+
+///
+/// Which media types each sort of cosmetic file accepts, and how a refusal names the sort.
+///
+///
+/// The check is against what the store actually received, so it is the last word on whether a
+/// clip slot holds a clip: a still uploaded into a background's video slot is refused here rather
+/// than published as a background that never plays.
+///
+[TestFixture]
+public class CosmeticAssetMediaTests
+{
+ [TestCase(CosmeticAssetKind.Image, "image/png")]
+ [TestCase(CosmeticAssetKind.Image, "IMAGE/WEBP")]
+ [TestCase(CosmeticAssetKind.SpriteSheet, "image/png")]
+ [TestCase(CosmeticAssetKind.SpriteSheet, "Image/Avif")]
+ [TestCase(CosmeticAssetKind.Video, "video/webm")]
+ [TestCase(CosmeticAssetKind.Video, "VIDEO/MP4")]
+ [TestCase(CosmeticAssetKind.Font, "font/woff2")]
+ [TestCase(CosmeticAssetKind.Font, "application/font-woff2")]
+ [TestCase(CosmeticAssetKind.Font, "application/x-font-ttf")]
+ [TestCase(CosmeticAssetKind.Font, "application/vnd.ms-opentype")]
+ [TestCase(CosmeticAssetKind.Font, "application/octet-stream")]
+ public void A_file_of_the_right_media_type_is_accepted(CosmeticAssetKind kind, string contentType)
+ => Assert.That(CosmeticAssetMedia.Accepts(kind, contentType), Is.True);
+
+ [TestCase(CosmeticAssetKind.Image, "video/webm")]
+ [TestCase(CosmeticAssetKind.SpriteSheet, "video/webm")]
+ [TestCase(CosmeticAssetKind.Video, "image/png")]
+ [TestCase(CosmeticAssetKind.Video, "image/gif")]
+ [TestCase(CosmeticAssetKind.Font, "image/png")]
+ public void A_file_of_another_media_type_is_refused(CosmeticAssetKind kind, string contentType)
+ => Assert.That(CosmeticAssetMedia.Accepts(kind, contentType), Is.False);
+
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ public void A_file_the_store_could_not_type_is_refused_for_every_sort(string? contentType)
+ {
+ Assert.Multiple(() =>
+ {
+ foreach (var kind in Enum.GetValues())
+ {
+ Assert.That(CosmeticAssetMedia.Accepts(kind, contentType), Is.False, kind.ToString());
+ }
+ });
+ }
+
+ /// A sort this build does not know accepts nothing rather than everything.
+ [Test]
+ public void A_sort_this_build_does_not_know_accepts_nothing()
+ => Assert.That(CosmeticAssetMedia.Accepts((CosmeticAssetKind)99, "image/png"), Is.False);
+
+ [TestCase(CosmeticAssetKind.Image, "an image")]
+ [TestCase(CosmeticAssetKind.SpriteSheet, "a sprite sheet image")]
+ [TestCase(CosmeticAssetKind.Video, "a video")]
+ [TestCase(CosmeticAssetKind.Font, "a font file")]
+ [TestCase((CosmeticAssetKind)99, "99")]
+ public void A_sort_is_named_in_words(CosmeticAssetKind kind, string words)
+ => Assert.That(CosmeticAssetMedia.Describe(kind), Is.EqualTo(words));
+}
diff --git a/tests/ArgonSharedLogicTest/CosmeticGrainSerializationTests.cs b/tests/ArgonSharedLogicTest/CosmeticGrainSerializationTests.cs
index 0f8192cc..5dc9c4a0 100644
--- a/tests/ArgonSharedLogicTest/CosmeticGrainSerializationTests.cs
+++ b/tests/ArgonSharedLogicTest/CosmeticGrainSerializationTests.cs
@@ -132,6 +132,66 @@ public void A_catalogue_arrives_with_its_frames_typed()
}
}
+ /// A scene's actors and an orbit's figures are unions inside a union inside a catalogue.
+ [Test]
+ public void A_catalogue_arrives_with_its_scenes_and_orbits_typed()
+ {
+ var sprite = new SceneSprite(null, SceneSource.Atlas, new SceneAtlas(0, 0, 32, 32, 4, 4, 8, null), null,
+ SceneDepth.Front, new IonArray([SceneOccluder.Avatar]), null,
+ new IonArray([new SceneStop(0d, 0d), new SceneStop(1d, 1d)]), null,
+ 20d, 4000, null, SceneRepeat.Once, new ScenePoint(SceneAnchor.TopRight, -2.5d, null),
+ new ScenePoint(SceneAnchor.BottomLeft, null, null), SceneEase.InOut, SceneTurn.Mirror, null, 10d,
+ SceneBowAxis.Auto, null, null, null, null);
+
+ var figure = new OrbitSatellite(AssetSlot.Primary, new OrbitSprite(8, 4, 12, null), 62d, 70d, null, null, null,
+ 6000, null, null, null, 30d, 24d, null, null, true, OrbitTurn.Spin, 1.2d, 0.8d, null, null, null, true, null, null);
+
+ var original = new CosmeticCatalogue(new IonArray(["profile.scene", "avatar.orbit"]),
+ new IonArray([
+ new CatalogueCosmetic(Guid.NewGuid(), "profile.scene", "sakura", "cosmetic.sakura.name", null, null, 1,
+ new PayloadProfileScene(SceneReachRule.Choice, SceneReach.Content, null, new SceneSheet(256, 128),
+ new IonArray([sprite])),
+ new IonArray([new CosmeticAsset(AssetSlot.Poster, "file-2")]),
+ null, false, true, IonArray.Empty),
+ new CatalogueCosmetic(Guid.NewGuid(), "avatar.orbit", "cat", "cosmetic.cat.name", null, null, 1,
+ new PayloadAvatarOrbit(new IonArray([figure]), 34),
+ IonArray.Empty, null, false, true, IonArray.Empty)
+ ]));
+
+ foreach (var carried in new[] { RoundTrip(original), Copy(original), ThroughCache(original) })
+ {
+ var scene = (PayloadProfileScene)carried.items[0].payload;
+ var orbit = (PayloadAvatarOrbit)carried.items[1].payload;
+ var back = (SceneSprite)scene.actors.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(carried.items[0].assets.Single().slot, Is.EqualTo(AssetSlot.Poster));
+ Assert.That(scene.reach, Is.EqualTo(SceneReachRule.Choice));
+ Assert.That(back.from, Is.EqualTo(sprite.from));
+ Assert.That(back.occlude, Is.EqualTo(new[] { SceneOccluder.Avatar }));
+ Assert.That(back.opacity!.Value.Select(stop => stop.v), Is.EqualTo(new[] { 0d, 1d }));
+ Assert.That(back.bowPct, Is.EqualTo(10d));
+ Assert.That(orbit.satellites.Single(), Is.EqualTo(figure));
+ });
+ }
+ }
+
+ /// The two cases a wardrobe puts on that are not a name: several badges, and a scene's reach.
+ [Test]
+ public void Badges_and_a_scene_arrive_as_their_cases()
+ {
+ var badges = new WornItems(new IonArray([Guid.NewGuid(), Guid.NewGuid()]));
+ var scene = new WornScene(Guid.NewGuid(), SceneReach.Card);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(((WornItems)RoundTrip(badges)).itemIds.ToArray(), Is.EqualTo(badges.itemIds.ToArray()));
+ Assert.That(RoundTrip(scene), Is.EqualTo(scene));
+ Assert.That(RoundTrip(Profile(new IonArray([scene]))).cosmetics!.Value.Single(), Is.EqualTo(scene));
+ });
+ }
+
/// How the catalogue is held in the cache: its own Ion encoding.
private static CosmeticCatalogue ThroughCache(CosmeticCatalogue catalogue)
{
diff --git a/tests/ArgonSharedLogicTest/CosmeticKindRegistryTests.cs b/tests/ArgonSharedLogicTest/CosmeticKindRegistryTests.cs
index 349d6b7f..32ae08a9 100644
--- a/tests/ArgonSharedLogicTest/CosmeticKindRegistryTests.cs
+++ b/tests/ArgonSharedLogicTest/CosmeticKindRegistryTests.cs
@@ -29,11 +29,25 @@ public void Every_kind_this_build_ships_is_in_the_registry()
Assert.That(keys, Is.EquivalentTo(new[]
{
- "profile.frame", "avatar.decoration", "nickname.style",
+ "profile.background", "profile.badge", "profile.frame", "profile.scene",
+ "avatar.decoration", "avatar.orbit", "nickname.style",
"option.font", "option.text-effect"
}));
}
+ [Test]
+ public void A_required_asset_is_reported_as_one()
+ {
+ var background = Shipped().Find("profile.background")!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(background.RequiredAssets().Select(asset => asset.Slot),
+ Is.EquivalentTo(new[] { CosmeticAssetSlot.Primary }));
+ Assert.That(background.Assets[CosmeticAssetSlot.Poster].IsRequired, Is.False);
+ });
+ }
+
[Test]
public void A_kind_is_described_without_being_constructed()
{
@@ -129,9 +143,9 @@ public void An_orphaned_key_is_simply_absent()
Assert.Multiple(() =>
{
- Assert.That(registry.Contains("profile.background"), Is.False);
- Assert.That(registry.Find("profile.background"), Is.Null);
- Assert.That(registry.TryGet("profile.background", out _), Is.False);
+ Assert.That(registry.Contains("profile.aurora"), Is.False);
+ Assert.That(registry.Find("profile.aurora"), Is.Null);
+ Assert.That(registry.TryGet("profile.aurora", out _), Is.False);
});
}
diff --git a/tests/ArgonSharedLogicTest/CosmeticOrbitPayloadTests.cs b/tests/ArgonSharedLogicTest/CosmeticOrbitPayloadTests.cs
new file mode 100644
index 00000000..3a06ba6e
--- /dev/null
+++ b/tests/ArgonSharedLogicTest/CosmeticOrbitPayloadTests.cs
@@ -0,0 +1,475 @@
+namespace ArgonSharedLogicTest;
+
+using Argon.Features.Cosmetics;
+using ArgonContracts;
+
+///
+/// What an orbit's payload refuses, in so many words, and what Ion carries of one it accepts.
+///
+///
+/// An orbit is drawn wherever a face is, so its refusals are mostly about reach: a figure that
+/// lands on the neighbouring message is a number somebody typed, and the arithmetic that catches it
+/// is the same the client does to reserve room. Each refusal below starts from a satellite that
+/// passes and changes one thing.
+///
+/// Numbers are printed with the invariant culture here so that a decimal point reads the same
+/// on every machine.
+///
+[TestFixture]
+[SetCulture("")]
+public class CosmeticOrbitPayloadTests
+{
+ private static readonly CosmeticKindDefinition OrbitKind =
+ CosmeticKindRegistry.FromAssembly(typeof(ICosmeticKind).Assembly).Find("avatar.orbit")!;
+
+ ///
+ /// A square figure exactly at the reach ceiling when it lies square to the screen: 160 out, half
+ /// of 80 across, less the 50 that is the face itself, is 150.
+ ///
+ private static CosmeticOrbitSatellite Satellite(Action? change = null)
+ {
+ var satellite = new CosmeticOrbitSatellite
+ {
+ Type = "satellite",
+ Slot = "Primary",
+ RadiusPct = 160d,
+ WidthPct = 80d,
+ HeightPct = 80d,
+ PeriodMs = 6000
+ };
+
+ change?.Invoke(satellite);
+
+ return satellite;
+ }
+
+ private static AvatarOrbitPayload Orbit(params CosmeticOrbitSatellite[] satellites) => new() { Satellites = satellites };
+
+ private static AvatarOrbitPayload Orbit(Action change) => Orbit(Satellite(change));
+
+ private static IReadOnlyList ErrorsOf(AvatarOrbitPayload orbit)
+ {
+ var report = new CosmeticPayloadReport();
+
+ orbit.Validate(report);
+
+ return report.Errors;
+ }
+
+ private static TestCaseData Refused(string because, AvatarOrbitPayload orbit, params string[] refusals)
+ => new TestCaseData(orbit, refusals).SetArgDisplayNames(because);
+
+ private static TestCaseData Accepted(string because, AvatarOrbitPayload orbit)
+ => new TestCaseData(orbit).SetArgDisplayNames(because);
+
+ [TestCaseSource(nameof(Refusals))]
+ public void A_value_outside_its_rule_is_refused_in_so_many_words(AvatarOrbitPayload orbit, string[] refusals)
+ => Assert.That(ErrorsOf(orbit), Is.EqualTo(refusals));
+
+ [TestCaseSource(nameof(Acceptances))]
+ public void A_value_inside_its_rule_is_accepted(AvatarOrbitPayload orbit)
+ => Assert.That(ErrorsOf(orbit), Is.Empty);
+
+ private static IEnumerable Refusals()
+ {
+ yield return Refused("no satellites", new AvatarOrbitPayload(), "satellites needs at least one figure for there to be an orbit");
+
+ yield return Refused("an empty list of satellites", Orbit(), "satellites needs at least one figure for there to be an orbit");
+
+ yield return Refused("nine satellites", Orbit(Enumerable.Range(0, 9).Select(_ => Satellite()).ToArray()),
+ "satellites is capped at 8, which is what the console's own list holds");
+
+ yield return Refused("a negative still size", new AvatarOrbitPayload { Satellites = [Satellite()], MinSizePx = -1 },
+ "minSizePx is -1, and the range is 0 to 512");
+
+ yield return Refused("a still size past a drawing", new AvatarOrbitPayload { Satellites = [Satellite()], MinSizePx = 513 },
+ "minSizePx is 513, and the range is 0 to 512");
+
+ yield return Refused("a body that is not a satellite, which is checked no further", Orbit(satellite =>
+ {
+ satellite.Type = "moon";
+ satellite.RadiusPct = -1d;
+ }),
+ "satellites[0].type is 'satellite'");
+
+ yield return Refused("a body of no type", Orbit(satellite => satellite.Type = null), "satellites[0].type is 'satellite'");
+
+ yield return Refused("a satellite with no file", Orbit(satellite => satellite.Slot = null),
+ "satellites[0].slot names no file, and a satellite is a picture");
+
+ yield return Refused("a satellite with an empty file name", Orbit(satellite => satellite.Slot = string.Empty),
+ "satellites[0].slot names no file, and a satellite is a picture");
+
+ yield return Refused("a slot that does not exist", Orbit(satellite => satellite.Slot = "Nowhere"),
+ "satellites[0].slot 'Nowhere' is not an asset slot");
+
+ yield return Refused("a slot by a number no slot has", Orbit(satellite => satellite.Slot = "9"),
+ "satellites[0].slot '9' is not an asset slot");
+
+ // Enum.TryParse reads all of these as a member, and none of them is a name anybody wrote.
+ foreach (var slot in new[] { "0", "4", "7", "Primary, Secondary", " Primary", "Primary ", "primary", "PRIMARY" })
+ {
+ yield return Refused($"a slot spelled '{slot}'", Orbit(satellite => satellite.Slot = slot),
+ $"satellites[0].slot '{slot}' is not an asset slot");
+ }
+
+ yield return Refused("a slot the kind has no file for", Orbit(satellite => satellite.Slot = "Poster"),
+ "satellites[0].slot 'Poster' is not a slot an orbit has a file in");
+
+ yield return Refused("a figure that is not there", Orbit(Satellite(), null!, Satellite()),
+ "satellites[1] is null");
+
+ yield return Refused("only figures that are not there", Orbit(null!, null!),
+ "satellites[0] is null",
+ "satellites[1] is null");
+
+ yield return Refused("no radius, so no reach to measure", Orbit(satellite => satellite.RadiusPct = null),
+ "satellites[0].radiusPct is missing");
+
+ yield return Refused("a negative radius", Orbit(satellite => satellite.RadiusPct = -1d),
+ "satellites[0].radiusPct is -1, and the range is 0 to 200");
+
+ yield return Refused("a radius past twice the face", Orbit(satellite => satellite.RadiusPct = 201d),
+ "satellites[0].radiusPct is 201, and the range is 0 to 200");
+
+ yield return Refused("no width, so no reach to measure", Orbit(satellite =>
+ {
+ satellite.WidthPct = null;
+ satellite.RadiusPct = 200d;
+ }),
+ "satellites[0].widthPct is missing");
+
+ yield return Refused("a figure of no width", Orbit(satellite => satellite.WidthPct = 0d),
+ "satellites[0].widthPct is 0, and the range is 1 to 200");
+
+ yield return Refused("no height, so no reach to measure", Orbit(satellite =>
+ {
+ satellite.HeightPct = null;
+ satellite.RadiusPct = 200d;
+ }),
+ "satellites[0].heightPct is missing");
+
+ yield return Refused("a figure twice the face and more", Orbit(satellite => satellite.HeightPct = 201d),
+ "satellites[0].heightPct is 201, and the range is 1 to 200");
+
+ yield return Refused("no period", Orbit(satellite => satellite.PeriodMs = null), "satellites[0].periodMs is missing");
+
+ yield return Refused("a strobe", Orbit(satellite => satellite.PeriodMs = 239), "satellites[0].periodMs is 239, and the range is 240 to 120000");
+
+ yield return Refused("a period past two minutes", Orbit(satellite => satellite.PeriodMs = 120_001),
+ "satellites[0].periodMs is 120001, and the range is 240 to 120000");
+
+ yield return Refused("a ring tipped past edge-on", Orbit(satellite => satellite.TiltDeg = 91d),
+ "satellites[0].tiltDeg is 91, and the range is 0 to 90");
+
+ yield return Refused("a ring tipped backwards", Orbit(satellite => satellite.TiltDeg = -1d),
+ "satellites[0].tiltDeg is -1, and the range is 0 to 90");
+
+ yield return Refused("a lean past half a turn", Orbit(satellite => satellite.YawDeg = 181d),
+ "satellites[0].yawDeg is 181, and the range is -180 to 180");
+
+ yield return Refused("a rock past half a turn", Orbit(satellite => satellite.YawSwingDeg = 181d),
+ "satellites[0].yawSwingDeg is 181, and the range is 0 to 180");
+
+ yield return Refused("a rock too quick", Orbit(satellite => satellite.YawSwingMs = 239),
+ "satellites[0].yawSwingMs is 239, and the range is 240 to 120000");
+
+ yield return Refused("a rock past two minutes", Orbit(satellite => satellite.YawSwingMs = 120_001),
+ "satellites[0].yawSwingMs is 120001, and the range is 240 to 120000");
+
+ yield return Refused("a phase past two minutes back", Orbit(satellite => satellite.PhaseMs = -120_001),
+ "satellites[0].phaseMs is -120001, and the range is -120000 to 120000");
+
+ yield return Refused("a still pose past a full circle", Orbit(satellite => satellite.StillDeg = 361d),
+ "satellites[0].stillDeg is 361, and the range is 0 to 360");
+
+ yield return Refused("an invisible figure", Orbit(satellite => satellite.Opacity = 0d),
+ "satellites[0].opacity is 0, and the range is 0.05 to 1");
+
+ yield return Refused("a figure lean past half a turn", Orbit(satellite =>
+ {
+ satellite.LeanDeg = -181d;
+ satellite.RadiusPct = 100d;
+ }),
+ "satellites[0].leanDeg is -181, and the range is -180 to 180");
+
+ yield return Refused("a turn of neither kind", Orbit(satellite => satellite.Turn = "flip"),
+ "satellites[0].turn is 'mirror' or 'spin'");
+
+ yield return Refused("a figure shrunk to nothing up close", Orbit(satellite => satellite.NearScale = 0d),
+ "satellites[0].nearScale is 0, and the range is 0.1 to 4");
+
+ yield return Refused("a scale out of range, which is not also measured", Orbit(satellite => satellite.NearScale = 5d),
+ "satellites[0].nearScale is 5, and the range is 0.1 to 4");
+
+ yield return Refused("a figure grown past four times far away", Orbit(satellite => satellite.FarScale = 5d),
+ "satellites[0].farScale is 5, and the range is 0.1 to 4");
+
+ yield return Refused("a dimming past dark", Orbit(satellite => satellite.Dim = 1.5d),
+ "satellites[0].dim is 1.5, and the range is 0 to 1");
+
+ yield return Refused("a negative haze", Orbit(satellite => satellite.Haze = -0.5d),
+ "satellites[0].haze is -0.5, and the range is 0 to 1");
+
+ yield return Refused("a blur past an eighth of the face", Orbit(satellite => satellite.BlurPct = 13d),
+ "satellites[0].blurPct is 13, and the range is 0 to 12");
+
+ yield return Refused("a negative bob", Orbit(satellite => satellite.BobPct = -1d),
+ "satellites[0].bobPct is -1, and the range is 0 to 50");
+
+ yield return Refused("a bob too quick", Orbit(satellite => satellite.BobPeriodMs = 239),
+ "satellites[0].bobPeriodMs is 239, and the range is 240 to 120000");
+
+ // The strip a figure is drawn from.
+ AvatarOrbitPayload Strip(int? frames, int? columns, Action? change = null)
+ => Orbit(satellite =>
+ {
+ satellite.Sprite = new CosmeticOrbitSprite { Frames = frames, Columns = columns };
+ change?.Invoke(satellite.Sprite);
+ });
+
+ yield return Refused("a strip with no frames, which is checked no further", Strip(null, 4, sprite => sprite.Still = 9),
+ "satellites[0].sprite.frames is missing");
+
+ yield return Refused("a strip of one frame", Strip(1, 1), "satellites[0].sprite.frames is 1, and the range is 2 to 64");
+
+ yield return Refused("a strip of sixty-five frames", Strip(65, 5), "satellites[0].sprite.frames is 65, and the range is 2 to 64");
+
+ yield return Refused("a strip with no columns, which is checked no further", Strip(8, null, sprite => sprite.Still = 9),
+ "satellites[0].sprite.columns is missing");
+
+ yield return Refused("a strip of no columns", Strip(8, 0), "satellites[0].sprite.columns is 0, and the range is 1 to 64");
+
+ yield return Refused("a strip played not at all", Strip(8, 4, sprite => sprite.Fps = 0), "satellites[0].sprite.fps is 0, and the range is 1 to 60");
+
+ yield return Refused("a strip played too fast", Strip(8, 4, sprite => sprite.Fps = 61), "satellites[0].sprite.fps is 61, and the range is 1 to 60");
+
+ yield return Refused("more columns than frames", Strip(2, 4), "satellites[0].sprite.frames must divide evenly into rows of 4");
+
+ yield return Refused("a last row that runs out", Strip(6, 4), "satellites[0].sprite.frames must divide evenly into rows of 4");
+
+ yield return Refused("a still frame past the strip", Strip(8, 4, sprite => sprite.Still = 8),
+ "satellites[0].sprite.still names frame 8, and the strip has 8");
+
+ yield return Refused("a still frame before the strip", Strip(8, 4, sprite => sprite.Still = -1),
+ "satellites[0].sprite.still names frame -1, and the strip has 8");
+
+ // Reach: what lands on the neighbouring message is the sum, so the sum is checked.
+ yield return Refused("a figure that spins presents its corner", Orbit(satellite => satellite.Turn = "spin"),
+ "satellites[0] reaches 167 per cent of the face past its edge, and 150 is as far as an ornament goes");
+
+ yield return Refused("a figure that leans presents its corner", Orbit(satellite => satellite.LeanDeg = 10d),
+ "satellites[0] reaches 167 per cent of the face past its edge, and 150 is as far as an ornament goes");
+
+ yield return Refused("a figure that bobs reaches further", Orbit(satellite => satellite.BobPct = 10d),
+ "satellites[0] reaches 160 per cent of the face past its edge, and 150 is as far as an ornament goes");
+
+ yield return Refused("a figure larger up close reaches further", Orbit(satellite => satellite.NearScale = 1.5d),
+ "satellites[0] reaches 250 per cent of the face past its edge, and 150 is as far as an ornament goes");
+
+ yield return Refused("a figure larger far away reaches further", Orbit(satellite => satellite.FarScale = 1.25d),
+ "satellites[0] reaches 200 per cent of the face past its edge, and 150 is as far as an ornament goes");
+
+ yield return Refused("every satellite is measured, the second named by its place",
+ Orbit(Satellite(), Satellite(satellite => satellite.Turn = "spin")),
+ "satellites[1] reaches 167 per cent of the face past its edge, and 150 is as far as an ornament goes");
+ }
+
+ private static IEnumerable Acceptances()
+ {
+ yield return Accepted("a figure square to the screen at the ceiling", Orbit(Satellite()));
+ yield return Accepted("eight satellites", Orbit(Enumerable.Range(0, 8).Select(_ => Satellite()).ToArray()));
+ yield return Accepted("the largest still size", new AvatarOrbitPayload { Satellites = [Satellite()], MinSizePx = 512 });
+ yield return Accepted("a figure that would spin but does not face its travel", Orbit(satellite =>
+ {
+ satellite.Turn = "spin";
+ satellite.FaceTravel = false;
+ }));
+
+ yield return Accepted("a figure that spins facing its travel, well inside", Orbit(satellite =>
+ {
+ satellite.Turn = "spin";
+ satellite.FaceTravel = true;
+ satellite.RadiusPct = 100d;
+ }));
+
+ yield return Accepted("a figure that mirrors", Orbit(satellite => satellite.Turn = "mirror"));
+ yield return Accepted("a figure with an empty turn", Orbit(satellite => satellite.Turn = string.Empty));
+ yield return Accepted("a figure with no lean", Orbit(satellite => satellite.LeanDeg = 0d));
+ yield return Accepted("a figure smaller far away", Orbit(satellite => satellite.FarScale = 0.5d));
+ yield return Accepted("every number at the edge of its range", Orbit(satellite =>
+ {
+ satellite.RadiusPct = 0d;
+ satellite.WidthPct = 200d;
+ satellite.HeightPct = 1d;
+ satellite.PeriodMs = 240;
+ satellite.TiltDeg = 90d;
+ satellite.YawDeg = -180d;
+ satellite.YawSwingDeg = 180d;
+ satellite.YawSwingMs = 120_000;
+ satellite.PhaseMs = 120_000;
+ satellite.Reverse = true;
+ satellite.StillDeg = 360d;
+ satellite.Opacity = 0.05d;
+ satellite.Dim = 1d;
+ satellite.Haze = 0d;
+ satellite.BlurPct = 12d;
+ satellite.Occlude = false;
+ satellite.BobPct = 0d;
+ satellite.BobPeriodMs = 120_000;
+ satellite.Sprite = new CosmeticOrbitSprite { Frames = 64, Columns = 8, Fps = 60, Still = 63 };
+ }));
+
+ foreach (var slot in OrbitKind.Assets.Keys)
+ {
+ yield return Accepted($"the {slot} slot", Orbit(satellite => satellite.Slot = slot.ToString()));
+ }
+ }
+
+ ///
+ /// The payload's own list of slots is the kind's. A slot the kind gained would otherwise be one no
+ /// figure may name, and one it lost a figure that names a file nobody can upload.
+ ///
+ [Test]
+ public void A_figure_may_name_exactly_the_slots_the_kind_declares()
+ {
+ foreach (var slot in Enum.GetValues())
+ {
+ var errors = ErrorsOf(Orbit(satellite => satellite.Slot = slot.ToString()));
+
+ Assert.That(errors, OrbitKind.Assets.ContainsKey(slot) ? Is.Empty : Is.Not.Empty, slot.ToString());
+ }
+ }
+
+ [Test]
+ public void A_figure_arrives_with_every_number_the_row_said()
+ {
+ var orbit = new AvatarOrbitPayload
+ {
+ MinSizePx = 34,
+ Satellites =
+ [
+ Satellite(satellite =>
+ {
+ satellite.Slot = "Secondary";
+ satellite.Sprite = new CosmeticOrbitSprite { Frames = 8, Columns = 4, Fps = 12, Still = 3 };
+ satellite.TiltDeg = 70d;
+ satellite.YawDeg = -20d;
+ satellite.YawSwingDeg = 15d;
+ satellite.YawSwingMs = 9000;
+ satellite.PhaseMs = -1500;
+ satellite.Reverse = true;
+ satellite.StillDeg = 90d;
+ satellite.Opacity = 0.8d;
+ satellite.LeanDeg = 5d;
+ satellite.FaceTravel = true;
+ satellite.Turn = "mirror";
+ satellite.NearScale = 1.2d;
+ satellite.FarScale = 0.6d;
+ satellite.Dim = 0.3d;
+ satellite.Haze = 0.2d;
+ satellite.BlurPct = 1.5d;
+ satellite.Occlude = false;
+ satellite.BobPct = 4d;
+ satellite.BobPeriodMs = 1300;
+ })
+ ]
+ };
+
+ var wire = (PayloadAvatarOrbit)orbit.ToWire();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(wire.minSizePx, Is.EqualTo((ushort)34));
+ Assert.That(wire.satellites.Single(), Is.EqualTo(new OrbitSatellite(
+ AssetSlot.Secondary,
+ new OrbitSprite(8, 4, 12, 3),
+ 160d,
+ 70d,
+ -20d,
+ 15d,
+ 9000,
+ 6000,
+ -1500,
+ true,
+ 90d,
+ 80d,
+ 80d,
+ 0.8d,
+ 5d,
+ true,
+ OrbitTurn.Mirror,
+ 1.2d,
+ 0.6d,
+ 0.3d,
+ 0.2d,
+ 1.5d,
+ false,
+ 4d,
+ 1300)));
+ });
+ }
+
+ [Test]
+ public void A_figure_that_says_little_arrives_saying_little()
+ {
+ var wire = (PayloadAvatarOrbit)Orbit(Satellite(satellite => satellite.Sprite = new CosmeticOrbitSprite { Frames = 8, Columns = 4 })).ToWire();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(wire.minSizePx, Is.Null);
+ Assert.That(wire.satellites.Single(), Is.EqualTo(new OrbitSatellite(
+ AssetSlot.Primary,
+ new OrbitSprite(8, 4, null, null),
+ 160d,
+ null,
+ null,
+ null,
+ null,
+ 6000,
+ null,
+ null,
+ null,
+ 80d,
+ 80d,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null)));
+ });
+ }
+
+ [TestCase("mirror", OrbitTurn.Mirror)]
+ [TestCase("spin", OrbitTurn.Spin)]
+ [TestCase(null, null)]
+ [TestCase("", null)]
+ [TestCase("flip", null)]
+ public void A_turn_is_its_case_or_absent(string? turn, OrbitTurn? expected)
+ {
+ var wire = (PayloadAvatarOrbit)Orbit(Satellite(satellite => satellite.Turn = turn)).ToWire();
+
+ Assert.That(((OrbitSatellite)wire.satellites.Single()).turn, Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void A_figure_with_no_strip_is_one_drawing()
+ {
+ var wire = (PayloadAvatarOrbit)Orbit(Satellite()).ToWire();
+
+ Assert.That(((OrbitSatellite)wire.satellites.Single()).sprite, Is.Null);
+ }
+
+ /// The mapping is only handed what passed validation; a body it has no case for stops it.
+ [Test]
+ public void An_unvalidated_body_of_no_known_type_stops_the_mapping()
+ => Assert.That(() => Orbit(Satellite(satellite => satellite.Type = "moon")).ToWire(),
+ Throws.TypeOf().With.Message.Contains("an orbit body this build has no case for"));
+}
diff --git a/tests/ArgonSharedLogicTest/CosmeticPayloadWireTests.cs b/tests/ArgonSharedLogicTest/CosmeticPayloadWireTests.cs
index e0242216..c8d83a65 100644
--- a/tests/ArgonSharedLogicTest/CosmeticPayloadWireTests.cs
+++ b/tests/ArgonSharedLogicTest/CosmeticPayloadWireTests.cs
@@ -23,6 +23,34 @@ public class CosmeticPayloadWireTests
private static ICosmeticPayload? Wire(string kindKey, string json) => Registry.Find(kindKey)!.PayloadToWire(json);
+ private const string Orbit =
+ """
+ {"minSizePx":34,"satellites":[
+ {"type":"satellite","slot":"Primary","radiusPct":62,"widthPct":30,"heightPct":24,"periodMs":6000,
+ "tiltDeg":70,"turn":"spin","nearScale":1.2,"sprite":{"frames":8,"columns":4}}
+ ]}
+ """;
+
+ /// One of every sort of actor, in the order a scene draws them.
+ private const string Scene =
+ """
+ {"reach":"choice","defaultReach":"content","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},"fit":"cover","spillPct":4,
+ "retreat":{"atMs":3000,"durationMs":800,"remain":0.2}},
+ {"type":"sprite","source":"atlas","atlas":{"x":64,"y":0,"w":32,"h":32,"frames":4,"columns":4,"fps":8},
+ "sizePct":20,"durationMs":4000,"from":{"anchor":"topRight","dxPct":-2.5},"to":{"anchor":"bottomLeft"},
+ "ease":"inOut","turn":"mirror","bowAxis":"y","depth":"front","occlude":["avatar"],
+ "rotate":[{"at":0,"v":-10},{"at":1,"v":10}]},
+ {"type":"band","source":"file","slot":"Secondary","edge":"bottom","slice":[8,8,8,8],"thicknessPct":12,
+ "tile":"round","grow":{"fromPct":0,"toPct":100,"durationMs":2000,"from":"bottomLeft","leadAxis":"y",
+ "sway":{"deg":3,"ms":5000}}},
+ {"type":"wrap","source":"file","slot":"Tertiary","replay":true,"slice":[24,24,24,24],"widthPct":[6,0,6,0]},
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":64,"w":16,"h":16},"edge":"top","count":24,
+ "seed":7,"sizePct":4,"sizeVarPct":2,"durationMs":9000,"repeat":"pingPong","flutterDeg":40,
+ "shadow":{"dxPct":0.5,"dyPct":1,"blurPct":1,"alpha":0.3}}
+ ]}
+ """;
+
/// A kind with rows and no case would have every row of it quietly missing from the catalogue.
[Test]
public void Every_kind_with_rows_has_a_case_on_the_wire()
@@ -32,7 +60,11 @@ public void Every_kind_with_rows_has_a_case_on_the_wire()
["avatar.decoration"] = """{"insetPct":10,"beneath":false}""",
["profile.frame"] = """{"parts":[{"type":"ring","thickness":2,"colors":[-1]}]}""",
["option.font"] = """{"cssFamily":"Inter"}""",
- ["option.text-effect"] = "{}"
+ ["option.text-effect"] = "{}",
+ ["profile.background"] = "{}",
+ ["profile.badge"] = """{"tooltipKey":"badge_staff"}""",
+ ["avatar.orbit"] = Orbit,
+ ["profile.scene"] = Scene
};
var withRows = Registry.All.Where(kind => !kind.IsBare).Select(kind => kind.Key).ToList();
@@ -130,4 +162,157 @@ public void What_a_part_leaves_out_arrives_as_what_the_client_would_draw()
Assert.That(ring.motion, Is.Null);
});
}
+
+ [Test]
+ public void A_background_is_its_loop_and_its_tint()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Wire("profile.background", """{"loop":false,"tintOpacity":0.5}"""),
+ Is.EqualTo(new PayloadProfileBackground(false, 0.5d)));
+
+ // The payload type's own defaults: a clip loops, under a third of the card's colour.
+ Assert.That(Wire("profile.background", "{}"), Is.EqualTo(new PayloadProfileBackground(true, 0.35d)));
+ Assert.That(Wire("profile.background", """{"tintOpacity":4}"""), Is.Null);
+ });
+ }
+
+ [Test]
+ public void A_badge_is_its_tooltip_and_its_tint()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Wire("profile.badge", """{"tooltipKey":"badge_staff","tint":-65536}"""),
+ Is.EqualTo(new PayloadBadge("badge_staff", -65536)));
+ Assert.That(Wire("profile.badge", """{"tooltipKey":"badge_staff"}"""), Is.EqualTo(new PayloadBadge("badge_staff", null)));
+ Assert.That(Wire("profile.badge", """{"tint":255}"""), Is.Null);
+ });
+ }
+
+ [Test]
+ public void An_orbit_arrives_as_typed_figures_saying_only_what_the_row_said()
+ {
+ var orbit = (PayloadAvatarOrbit)Wire("avatar.orbit", Orbit)!;
+ var cat = (OrbitSatellite)orbit.satellites.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(orbit.minSizePx, Is.EqualTo((ushort)34));
+ Assert.That(cat.slot, Is.EqualTo(AssetSlot.Primary));
+ Assert.That(cat.radiusPct, Is.EqualTo(62d));
+ Assert.That(cat.periodMs, Is.EqualTo(6000));
+ Assert.That(cat.tiltDeg, Is.EqualTo(70d));
+ Assert.That(cat.turn, Is.EqualTo(OrbitTurn.Spin));
+ Assert.That(cat.nearScale, Is.EqualTo(1.2d));
+ Assert.That(cat.sprite, Is.EqualTo(new OrbitSprite(8, 4, null, null)));
+
+ // Absent stays absent: the renderer holds the defaults.
+ Assert.That(cat.farScale, Is.Null);
+ Assert.That(cat.occlude, Is.Null);
+ Assert.That(cat.stillDeg, Is.Null);
+ });
+ }
+
+ [Test]
+ public void An_orbit_that_does_not_fit_its_kind_is_not_sent()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Wire("avatar.orbit", """{"satellites":[]}"""), Is.Null);
+ Assert.That(Wire("avatar.orbit",
+ """{"satellites":[{"type":"satellite","slot":"7","radiusPct":62,"widthPct":30,"heightPct":24,"periodMs":6000}]}"""),
+ Is.Null);
+ Assert.That(Wire("avatar.orbit",
+ """{"satellites":[{"type":"satellite","slot":"Primary","radiusPct":62,"widthPct":30,"heightPct":24,"periodMs":6000,"turn":"flip"}]}"""),
+ Is.Null);
+ Assert.That(Wire("avatar.orbit",
+ """{"satellites":[{"type":"satellite","slot":"4","radiusPct":62,"widthPct":30,"heightPct":24,"periodMs":6000}]}"""),
+ Is.Null);
+ Assert.That(Wire("avatar.orbit", """{"satellites":[null]}"""), Is.Null);
+ });
+ }
+
+ [Test]
+ public void A_scene_arrives_as_one_case_per_sort_of_actor_in_drawing_order()
+ {
+ var scene = (PayloadProfileScene)Wire("profile.scene", Scene)!;
+ var actors = scene.actors;
+
+ Assert.That(actors.Select(actor => actor.GetType()), Is.EqualTo(new[]
+ {
+ typeof(SceneWash), typeof(SceneSprite), typeof(SceneBand), typeof(SceneWrap), typeof(SceneEmitter)
+ }));
+
+ var wash = (SceneWash)actors[0];
+ var sprite = (SceneSprite)actors[1];
+ var band = (SceneBand)actors[2];
+ var wrap = (SceneWrap)actors[3];
+ var emitter = (SceneEmitter)actors[4];
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(scene.reach, Is.EqualTo(SceneReachRule.Choice));
+ Assert.That(scene.defaultReach, Is.EqualTo(SceneReach.Content));
+ Assert.That(scene.sheet, Is.EqualTo(new SceneSheet(256, 128)));
+
+ Assert.That(wash.source, Is.EqualTo(SceneSource.Atlas));
+ Assert.That(wash.atlas, Is.EqualTo(new SceneAtlas(0, 0, 64, 64, null, null, null, null)));
+ Assert.That(wash.fit, Is.EqualTo(SceneFit.Cover));
+ Assert.That(wash.spillPct, Is.EqualTo(4d));
+ Assert.That(wash.retreat, Is.EqualTo(new SceneRetreat(3000, 800, null, null, null, 0.2d)));
+
+ Assert.That(sprite.atlas, Is.EqualTo(new SceneAtlas(64, 0, 32, 32, 4, 4, 8, null)));
+ Assert.That(sprite.from, Is.EqualTo(new ScenePoint(SceneAnchor.TopRight, -2.5d, null)));
+ Assert.That(sprite.to, Is.EqualTo(new ScenePoint(SceneAnchor.BottomLeft, null, null)));
+ Assert.That(sprite.ease, Is.EqualTo(SceneEase.InOut));
+ Assert.That(sprite.turn, Is.EqualTo(SceneTurn.Mirror));
+ Assert.That(sprite.bowAxis, Is.EqualTo(SceneBowAxis.Y));
+ Assert.That(sprite.depth, Is.EqualTo(SceneDepth.Front));
+ Assert.That(sprite.occlude, Is.EqualTo(new[] { SceneOccluder.Avatar }));
+ Assert.That(sprite.rotate, Is.EqualTo(new[] { new SceneStop(0d, -10d), new SceneStop(1d, 10d) }));
+
+ Assert.That(band.slot, Is.EqualTo(AssetSlot.Secondary));
+ Assert.That(band.edge, Is.EqualTo(SceneEdge.Bottom));
+ Assert.That(band.slice, Is.EqualTo(new SceneSlice(8, 8, 8, 8)));
+ Assert.That(band.tile, Is.EqualTo(SceneTile.Round));
+ Assert.That(band.cornerPct, Is.Null);
+ Assert.That(band.grow, Is.EqualTo(new SceneGrow(0d, 100d, 2000, null, null, null, SceneLeadAxis.Y, null, null,
+ new SceneSway(3d, 5000), SceneGrowOrigin.BottomLeft)));
+
+ Assert.That(wrap.slot, Is.EqualTo(AssetSlot.Tertiary));
+ Assert.That(wrap.replay, Is.True);
+ Assert.That(wrap.widthPct, Is.EqualTo(new SceneWidths(6d, 0d, 6d, 0d)));
+
+ Assert.That(emitter.edge, Is.EqualTo(SceneEdge.Top));
+ Assert.That(emitter.count, Is.EqualTo((ushort)24));
+ Assert.That(emitter.seed, Is.EqualTo((ushort)7));
+ Assert.That(emitter.sizeVarPct, Is.EqualTo(2d));
+ Assert.That(emitter.repeat, Is.EqualTo(SceneRepeat.PingPong));
+ Assert.That(emitter.flutterDeg, Is.EqualTo(40d));
+ Assert.That(emitter.shadow, Is.EqualTo(new SceneShadow(0.5d, 1d, 1d, 0.3d)));
+ });
+ }
+
+ /// A field another sort of actor owns keeps the whole row out, rather than being dropped.
+ [Test]
+ public void A_scene_that_does_not_fit_its_kind_is_not_sent()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Wire("profile.scene", """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary","edge":"top","slice":[8,8,8,8],"thicknessPct":10,"count":3}
+ ]}
+ """), Is.Null);
+
+ // A null in a list is a refusal like any other, never something the wire is asked to read.
+ Assert.That(Wire("profile.scene", """{"reach":"card","actors":[null]}"""), Is.Null);
+ Assert.That(Wire("profile.scene", """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary","edge":"top","slice":[8,8,8,8],"thicknessPct":10,
+ "opacity":[{"at":0,"v":1},null],"occlude":[null]}
+ ]}
+ """), Is.Null);
+ });
+ }
}
diff --git a/tests/ArgonSharedLogicTest/CosmeticScenePayloadTests.cs b/tests/ArgonSharedLogicTest/CosmeticScenePayloadTests.cs
new file mode 100644
index 00000000..2e01319b
--- /dev/null
+++ b/tests/ArgonSharedLogicTest/CosmeticScenePayloadTests.cs
@@ -0,0 +1,1330 @@
+namespace ArgonSharedLogicTest;
+
+using Argon.Features.Cosmetics;
+
+///
+/// What a scene's payload has to satisfy, and the refusals that are the whole point of it.
+///
+///
+/// A scene carries more numbers than anything else in the catalogue and every one of them arrives
+/// from a form somebody typed into. The cases below are the ones where a wrong value is not a wrong
+/// picture but a broken card: an envelope that does not cover its cycle, a field belonging to
+/// another sort of actor, and a spread wider than the thing it is spreading.
+///
+[TestFixture]
+public class CosmeticScenePayloadTests
+{
+ private const string OneSprite =
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"}}
+ ]}
+ """;
+
+ private static CosmeticPayloadValidation Check(string json)
+ => CosmeticPayloadSchema.For().Validate(json);
+
+ [Test]
+ public void A_scene_of_one_sprite_is_valid()
+ {
+ var result = Check(OneSprite);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_scene_with_no_actors_is_refused()
+ {
+ Assert.That(Check("""{"reach":"card","sheet":{"w":256,"h":128},"actors":[]}""").IsValid, Is.False);
+ }
+
+ [Test]
+ public void Reach_outside_the_three_words_is_refused()
+ {
+ Assert.That(Check("""{"reach":"everything","actors":[]}""").IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_default_reach_without_a_choice_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","defaultReach":"content","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_choice_without_a_default_reach_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"choice","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_field_belonging_to_another_sort_of_actor_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "durationMs":4000}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("durationMs"));
+ }
+
+ [Test]
+ public void An_envelope_that_does_not_start_at_zero_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "opacity":[{"at":0.2,"v":0},{"at":1,"v":1}]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void An_envelope_whose_stops_go_backwards_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "opacity":[{"at":0,"v":0},{"at":0.7,"v":1},{"at":0.3,"v":0.5},{"at":1,"v":0}]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void An_envelope_of_one_stop_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "opacity":[{"at":0,"v":1}]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_non_monotonic_envelope_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "opacity":[{"at":0,"v":0},{"at":0.15,"v":1},{"at":0.8,"v":1},{"at":1,"v":0}]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// The one scene here that carries no sheet, because nothing in it reads one — a sheet would be
+ /// a second refusal and this test would stop saying what it is named for.
+ ///
+ [Test]
+ public void An_emitter_reading_a_whole_file_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"emitter","source":"file","slot":"Secondary","edge":"top",
+ "count":10,"sizePct":4,"durationMs":6000}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("cannot be spread out"));
+ }
+
+ [Test]
+ public void An_emitter_spread_wider_than_its_middle_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"sizeVarPct":6,"durationMs":6000}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_band_with_a_file_of_its_own_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,"cornerPct":12}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// The refusal that keeps a band a nine-slice: border-image-source is a whole picture,
+ /// and a rectangle of the sheet is not one.
+ ///
+ [Test]
+ public void A_band_reading_the_sheet_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"band","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("a file of its own"));
+ }
+
+ [Test]
+ public void A_band_that_grows_inwards_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":100,"toPct":0,"durationMs":2000}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_wrap_with_a_file_and_four_widths_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary",
+ "slice":[24,24,24,24],"widthPct":[8,8,8,8],"tile":"round"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// A wrap is a nine-slice, so it takes the band's refusal along with the band's cut.
+ ///
+ [Test]
+ public void A_wrap_reading_the_sheet_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wrap","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "slice":[24,24,24,24],"widthPct":[8,8,8,8]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("a file of its own"));
+ }
+
+ [Test]
+ public void A_wrap_with_no_widths_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary","slice":[24,24,24,24]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("widthPct is missing"));
+ }
+
+ [Test]
+ public void A_wrap_whose_widths_are_not_four_numbers_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary",
+ "slice":[24,24,24,24],"widthPct":[8,8]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("and has 2"));
+ }
+
+ ///
+ /// Four sides of nothing passes every range there is and draws no pixels, which on a card is
+ /// indistinguishable from a file that failed to load.
+ ///
+ [Test]
+ public void A_wrap_of_four_zero_widths_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary",
+ "slice":[24,24,24,24],"widthPct":[0,0,0,0]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("drawn nowhere"));
+ }
+
+ ///
+ /// The pair that keeps the two nine-slices apart: a wrap has no one side to lie on, and a band
+ /// has no four to be thick on.
+ ///
+ [Test]
+ public void A_wrap_given_an_edge_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary","edge":"bottom",
+ "slice":[24,24,24,24],"widthPct":[8,8,8,8]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("edge"));
+ }
+
+ [Test]
+ public void A_band_given_the_widths_of_a_wrap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,"widthPct":[8,8,8,8]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("widthPct"));
+ }
+
+ ///
+ /// A reveal out of a point rather than in from a side — the difference between a blind coming
+ /// down and a vine taking the card.
+ ///
+ [Test]
+ public void A_reveal_rooted_at_an_anchor_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary",
+ "slice":[24,24,24,24],"widthPct":[6,10,10,6],
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"from":"bottomLeft"}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_reveal_rooted_at_neither_an_edge_nor_an_anchor_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"from":"root"}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("grow.from"));
+ }
+
+ [Test]
+ public void Occluding_something_that_is_not_part_of_a_card_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "occlude":["board"]}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_field_the_type_does_not_declare_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},"fitt":"cover"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void A_scene_reading_the_sheet_without_one_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("sheet is missing"));
+ }
+
+ [Test]
+ public void A_sheet_nothing_reads_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"file","slot":"Secondary"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("sheet is only read"));
+ }
+
+ [Test]
+ public void A_rectangle_running_off_the_sheet_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":200,"y":0,"w":64,"h":64}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("8px past the right edge"));
+ }
+
+ ///
+ /// The pair that proves the fit is measured over the whole strip: the same rectangle is refused
+ /// as four frames and accepted as one picture.
+ ///
+ [Test]
+ public void A_strip_whose_last_frame_runs_off_the_sheet_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":72,"h":64,"frames":4,"columns":4},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("32px past the right edge"));
+ }
+
+ [Test]
+ public void The_same_rectangle_as_one_picture_fits()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":72,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// A scene that ends: every actor but a band may say its own cycle plays once.
+ ///
+ ///
+ /// repeat was a sprite's alone until 2026-09-18, which made a whole sort of cosmetic
+ /// inexpressible — the one that plays through when somebody opens a card and then simply leaves
+ /// it decorated. A wash and a scatter that could only loop went round forever underneath the
+ /// parts that had finished.
+ ///
+ [Test]
+ public void A_wash_and_a_scatter_may_play_once()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},"repeat":"once",
+ "opacity":[{"at":0,"v":0},{"at":1,"v":1}]},
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,"repeat":"once"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_band_still_takes_no_repeat_of_its_own()
+ {
+ // Its cycle is inside `grow`, next to the hold it shares one with. Two places to say the
+ // same thing is two places for them to disagree.
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,"repeat":"once"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("repeat"));
+ }
+
+ [Test]
+ public void A_repeat_outside_the_three_words_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},"repeat":"forever"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ [Test]
+ public void Tuning_outside_the_two_words_is_refused()
+ {
+ var result = CosmeticPayloadSchema.For().Validate("""{"reach":"both"}""");
+
+ Assert.That(result.IsValid, Is.False);
+ }
+
+ // Volume, physics and growth — the fields added 2026-09-18.
+ //
+ // Every one of them is optional, and the proof of that is every test above this line: not one
+ // of them names a field below, and all of them still pass. That is what "an existing row keeps
+ // parsing" means, and it is worth more than a test that asserts it in one payload.
+
+ ///
+ /// A shadow belongs to no one sort of actor, so the thing worth checking is that it is refused
+ /// by none of them.
+ ///
+ [Test]
+ public void A_shadow_on_every_sort_of_actor_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "shadow":{"dxPct":2,"dyPct":4,"blurPct":3,"alpha":0.35}},
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},
+ "shadow":{"dxPct":-50,"dyPct":50,"blurPct":50,"alpha":0}},
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "shadow":{"dxPct":0,"dyPct":-50,"blurPct":0,"alpha":1}},
+ {"type":"wrap","source":"file","slot":"Tertiary",
+ "slice":[24,24,24,24],"widthPct":[8,8,8,8],
+ "shadow":{"dxPct":-4,"dyPct":-4,"blurPct":6,"alpha":0.2}},
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,
+ "shadow":{"dxPct":1,"dyPct":1,"blurPct":2,"alpha":0.4}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_shadow_thrown_further_than_half_the_card_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "shadow":{"dxPct":51,"dyPct":4,"blurPct":3,"alpha":0.35}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("shadow.dxPct"));
+ }
+
+ [Test]
+ public void A_shadow_blurred_past_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "shadow":{"dxPct":2,"dyPct":4,"blurPct":51,"alpha":0.35}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("shadow.blurPct"));
+ }
+
+ [Test]
+ public void A_shadow_more_opaque_than_opaque_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "shadow":{"dxPct":2,"dyPct":4,"blurPct":3,"alpha":1.5}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("shadow.alpha"));
+ }
+
+ ///
+ /// Four numbers or nothing: a blur with no alpha is a shadow at whatever opacity the renderer
+ /// picked, which is the renderer deciding where the sun is.
+ ///
+ [Test]
+ public void A_shadow_missing_a_member_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "shadow":{"dxPct":2,"dyPct":4,"blurPct":3}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("shadow.alpha is missing"));
+ }
+
+ [Test]
+ public void A_fluttering_scatter_with_depth_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,
+ "flutterDeg":72,"bobPct":3,
+ "swayPct":4,"swayPeriodMs":2400,"swayVarMs":900,
+ "depthSpreadPct":60}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_flutter_past_edge_on_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,"flutterDeg":91}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("flutterDeg"));
+ }
+
+ [Test]
+ public void A_scatter_bobbing_deeper_than_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,"bobPct":26}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("bobPct"));
+ }
+
+ [Test]
+ public void A_depth_spread_past_a_whole_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,"depthSpreadPct":101}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("depthSpreadPct"));
+ }
+
+ ///
+ /// The same trap as a size spread wider than its middle, one period in: the copies at the far
+ /// end of the spread come out with no swing at all.
+ ///
+ [Test]
+ public void A_sway_spread_wider_than_its_period_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,
+ "swayPct":4,"swayPeriodMs":2400,"swayVarMs":3000}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("swayVarMs is wider than swayPeriodMs"));
+ }
+
+ [Test]
+ public void A_bowed_and_banked_sprite_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},
+ "bowPct":-140,"bowAxis":"y","bankDeg":-22,"bobPct":5}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_bow_past_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},"bowPct":201}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("bowPct"));
+ }
+
+ [Test]
+ public void A_bank_past_half_a_turn_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},"bankDeg":-181}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("bankDeg"));
+ }
+
+ [Test]
+ public void A_sprite_bobbing_deeper_than_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},"bobPct":26}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("bobPct"));
+ }
+
+ [Test]
+ public void A_bow_axis_outside_the_three_words_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},
+ "bowPct":40,"bowAxis":"sideways"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("bowAxis"));
+ }
+
+ ///
+ /// bobPct is the one field a sprite and a scatter share and mean different things by, so
+ /// it belongs to neither's own list — and what could go wrong is that it is put in both, and
+ /// each of them refuses its own field.
+ ///
+ [Test]
+ public void A_bob_belongs_to_the_two_travelling_sorts_and_to_nothing_else()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},"bobPct":4}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("bobPct"));
+ }
+
+ ///
+ /// The pair that proves each new name is registered in both halves of the refusal.
+ ///
+ ///
+ /// An accepting test cannot see a half-registered field. A name listed as foreign but
+ /// missing from the name-to-property switch reads as never filled, so it is silently allowed
+ /// everywhere and the actor it belongs to still parses. Only the wrong sort of actor carrying
+ /// it tells the two halves apart.
+ ///
+ [Test]
+ public void The_scatter_only_fields_are_refused_on_a_sprite()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},
+ "flutterDeg":30,"swayVarMs":400,"depthSpreadPct":50}
+ ]}
+ """);
+
+ var errors = string.Join("; ", result.Errors);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(errors, Does.Contain("flutterDeg"));
+ Assert.That(errors, Does.Contain("swayVarMs"));
+ Assert.That(errors, Does.Contain("depthSpreadPct"));
+ }
+
+ [Test]
+ public void The_sprite_only_fields_are_refused_on_a_scatter()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,
+ "bowPct":40,"bowAxis":"x","bankDeg":12}
+ ]}
+ """);
+
+ var errors = string.Join("; ", result.Errors);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(errors, Does.Contain("bowPct"));
+ Assert.That(errors, Does.Contain("bowAxis"));
+ Assert.That(errors, Does.Contain("bankDeg"));
+ }
+
+ [Test]
+ public void A_growth_that_extends_wobbles_and_breathes_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"from":"bottom",
+ "fromScale":0.08,"leadAxis":"both","wobbleDeg":4,"softness":0.7,
+ "sway":{"deg":1.5,"ms":5200}}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_growth_starting_at_no_size_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"fromScale":0}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("fromScale"));
+ }
+
+ [Test]
+ public void A_growth_starting_larger_than_it_finishes_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"fromScale":1.5}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("fromScale"));
+ }
+
+ [Test]
+ public void A_lead_axis_outside_the_three_words_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"leadAxis":"z"}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("leadAxis"));
+ }
+
+ [Test]
+ public void A_wobble_past_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"wobbleDeg":46}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("wobbleDeg"));
+ }
+
+ [Test]
+ public void A_reveal_softer_than_a_whole_edge_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"softness":1.5}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("softness"));
+ }
+
+ [Test]
+ public void A_breathing_sway_leaning_past_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"sway":{"deg":46,"ms":5200}}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("sway.deg"));
+ }
+
+ [Test]
+ public void A_breathing_sway_faster_than_the_shortest_period_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"sway":{"deg":2,"ms":100}}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("sway.ms"));
+ }
+
+ [Test]
+ public void A_breathing_sway_missing_a_member_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":0,"toPct":100,"durationMs":3000,"sway":{"deg":2}}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("sway.ms is missing"));
+ }
+
+ // Spill, replay and retreat — the fields added 2026-09-18 for a picture that hangs past the card
+ // and then gets out of the way of the text.
+ //
+ // The same proof of optionality as above: nothing above this line names a field below.
+
+ [Test]
+ public void A_spilling_wash_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},"spillPct":12}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_spilling_sprite_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},"spillPct":5}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// The spill box is the card plus the hang, and only cover fills it: a contained picture
+ /// would hang past the card as empty box. An absent fit is cover, and passes.
+ ///
+ [Test]
+ public void A_spilling_wash_that_does_not_cover_the_card_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "spillPct":12,"fit":"contain"}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("spillPct needs fit 'cover'"));
+ }
+
+ [Test]
+ public void A_spill_on_a_band_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,"spillPct":4}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("has no 'spillPct'"));
+ }
+
+ [Test]
+ public void A_spill_on_a_wrap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wrap","source":"file","slot":"Secondary",
+ "slice":[24,24,24,24],"widthPct":[8,8,8,8],"spillPct":4}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("has no 'spillPct'"));
+ }
+
+ [Test]
+ public void A_spill_on_a_scatter_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,"spillPct":4}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("has no 'spillPct'"));
+ }
+
+ [Test]
+ public void A_spill_past_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},"spillPct":26}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("spillPct is 26"));
+ }
+
+ [Test]
+ public void A_file_that_replays_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"wash","source":"file","slot":"Secondary","replay":true}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// Only a file has a clock of its own to restart. A rectangle of the sheet is moved by this
+ /// code's animations, which start afresh with the card whatever anybody says.
+ ///
+ [Test]
+ public void A_rectangle_of_the_sheet_that_replays_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},"replay":true}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("no clock of its own"));
+ }
+
+ /// The console's toggle writes false, so false has to pass everywhere.
+ [Test]
+ public void A_replay_switched_off_is_accepted_on_a_rectangle_of_the_sheet()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"sprite","source":"atlas","atlas":{"x":0,"y":0,"w":64,"h":64},
+ "sizePct":20,"durationMs":4000,
+ "from":{"anchor":"topRight"},"to":{"anchor":"bottomLeft"},"replay":false}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_retreating_wash_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "retreat":{"atMs":9200,"durationMs":2800,"edgePct":9,"footPct":7,"softPct":6,"remain":0}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ ///
+ /// The strips default to nothing and the softness to a little, so when and how long are the
+ /// whole of a retreat that keeps nothing.
+ ///
+ [Test]
+ public void A_retreat_of_its_two_times_alone_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "retreat":{"atMs":9200,"durationMs":2800}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_retreat_with_no_start_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "retreat":{"durationMs":2800}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("retreat.atMs is missing"));
+ }
+
+ [Test]
+ public void A_retreat_keeping_more_than_half_the_card_a_side_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "retreat":{"atMs":9200,"durationMs":2800,"edgePct":51}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("retreat.edgePct"));
+ }
+
+ [Test]
+ public void A_retreat_softer_than_its_cap_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "retreat":{"atMs":9200,"durationMs":2800,"softPct":26}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("retreat.softPct"));
+ }
+
+ ///
+ /// A whole passes the range and changes nothing on the card — the same trap as a wrap of four
+ /// zero widths, refused for the same reason.
+ ///
+ [Test]
+ public void A_retreat_that_leaves_everything_behind_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"wash","source":"atlas","atlas":{"x":0,"y":0,"w":8,"h":8},
+ "retreat":{"atMs":9200,"durationMs":2800,"remain":1}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("not a retreat"));
+ }
+
+ [Test]
+ public void A_scatter_given_a_retreat_is_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","sheet":{"w":256,"h":128},"actors":[
+ {"type":"emitter","source":"atlas","atlas":{"x":0,"y":0,"w":16,"h":16},
+ "edge":"top","count":10,"sizePct":4,"durationMs":6000,
+ "retreat":{"atMs":9200,"durationMs":2800}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("has no 'retreat'"));
+ }
+
+ ///
+ /// Equal ends install no reveal, and what is left of the grow — extension, wobble, duration and
+ /// breathing — is exactly what a tree that is drawn whole and only has to stand up wants.
+ ///
+ [Test]
+ public void A_growth_with_nothing_to_uncover_is_accepted()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":100,"toPct":100,"durationMs":7600,"sway":{"deg":1.1,"ms":5200}}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.True, string.Join("; ", result.Errors));
+ }
+
+ [Test]
+ public void A_growth_that_runs_inwards_is_still_refused()
+ {
+ var result = Check(
+ """
+ {"reach":"card","actors":[
+ {"type":"band","source":"file","slot":"Secondary",
+ "edge":"bottom","slice":[8,8,8,8],"thicknessPct":10,
+ "grow":{"fromPct":100,"toPct":50,"durationMs":3000}}
+ ]}
+ """);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(string.Join("; ", result.Errors), Does.Contain("growth runs outwards"));
+ }
+}
diff --git a/tests/ArgonSharedLogicTest/CosmeticSceneRefusalTests.cs b/tests/ArgonSharedLogicTest/CosmeticSceneRefusalTests.cs
new file mode 100644
index 00000000..53af29b1
--- /dev/null
+++ b/tests/ArgonSharedLogicTest/CosmeticSceneRefusalTests.cs
@@ -0,0 +1,1167 @@
+namespace ArgonSharedLogicTest;
+
+using Argon.Features.Cosmetics;
+
+///
+/// Every refusal a scene's payload can make, by the exact words it makes it in.
+///
+///
+/// The words are the console's: they are what an operator reads under the field they got wrong,
+/// so a refusal that names the wrong field, or two refusals for one mistake, is a defect even when
+/// the row is rightly kept out. Each case below starts from a scene that passes and changes one
+/// thing, and expects exactly the refusals that one thing earns.
+///
+/// Numbers are printed with the invariant culture here so that a decimal point reads the same
+/// on every machine.
+///
+[TestFixture]
+[SetCulture("")]
+public class CosmeticSceneRefusalTests
+{
+ private const string AnchorWords = "topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight";
+
+ private static readonly string[] Anchors =
+ ["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"];
+
+ private static readonly string[] Sorts = ["wash", "sprite", "band", "wrap", "emitter"];
+
+ /// A valid value for every field some sorts own and others refuse by name.
+ private static readonly Dictionary> Fill = new()
+ {
+ ["fit"] = actor => actor.Fit = "cover",
+ ["periodMs"] = actor => actor.PeriodMs = 1000,
+ ["sizePct"] = actor => actor.SizePct = 20d,
+ ["durationMs"] = actor => actor.DurationMs = 4000,
+ ["bobPct"] = actor => actor.BobPct = 2d,
+ ["from"] = actor => actor.From = new CosmeticScenePoint { Anchor = "top" },
+ ["to"] = actor => actor.To = new CosmeticScenePoint { Anchor = "bottom" },
+ ["repeat"] = actor => actor.Repeat = "loop",
+ ["ease"] = actor => actor.Ease = "linear",
+ ["turn"] = actor => actor.Turn = "none",
+ ["rotate"] = actor => actor.Rotate = Envelope(0d, 90d),
+ ["bowPct"] = actor => actor.BowPct = 10d,
+ ["bowAxis"] = actor => actor.BowAxis = "auto",
+ ["bankDeg"] = actor => actor.BankDeg = 10d,
+ ["scale"] = actor => actor.Scale = Envelope(1d, 2d),
+ ["spillPct"] = actor => actor.SpillPct = 0d,
+ ["edge"] = actor => actor.Edge = "top",
+ ["slice"] = actor => actor.Slice = [8, 8, 8, 8],
+ ["thicknessPct"] = actor => actor.ThicknessPct = 10d,
+ ["cornerPct"] = actor => actor.CornerPct = 10d,
+ ["widthPct"] = actor => actor.WidthPct = [6d, 6d, 6d, 6d],
+ ["outsetPct"] = actor => actor.OutsetPct = 2d,
+ ["tile"] = actor => actor.Tile = "round",
+ ["grow"] = actor => actor.Grow = Grow(),
+ ["retreat"] = actor => actor.Retreat = Retreat(),
+ ["count"] = actor => actor.Count = 4,
+ ["seed"] = actor => actor.Seed = 7,
+ ["sizeVarPct"] = actor => actor.SizeVarPct = 1d,
+ ["durationVarMs"] = actor => actor.DurationVarMs = 100,
+ ["driftPct"] = actor => actor.DriftPct = 10d,
+ ["swayPct"] = actor => actor.SwayPct = 10d,
+ ["swayPeriodMs"] = actor => actor.SwayPeriodMs = 1000,
+ ["swayVarMs"] = actor => actor.SwayVarMs = 100,
+ ["spinDeg"] = actor => actor.SpinDeg = 90d,
+ ["flutterDeg"] = actor => actor.FlutterDeg = 30d,
+ ["depthSpreadPct"] = actor => actor.DepthSpreadPct = 50d
+ };
+
+ /// Which sort owns which of those fields. Everything else is refused by name.
+ private static readonly Dictionary Owned = new()
+ {
+ ["wash"] = ["fit", "periodMs", "scale", "repeat", "spillPct", "grow", "retreat"],
+ ["sprite"] =
+ [
+ "from", "to", "ease", "turn", "rotate", "bowPct", "bowAxis", "bankDeg",
+ "sizePct", "durationMs", "bobPct", "scale", "repeat", "spillPct", "retreat"
+ ],
+ ["band"] = ["thicknessPct", "cornerPct", "edge", "slice", "outsetPct", "tile", "grow", "retreat"],
+ ["wrap"] = ["widthPct", "slice", "outsetPct", "tile", "grow", "retreat"],
+ ["emitter"] =
+ [
+ "count", "seed", "sizeVarPct", "durationVarMs", "driftPct", "swayPct", "swayPeriodMs", "swayVarMs",
+ "spinDeg", "flutterDeg", "depthSpreadPct", "sizePct", "durationMs", "bobPct", "edge", "repeat"
+ ]
+ };
+
+ private static CosmeticSceneAtlas Cell() => new() { X = 0, Y = 0, W = 16, H = 16 };
+
+ private static CosmeticSceneStop[] Envelope(double start, double end)
+ => [new CosmeticSceneStop { At = 0d, V = start }, new CosmeticSceneStop { At = 1d, V = end }];
+
+ private static CosmeticSceneGrow Grow() => new() { FromPct = 0d, ToPct = 100d, DurationMs = 2000 };
+
+ private static CosmeticSceneRetreat Retreat() => new() { AtMs = 3000, DurationMs = 800 };
+
+ private static CosmeticSceneActor Wash(Action? change = null)
+ => Changed(new CosmeticSceneActor { Type = "wash", Source = "atlas", Atlas = Cell() }, change);
+
+ private static CosmeticSceneActor Sprite(Action? change = null)
+ => Changed(new CosmeticSceneActor
+ {
+ Type = "sprite",
+ Source = "atlas",
+ Atlas = Cell(),
+ SizePct = 20d,
+ DurationMs = 4000,
+ From = new CosmeticScenePoint { Anchor = "topRight" },
+ To = new CosmeticScenePoint { Anchor = "bottomLeft" }
+ }, change);
+
+ private static CosmeticSceneActor Band(Action? change = null)
+ => Changed(new CosmeticSceneActor
+ {
+ Type = "band",
+ Source = "file",
+ Slot = "Secondary",
+ Edge = "bottom",
+ Slice = [8, 8, 8, 8],
+ ThicknessPct = 10d
+ }, change);
+
+ private static CosmeticSceneActor Wrap(Action? change = null)
+ => Changed(new CosmeticSceneActor
+ {
+ Type = "wrap",
+ Source = "file",
+ Slot = "Tertiary",
+ Slice = [8, 8, 8, 8],
+ WidthPct = [6d, 0d, 6d, 0d]
+ }, change);
+
+ private static CosmeticSceneActor Emitter(Action? change = null)
+ => Changed(new CosmeticSceneActor
+ {
+ Type = "emitter",
+ Source = "atlas",
+ Atlas = Cell(),
+ Edge = "top",
+ Count = 12,
+ SizePct = 4d,
+ DurationMs = 9000
+ }, change);
+
+ private static CosmeticSceneActor OfSort(string sort, Action? change = null) => sort switch
+ {
+ "wash" => Wash(change),
+ "sprite" => Sprite(change),
+ "band" => Band(change),
+ "wrap" => Wrap(change),
+ "emitter" => Emitter(change),
+ _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, null)
+ };
+
+ private static CosmeticSceneActor Changed(CosmeticSceneActor actor, Action? change)
+ {
+ change?.Invoke(actor);
+
+ return actor;
+ }
+
+ /// A scene of these actors, with a sheet exactly when one of them reads it.
+ private static ProfileScenePayload Scene(CosmeticSceneActor actor, Action? change = null)
+ {
+ var scene = new ProfileScenePayload
+ {
+ Reach = "card",
+ Sheet = actor.Source is null or "atlas" ? new CosmeticSceneSheet { W = 256, H = 256 } : null,
+ Actors = [actor]
+ };
+
+ change?.Invoke(scene);
+
+ return scene;
+ }
+
+ private static IReadOnlyList ErrorsOf(IValidatableCosmeticPayload payload)
+ {
+ var report = new CosmeticPayloadReport();
+
+ payload.Validate(report);
+
+ return report.Errors;
+ }
+
+ ///
+ /// Strings one step away from each word: one character changed, one missing, one too many.
+ ///
+ ///
+ /// The compiler matches a closed set of strings by length and then by character, so a word that is
+ /// almost right takes paths through that match a plainly wrong one never does.
+ ///
+ private static IEnumerable NearMisses(params string[] words)
+ {
+ var misses = new HashSet { "~" };
+
+ foreach (var word in words)
+ {
+ for (var index = 0; index < word.Length; index++)
+ {
+ misses.Add(string.Concat(word.AsSpan(0, index), "~", word.AsSpan(index + 1)));
+ }
+
+ misses.Add(word[..^1]);
+ misses.Add(word + "~");
+ }
+
+ misses.Remove(string.Empty);
+ misses.ExceptWith(words);
+
+ return misses;
+ }
+
+ private static TestCaseData Refused(string because, IValidatableCosmeticPayload payload, params string[] refusals)
+ => new TestCaseData(payload, refusals).SetArgDisplayNames(because);
+
+ private static TestCaseData Accepted(string because, IValidatableCosmeticPayload payload)
+ => new TestCaseData(payload).SetArgDisplayNames(because);
+
+ [TestCaseSource(nameof(SceneRefusals))]
+ [TestCaseSource(nameof(SortRefusals))]
+ [TestCaseSource(nameof(CommonRefusals))]
+ [TestCaseSource(nameof(PartRefusals))]
+ [TestCaseSource(nameof(AtlasRefusals))]
+ public void A_value_outside_its_rule_is_refused_in_so_many_words(IValidatableCosmeticPayload payload, string[] refusals)
+ => Assert.That(ErrorsOf(payload), Is.EqualTo(refusals));
+
+ [TestCaseSource(nameof(Acceptances))]
+ public void A_value_inside_its_rule_is_accepted(IValidatableCosmeticPayload payload)
+ => Assert.That(ErrorsOf(payload), Is.Empty);
+
+ /// The one rule that is a table rather than a check: a field belongs to the sorts that draw with it.
+ [TestCaseSource(nameof(ForeignFields))]
+ public void A_field_another_sort_owns_is_refused_by_its_name(string sort, string field)
+ {
+ var actor = OfSort(sort, Fill[field]);
+
+ Assert.That(ErrorsOf(Scene(actor)), Is.EqualTo(new[] { $"actors[0] is a {sort} and has no '{field}'" }));
+ }
+
+ [TestCaseSource(nameof(OwnFields))]
+ public void A_field_a_sort_owns_is_its_own(string sort, string field)
+ => Assert.That(ErrorsOf(Scene(OfSort(sort, Fill[field]))), Is.Empty);
+
+ /// A word one character off is as wrong as any other word, and refused the same way.
+ [TestCaseSource(nameof(ClosedWords))]
+ public void A_near_miss_of_a_closed_word_is_refused(string field, string[] words, Func build, string refusal)
+ {
+ Assert.Multiple(() =>
+ {
+ foreach (var word in words)
+ {
+ Assert.That(ErrorsOf(build(word)), Is.Empty, $"{field} '{word}'");
+ }
+
+ foreach (var miss in NearMisses(words))
+ {
+ Assert.That(ErrorsOf(build(miss)), Is.EqualTo(new[] { refusal.Replace("{0}", miss) }), $"{field} '{miss}'");
+ }
+ });
+ }
+
+ private static IEnumerable ForeignFields()
+ {
+ foreach (var sort in Sorts)
+ {
+ foreach (var field in Fill.Keys.Except(Owned[sort]))
+ {
+ yield return new TestCaseData(sort, field);
+ }
+ }
+ }
+
+ private static IEnumerable OwnFields()
+ {
+ foreach (var sort in Sorts)
+ {
+ foreach (var field in Owned[sort])
+ {
+ yield return new TestCaseData(sort, field);
+ }
+ }
+ }
+
+ private static IEnumerable ClosedWords()
+ {
+ TestCaseData Words(string field, string[] words, Func build, string refusal)
+ => new TestCaseData(field, words, build, refusal).SetArgDisplayNames(field);
+
+ yield return Words("reach", ["content", "card", "choice"],
+ word => Scene(Wash(), scene =>
+ {
+ scene.Reach = word;
+ scene.DefaultReach = word is "choice" ? "card" : null;
+ }),
+ "reach is 'content', 'card' or 'choice'");
+
+ yield return Words("defaultReach", ["content", "card"],
+ word => Scene(Wash(), scene =>
+ {
+ scene.Reach = "choice";
+ scene.DefaultReach = word;
+ }),
+ "defaultReach is 'content' or 'card' when reach is 'choice'");
+
+ yield return Words("tuning reach", ["content", "card"],
+ word => new ProfileSceneTuning { Reach = word },
+ "reach is either 'content' or 'card'");
+
+ yield return Words("type", Sorts,
+ word => Scene(Sorts.Contains(word) ? OfSort(word) : Wash(actor => actor.Type = word)),
+ "actors[0].type is 'wash', 'sprite', 'band', 'wrap' or 'emitter'");
+
+ yield return Words("source", ["atlas", "file"],
+ word => Scene(Wash(actor =>
+ {
+ actor.Source = word;
+ actor.Atlas = word is "atlas" ? Cell() : null;
+ actor.Slot = word is "file" ? "Secondary" : null;
+ }), scene => scene.Sheet = word is "atlas" ? new CosmeticSceneSheet { W = 256, H = 256 } : null),
+ "actors[0].source is 'atlas' or 'file'");
+
+ yield return Words("fit", ["cover", "contain", "stretch"],
+ word => Scene(Wash(actor => actor.Fit = word)),
+ "actors[0].fit is 'cover', 'contain' or 'stretch'");
+
+ yield return Words("repeat", ["loop", "once", "pingPong"],
+ word => Scene(Wash(actor => actor.Repeat = word)),
+ "actors[0].repeat is 'loop', 'once' or 'pingPong'");
+
+ yield return Words("depth", ["deep", "over", "front"],
+ word => Scene(Wash(actor => actor.Depth = word)),
+ "actors[0].depth is 'deep', 'over' or 'front'");
+
+ yield return Words("occlude", ["avatar"],
+ word => Scene(Wash(actor => actor.Occlude = [word])),
+ "actors[0].occlude names '{0}', and the only part of a card that hides anything is 'avatar'");
+
+ yield return Words("ease", ["linear", "in", "out", "inOut"],
+ word => Scene(Sprite(actor => actor.Ease = word)),
+ "actors[0].ease is 'linear', 'in', 'out' or 'inOut'");
+
+ yield return Words("turn", ["none", "mirror"],
+ word => Scene(Sprite(actor => actor.Turn = word)),
+ "actors[0].turn is 'none' or 'mirror'");
+
+ yield return Words("bowAxis", ["auto", "x", "y"],
+ word => Scene(Sprite(actor => actor.BowAxis = word)),
+ "actors[0].bowAxis is 'auto', 'x' or 'y'");
+
+ yield return Words("anchor", Anchors,
+ word => Scene(Sprite(actor => actor.From = new CosmeticScenePoint { Anchor = word })),
+ $"actors[0].from.anchor is one of {AnchorWords}");
+
+ yield return Words("band edge", ["top", "right", "bottom", "left"],
+ word => Scene(Band(actor => actor.Edge = word)),
+ "actors[0].edge is 'top', 'right', 'bottom' or 'left'");
+
+ yield return Words("emitter edge", ["top", "right", "bottom", "left"],
+ word => Scene(Emitter(actor => actor.Edge = word)),
+ "actors[0].edge is 'top', 'right', 'bottom' or 'left'");
+
+ yield return Words("band tile", ["stretch", "repeat", "round", "space"],
+ word => Scene(Band(actor => actor.Tile = word)),
+ "actors[0].tile is 'stretch', 'repeat', 'round' or 'space'");
+
+ yield return Words("wrap tile", ["stretch", "repeat", "round", "space"],
+ word => Scene(Wrap(actor => actor.Tile = word)),
+ "actors[0].tile is 'stretch', 'repeat', 'round' or 'space'");
+
+ yield return Words("grow repeat", ["loop", "once", "pingPong"],
+ word => Scene(Band(actor => actor.Grow = Changed(Grow(), grow => grow.Repeat = word))),
+ "actors[0].grow.repeat is 'loop', 'once' or 'pingPong'");
+
+ yield return Words("grow from", ["edge", .. Anchors],
+ word => Scene(Band(actor => actor.Grow = Changed(Grow(), grow => grow.From = word))),
+ $"actors[0].grow.from is 'edge' or one of {AnchorWords}");
+
+ yield return Words("grow leadAxis", ["y", "x", "both"],
+ word => Scene(Band(actor => actor.Grow = Changed(Grow(), grow => grow.LeadAxis = word))),
+ "actors[0].grow.leadAxis is 'y', 'x' or 'both'");
+ }
+
+ private static CosmeticSceneGrow Changed(CosmeticSceneGrow grow, Action change)
+ {
+ change(grow);
+
+ return grow;
+ }
+
+ private static IEnumerable SceneRefusals()
+ {
+ yield return Refused("no reach", Scene(Wash(), scene => scene.Reach = null), "reach is 'content', 'card' or 'choice'");
+
+ yield return Refused("a choice with no default", Scene(Wash(), scene => scene.Reach = "choice"),
+ "defaultReach is 'content' or 'card' when reach is 'choice'");
+
+ yield return Refused("a default for a reach that is not a choice", Scene(Wash(), scene => scene.DefaultReach = "content"),
+ "defaultReach is only read when reach is 'choice'");
+
+ yield return Refused("a negative minimum width", Scene(Wash(), scene => scene.MinWidthPx = -1),
+ "minWidthPx is -1, and the range is 0 to 512");
+
+ yield return Refused("a minimum width past a picker tile", Scene(Wash(), scene => scene.MinWidthPx = 513),
+ "minWidthPx is 513, and the range is 0 to 512");
+
+ yield return Refused("no actors", new ProfileScenePayload { Reach = "card" },
+ "actors needs at least one actor for there to be a scene");
+
+ yield return Refused("an empty list of actors", new ProfileScenePayload { Reach = "card", Actors = [] },
+ "actors needs at least one actor for there to be a scene");
+
+ yield return Refused("nine actors", Scene(Wash(), scene => scene.Actors = Enumerable.Range(0, 9).Select(_ => Wash()).ToArray()),
+ "actors is capped at 8");
+
+ // The sheet is still asked for by the actor after it: a null one reads nothing, and is not walked.
+ yield return Refused("an actor that is not there", Scene(Wash(), scene => scene.Actors = [null!, Wash()]),
+ "actors[0] is null");
+
+ yield return Refused("only actors that are not there", Scene(Band(), scene => scene.Actors = [null!, null!]),
+ "actors[0] is null",
+ "actors[1] is null");
+
+ yield return Refused("a sheet nothing reads", Scene(Band(), scene => scene.Sheet = new CosmeticSceneSheet { W = 256, H = 256 }),
+ "sheet is only read when an actor reads a rectangle out of it");
+
+ yield return Refused("no sheet for an actor that reads one", Scene(Wash(), scene => scene.Sheet = null),
+ "sheet is missing, and an actor reads a rectangle out of it");
+
+ yield return Refused("no sheet for an actor that reads one by default", Scene(Wash(actor => actor.Source = null), scene => scene.Sheet = null),
+ "sheet is missing, and an actor reads a rectangle out of it");
+
+ yield return Refused("no sheet for the second actor", Scene(Band(), scene => scene.Actors = [Band(), Wash()]),
+ "sheet is missing, and an actor reads a rectangle out of it");
+
+ // A sheet that is itself wrong measures nothing, so nothing is measured against it.
+ yield return Refused("a sheet with no width", Scene(Wash(actor => actor.Atlas!.W = 512), scene => scene.Sheet!.W = null),
+ "sheet.w is missing");
+
+ yield return Refused("a sheet of no width", Scene(Wash(), scene => scene.Sheet!.W = 0),
+ "sheet.w is 0, and the range is 1 to 4096");
+
+ yield return Refused("a sheet with no height", Scene(Wash(actor => actor.Atlas!.H = 512), scene => scene.Sheet!.H = null),
+ "sheet.h is missing");
+
+ yield return Refused("a sheet taller than a texture", Scene(Wash(), scene => scene.Sheet!.H = 4097),
+ "sheet.h is 4097, and the range is 1 to 4096");
+
+ yield return Refused("an actor of no known sort, which is checked no further",
+ Scene(Wash(actor =>
+ {
+ actor.Type = "cloud";
+ actor.Depth = "sideways";
+ })),
+ "actors[0].type is 'wash', 'sprite', 'band', 'wrap' or 'emitter'");
+
+ yield return Refused("an actor with no sort", Scene(Wash(actor => actor.Type = null)),
+ "actors[0].type is 'wash', 'sprite', 'band', 'wrap' or 'emitter'");
+
+ yield return Refused("a tuning reach of neither word", new ProfileSceneTuning { Reach = "far" }, "reach is either 'content' or 'card'");
+ }
+
+ private static IEnumerable SortRefusals()
+ {
+ // wash
+ yield return Refused("a wash period too quick", Scene(Wash(actor => actor.PeriodMs = 239)),
+ "actors[0].periodMs is 239, and the range is 240 to 600000");
+
+ yield return Refused("a wash period past ten minutes", Scene(Wash(actor => actor.PeriodMs = 600_001)),
+ "actors[0].periodMs is 600001, and the range is 240 to 600000");
+
+ yield return Refused("a wash spill past a quarter", Scene(Wash(actor => actor.SpillPct = 26d)),
+ "actors[0].spillPct is 26, and the range is 0 to 25");
+
+ yield return Refused("a negative wash spill", Scene(Wash(actor =>
+ {
+ actor.SpillPct = -1d;
+ actor.Fit = "contain";
+ })),
+ "actors[0].spillPct is -1, and the range is 0 to 25");
+
+ yield return Refused("a contained wash that spills", Scene(Wash(actor =>
+ {
+ actor.SpillPct = 4d;
+ actor.Fit = "contain";
+ })),
+ "actors[0].spillPct needs fit 'cover': a picture that hangs past the card has to cover it first");
+
+ yield return Refused("a stretched wash that spills", Scene(Wash(actor =>
+ {
+ actor.SpillPct = 4d;
+ actor.Fit = "stretch";
+ })),
+ "actors[0].spillPct needs fit 'cover': a picture that hangs past the card has to cover it first");
+
+ yield return Refused("a wash scale past eight", Scene(Wash(actor => actor.Scale = Envelope(1d, 9d))),
+ "actors[0].scale[1].v is 9, and the range is 0.1 to 8");
+
+ yield return Refused("a wash that grows with no duration", Scene(Wash(actor => actor.Grow = Changed(Grow(), grow => grow.DurationMs = null))),
+ "actors[0].grow.durationMs is missing");
+
+ // sprite
+ yield return Refused("a sprite with no size", Scene(Sprite(actor => actor.SizePct = null)), "actors[0].sizePct is missing");
+
+ yield return Refused("a sprite of no size", Scene(Sprite(actor => actor.SizePct = 0d)),
+ "actors[0].sizePct is 0, and the range is 1 to 400");
+
+ yield return Refused("a sprite four cards wide", Scene(Sprite(actor => actor.SizePct = 401d)),
+ "actors[0].sizePct is 401, and the range is 1 to 400");
+
+ yield return Refused("a sprite with no duration", Scene(Sprite(actor => actor.DurationMs = null)), "actors[0].durationMs is missing");
+
+ yield return Refused("a sprite too quick", Scene(Sprite(actor => actor.DurationMs = 239)),
+ "actors[0].durationMs is 239, and the range is 240 to 600000");
+
+ yield return Refused("a sprite past ten minutes", Scene(Sprite(actor => actor.DurationMs = 600_001)),
+ "actors[0].durationMs is 600001, and the range is 240 to 600000");
+
+ yield return Refused("a sprite from nowhere", Scene(Sprite(actor => actor.From = null)), "actors[0].from is missing");
+
+ yield return Refused("a sprite to nowhere", Scene(Sprite(actor => actor.To = null)), "actors[0].to is missing");
+
+ yield return Refused("a point with no anchor, which is checked no further",
+ Scene(Sprite(actor => actor.To = new CosmeticScenePoint { DxPct = 999d })),
+ $"actors[0].to.anchor is one of {AnchorWords}");
+
+ yield return Refused("a point nudged right past four cards",
+ Scene(Sprite(actor => actor.From = new CosmeticScenePoint { Anchor = "top", DxPct = 401d })),
+ "actors[0].from.dxPct is 401, and the range is -400 to 400");
+
+ yield return Refused("a point nudged up past four cards",
+ Scene(Sprite(actor => actor.From = new CosmeticScenePoint { Anchor = "top", DyPct = -401d })),
+ "actors[0].from.dyPct is -401, and the range is -400 to 400");
+
+ yield return Refused("a bow past twice the chord", Scene(Sprite(actor => actor.BowPct = 201d)),
+ "actors[0].bowPct is 201, and the range is -200 to 200");
+
+ yield return Refused("a bow past twice the chord the other way", Scene(Sprite(actor => actor.BowPct = -201d)),
+ "actors[0].bowPct is -201, and the range is -200 to 200");
+
+ yield return Refused("a bank past half a turn", Scene(Sprite(actor => actor.BankDeg = 181d)),
+ "actors[0].bankDeg is 181, and the range is -180 to 180");
+
+ yield return Refused("a sprite bob past a quarter", Scene(Sprite(actor => actor.BobPct = 26d)),
+ "actors[0].bobPct is 26, and the range is 0 to 25");
+
+ yield return Refused("a sprite spill past a quarter", Scene(Sprite(actor => actor.SpillPct = 26d)),
+ "actors[0].spillPct is 26, and the range is 0 to 25");
+
+ yield return Refused("a sprite scaled to nothing", Scene(Sprite(actor => actor.Scale = Envelope(0d, 1d))),
+ "actors[0].scale[0].v is 0, and the range is 0.1 to 8");
+
+ yield return Refused("a sprite spun past ten turns", Scene(Sprite(actor => actor.Rotate = Envelope(0d, 3601d))),
+ "actors[0].rotate[1].v is 3601, and the range is -3600 to 3600");
+
+ // band
+ yield return Refused("a band on no edge", Scene(Band(actor => actor.Edge = null)),
+ "actors[0].edge is 'top', 'right', 'bottom' or 'left'");
+
+ yield return Refused("a band cut from the sheet", Scene(Band(actor =>
+ {
+ actor.Source = "atlas";
+ actor.Atlas = Cell();
+ })),
+ "actors[0] is a nine-slice band, so it needs a file of its own rather than a rectangle of the sheet");
+
+ yield return Refused("a band with no slice", Scene(Band(actor => actor.Slice = null)),
+ "actors[0].slice is missing; it is four numbers — top, right, bottom, left");
+
+ yield return Refused("a band sliced three ways", Scene(Band(actor => actor.Slice = [8, 8, 8])),
+ "actors[0].slice is four numbers — top, right, bottom, left — and has 3");
+
+ yield return Refused("a band sliced outside its picture", Scene(Band(actor => actor.Slice = [8, -1, 8, 513])),
+ "actors[0].slice[1] is -1, and the range is 0 to 512",
+ "actors[0].slice[3] is 513, and the range is 0 to 512");
+
+ yield return Refused("a band with no thickness", Scene(Band(actor => actor.ThicknessPct = null)),
+ "actors[0].thicknessPct is missing");
+
+ yield return Refused("a band of no thickness", Scene(Band(actor => actor.ThicknessPct = 0d)),
+ "actors[0].thicknessPct is 0, and the range is 1 to 100");
+
+ yield return Refused("a band thicker than the card", Scene(Band(actor => actor.ThicknessPct = 101d)),
+ "actors[0].thicknessPct is 101, and the range is 1 to 100");
+
+ yield return Refused("a band corner wider than the card", Scene(Band(actor => actor.CornerPct = 101d)),
+ "actors[0].cornerPct is 101, and the range is 0 to 100");
+
+ yield return Refused("a negative band corner", Scene(Band(actor => actor.CornerPct = -1d)),
+ "actors[0].cornerPct is -1, and the range is 0 to 100");
+
+ yield return Refused("a band outset past a quarter", Scene(Band(actor => actor.OutsetPct = 26d)),
+ "actors[0].outsetPct is 26, and the range is 0 to 25");
+
+ // wrap
+ yield return Refused("a wrap cut from the sheet", Scene(Wrap(actor =>
+ {
+ actor.Source = null;
+ actor.Atlas = Cell();
+ })),
+ "actors[0] is a nine-slice wrap, so it needs a file of its own rather than a rectangle of the sheet");
+
+ yield return Refused("a wrap with no slice", Scene(Wrap(actor => actor.Slice = null)),
+ "actors[0].slice is missing; it is four numbers — top, right, bottom, left");
+
+ yield return Refused("a wrap with no widths", Scene(Wrap(actor => actor.WidthPct = null)),
+ "actors[0].widthPct is missing; it is four numbers — top, right, bottom, left");
+
+ yield return Refused("a wrap of two widths", Scene(Wrap(actor => actor.WidthPct = [6d, 6d])),
+ "actors[0].widthPct is four numbers — top, right, bottom, left — and has 2");
+
+ yield return Refused("a wrap of widths outside the card", Scene(Wrap(actor => actor.WidthPct = [6d, 101d, -1d, 0d])),
+ "actors[0].widthPct[1] is 101, and the range is 0 to 100",
+ "actors[0].widthPct[2] is -1, and the range is 0 to 100");
+
+ yield return Refused("a wrap drawn nowhere", Scene(Wrap(actor => actor.WidthPct = [0d, 0d, 0d, 0d])),
+ "actors[0].widthPct is zero on every side, so the wrap is drawn nowhere");
+
+ yield return Refused("a negative wrap outset", Scene(Wrap(actor => actor.OutsetPct = -1d)),
+ "actors[0].outsetPct is -1, and the range is 0 to 25");
+
+ // emitter
+ yield return Refused("a scatter on no edge", Scene(Emitter(actor => actor.Edge = null)),
+ "actors[0].edge is 'top', 'right', 'bottom' or 'left'");
+
+ yield return Refused("a scatter of a file", Scene(Emitter(actor =>
+ {
+ actor.Source = "file";
+ actor.Atlas = null;
+ })),
+ "actors[0] is an emitter and reads the atlas; a self-animating file cannot be spread out");
+
+ yield return Refused("a scatter of nothing counted", Scene(Emitter(actor => actor.Count = null)), "actors[0].count is missing");
+
+ yield return Refused("a scatter of none", Scene(Emitter(actor => actor.Count = 0)), "actors[0].count is 0, and the range is 1 to 48");
+
+ yield return Refused("a scatter of forty-nine", Scene(Emitter(actor => actor.Count = 49)), "actors[0].count is 49, and the range is 1 to 48");
+
+ yield return Refused("a negative seed", Scene(Emitter(actor => actor.Seed = -1)), "actors[0].seed is -1, and the range is 0 to 65535");
+
+ yield return Refused("a seed past two bytes", Scene(Emitter(actor => actor.Seed = 65_536)),
+ "actors[0].seed is 65536, and the range is 0 to 65535");
+
+ yield return Refused("a scatter with no size, which has no spread to compare", Scene(Emitter(actor =>
+ {
+ actor.SizePct = null;
+ actor.SizeVarPct = 5d;
+ })),
+ "actors[0].sizePct is missing");
+
+ yield return Refused("a scatter four cards wide", Scene(Emitter(actor => actor.SizePct = 401d)),
+ "actors[0].sizePct is 401, and the range is 1 to 400");
+
+ yield return Refused("a scatter with no duration, which has no spread to compare", Scene(Emitter(actor =>
+ {
+ actor.DurationMs = null;
+ actor.DurationVarMs = 100;
+ })),
+ "actors[0].durationMs is missing");
+
+ yield return Refused("a scatter too quick", Scene(Emitter(actor => actor.DurationMs = 239)),
+ "actors[0].durationMs is 239, and the range is 240 to 600000");
+
+ yield return Refused("a negative size spread", Scene(Emitter(actor => actor.SizeVarPct = -1d)),
+ "actors[0].sizeVarPct is -1, and the range is 0 to 400");
+
+ yield return Refused("a negative duration spread", Scene(Emitter(actor => actor.DurationVarMs = -1)),
+ "actors[0].durationVarMs is -1, and the range is 0 to 600000");
+
+ yield return Refused("a drift past two cards", Scene(Emitter(actor => actor.DriftPct = 201d)),
+ "actors[0].driftPct is 201, and the range is 0 to 200");
+
+ yield return Refused("a sway past the card", Scene(Emitter(actor => actor.SwayPct = 101d)),
+ "actors[0].swayPct is 101, and the range is 0 to 100");
+
+ yield return Refused("a sway too quick", Scene(Emitter(actor => actor.SwayPeriodMs = 239)),
+ "actors[0].swayPeriodMs is 239, and the range is 240 to 120000");
+
+ yield return Refused("a sway spread past two minutes", Scene(Emitter(actor => actor.SwayVarMs = 120_001)),
+ "actors[0].swayVarMs is 120001, and the range is 0 to 120000");
+
+ yield return Refused("a spin past ten turns", Scene(Emitter(actor => actor.SpinDeg = 3601d)),
+ "actors[0].spinDeg is 3601, and the range is 0 to 3600");
+
+ yield return Refused("a flutter past edge-on", Scene(Emitter(actor => actor.FlutterDeg = 91d)),
+ "actors[0].flutterDeg is 91, and the range is 0 to 90");
+
+ yield return Refused("a scatter bob past a quarter", Scene(Emitter(actor => actor.BobPct = 26d)),
+ "actors[0].bobPct is 26, and the range is 0 to 25");
+
+ yield return Refused("a depth spread past whole", Scene(Emitter(actor => actor.DepthSpreadPct = 101d)),
+ "actors[0].depthSpreadPct is 101, and the range is 0 to 100");
+
+ yield return Refused("a size spread wider than the size", Scene(Emitter(actor => actor.SizeVarPct = 5d)),
+ "actors[0].sizeVarPct is wider than sizePct, so some copies come out at no size at all");
+
+ yield return Refused("a duration spread wider than the duration", Scene(Emitter(actor => actor.DurationVarMs = 9001)),
+ "actors[0].durationVarMs is wider than durationMs, so some copies never finish");
+
+ yield return Refused("a sway spread wider than the sway", Scene(Emitter(actor =>
+ {
+ actor.SwayPeriodMs = 1000;
+ actor.SwayVarMs = 1001;
+ })),
+ "actors[0].swayVarMs is wider than swayPeriodMs, so some copies come out with no sway at all");
+ }
+
+ private static IEnumerable CommonRefusals()
+ {
+ yield return Refused("a source of neither kind, which reads no sheet", Scene(Wash(actor => actor.Source = "stream"), scene => scene.Sheet = null),
+ "actors[0].source is 'atlas' or 'file'");
+
+ yield return Refused("a sheet reader with no rectangle", Scene(Wash(actor => actor.Atlas = null)),
+ "actors[0].atlas is missing, and this actor reads the sheet");
+
+ yield return Refused("a file with a rectangle", Scene(Wash(actor =>
+ {
+ actor.Source = "file";
+ actor.Slot = "Secondary";
+ })),
+ "actors[0] draws a whole file and has no atlas rectangle");
+
+ yield return Refused("a file that names no slot", Scene(Wash(actor =>
+ {
+ actor.Source = "file";
+ actor.Atlas = null;
+ })),
+ "actors[0].slot names no file, and this actor draws a whole file");
+
+ yield return Refused("a file that names an empty slot", Scene(Wash(actor =>
+ {
+ actor.Source = "file";
+ actor.Atlas = null;
+ actor.Slot = string.Empty;
+ })),
+ "actors[0].slot names no file, and this actor draws a whole file");
+
+ yield return Refused("a band that names no slot", Scene(Band(actor => actor.Slot = null)),
+ "actors[0].slot names no file, and this actor draws a whole file");
+
+ yield return Refused("a wrap that names no slot", Scene(Wrap(actor => actor.Slot = null)),
+ "actors[0].slot names no file, and this actor draws a whole file");
+
+ yield return Refused("a rectangle told to replay", Scene(Wash(actor => actor.Replay = true)),
+ "actors[0].replay is only read for a file; a rectangle of the sheet has no clock of its own");
+
+ yield return Refused("a slot that does not exist", Scene(Wash(actor => actor.Slot = "Nowhere")),
+ "actors[0].slot 'Nowhere' is not an asset slot");
+
+ yield return Refused("a slot by a number no slot has", Scene(Wash(actor => actor.Slot = "9")),
+ "actors[0].slot '9' is not an asset slot");
+
+ // Enum.TryParse reads all of these as a member, and none of them is a name anybody wrote.
+ foreach (var slot in new[] { "0", "4", "7", "Primary, Secondary", " Primary", "Primary ", "primary", "PRIMARY" })
+ {
+ yield return Refused($"a slot spelled '{slot}'", Scene(Wash(actor => actor.Slot = slot)),
+ $"actors[0].slot '{slot}' is not an asset slot");
+ }
+
+ yield return Refused("a part of the card that is not there", Scene(Wash(actor => actor.Occlude = ["avatar", null!])),
+ "actors[0].occlude[1] is null");
+
+ yield return Refused("two parts of a card that hide nothing", Scene(Wash(actor => actor.Occlude = ["avatar", "glass", "name"])),
+ "actors[0].occlude names 'glass', and the only part of a card that hides anything is 'avatar'",
+ "actors[0].occlude names 'name', and the only part of a card that hides anything is 'avatar'");
+
+ yield return Refused("a delay past two minutes", Scene(Wash(actor => actor.DelayMs = 120_001)),
+ "actors[0].delayMs is 120001, and the range is -120000 to 120000");
+
+ yield return Refused("a delay past two minutes ago", Scene(Wash(actor => actor.DelayMs = -120_001)),
+ "actors[0].delayMs is -120001, and the range is -120000 to 120000");
+
+ yield return Refused("an opacity past opaque", Scene(Wash(actor => actor.Opacity = Envelope(2d, 1d))),
+ "actors[0].opacity[0].v is 2, and the range is 0 to 1");
+ }
+
+ private static IEnumerable PartRefusals()
+ {
+ CosmeticSceneStop Stop(double? at, double? value) => new() { At = at, V = value };
+
+ ProfileScenePayload Opacity(params CosmeticSceneStop[] stops) => Scene(Wash(actor => actor.Opacity = stops));
+
+ // envelope
+ yield return Refused("an envelope of no stops", Opacity(),
+ "actors[0].opacity needs at least two stops; one stop is a constant, and a constant is the field's own value");
+
+ yield return Refused("an envelope of one stop", Opacity(Stop(0d, 1d)),
+ "actors[0].opacity needs at least two stops; one stop is a constant, and a constant is the field's own value");
+
+ yield return Refused("an envelope of nine stops",
+ Opacity(Enumerable.Range(0, 9).Select(index => Stop(index / 8d, 1d)).ToArray()),
+ "actors[0].opacity is capped at 8 stops");
+
+ yield return Refused("a stop with no place, which ends the reading", Opacity(Stop(0d, 1d), Stop(null, 9d), Stop(1d, 1d)),
+ "actors[0].opacity[1].at is missing");
+
+ yield return Refused("an opacity stop that is not there, which ends the reading", Opacity(Stop(0d, 1d), null!, Stop(9d, 1d)),
+ "actors[0].opacity[1] is null");
+
+ yield return Refused("a scale stop that is not there", Scene(Wash(actor => actor.Scale = [null!, Stop(1d, 1d)])),
+ "actors[0].scale[0] is null");
+
+ yield return Refused("a rotation stop that is not there", Scene(Sprite(actor => actor.Rotate = [Stop(0d, 0d), null!])),
+ "actors[0].rotate[1] is null");
+
+ yield return Refused("a stop before the cycle", Opacity(Stop(-0.5d, 1d), Stop(1d, 1d)),
+ "actors[0].opacity[0].at is -0.5, and the range is 0 to 1");
+
+ yield return Refused("a stop after the cycle", Opacity(Stop(0d, 1d), Stop(1.5d, 1d)),
+ "actors[0].opacity[1].at is 1.5, and the range is 0 to 1");
+
+ yield return Refused("a stop that does not move on", Opacity(Stop(0d, 1d), Stop(0d, 1d), Stop(1d, 1d)),
+ "actors[0].opacity[1].at is 0, which does not come after 0");
+
+ yield return Refused("a stop with no value", Opacity(Stop(0d, null), Stop(1d, 1d)),
+ "actors[0].opacity[0].v is missing");
+
+ yield return Refused("two values out of range, both named", Opacity(Stop(0d, 2d), Stop(1d, -1d)),
+ "actors[0].opacity[0].v is 2, and the range is 0 to 1",
+ "actors[0].opacity[1].v is -1, and the range is 0 to 1");
+
+ yield return Refused("an envelope that starts late", Opacity(Stop(0.2d, 1d), Stop(1d, 1d)),
+ "actors[0].opacity starts at 0.2, and an envelope covers the whole cycle from 0");
+
+ yield return Refused("an envelope that ends early", Opacity(Stop(0d, 1d), Stop(0.8d, 1d)),
+ "actors[0].opacity ends at 0.8, and an envelope covers the whole cycle to 1");
+
+ // grow
+ ProfileScenePayload Growing(Action change) => Scene(Band(actor => actor.Grow = Changed(Grow(), change)));
+
+ yield return Refused("a grow from nowhere", Growing(grow => grow.FromPct = null), "actors[0].grow.fromPct is missing");
+
+ yield return Refused("a grow from below nothing", Growing(grow => grow.FromPct = -1d),
+ "actors[0].grow.fromPct is -1, and the range is 0 to 100");
+
+ yield return Refused("a grow to nowhere", Growing(grow => grow.ToPct = null), "actors[0].grow.toPct is missing");
+
+ yield return Refused("a grow past whole", Growing(grow => grow.ToPct = 101d),
+ "actors[0].grow.toPct is 101, and the range is 0 to 100");
+
+ yield return Refused("a grow too quick", Growing(grow => grow.DurationMs = 239),
+ "actors[0].grow.durationMs is 239, and the range is 240 to 600000");
+
+ yield return Refused("a negative hold", Growing(grow => grow.HoldMs = -1),
+ "actors[0].grow.holdMs is -1, and the range is 0 to 600000");
+
+ yield return Refused("a grow from an empty word", Growing(grow => grow.From = string.Empty),
+ $"actors[0].grow.from is 'edge' or one of {AnchorWords}");
+
+ yield return Refused("a grow from nothing at all", Growing(grow => grow.FromScale = 0d),
+ "actors[0].grow.fromScale is 0, and the range is 0.01 to 1");
+
+ yield return Refused("a grow that shrinks", Growing(grow => grow.FromScale = 1.5d),
+ "actors[0].grow.fromScale is 1.5, and the range is 0.01 to 1");
+
+ yield return Refused("a wobble past an eighth of a turn", Growing(grow => grow.WobbleDeg = 46d),
+ "actors[0].grow.wobbleDeg is 46, and the range is 0 to 45");
+
+ yield return Refused("an edge softer than soft", Growing(grow => grow.Softness = 1.5d),
+ "actors[0].grow.softness is 1.5, and the range is 0 to 1");
+
+ yield return Refused("a sway with no lean", Growing(grow => grow.Sway = new CosmeticSceneSway { Ms = 5000 }),
+ "actors[0].grow.sway.deg is missing");
+
+ yield return Refused("a sway past an eighth of a turn", Growing(grow => grow.Sway = new CosmeticSceneSway { Deg = 46d, Ms = 5000 }),
+ "actors[0].grow.sway.deg is 46, and the range is 0 to 45");
+
+ yield return Refused("a sway with no period", Growing(grow => grow.Sway = new CosmeticSceneSway { Deg = 3d }),
+ "actors[0].grow.sway.ms is missing");
+
+ yield return Refused("a sway too quick", Growing(grow => grow.Sway = new CosmeticSceneSway { Deg = 3d, Ms = 239 }),
+ "actors[0].grow.sway.ms is 239, and the range is 240 to 600000");
+
+ yield return Refused("a grow that runs inwards", Growing(grow =>
+ {
+ grow.FromPct = 60d;
+ grow.ToPct = 40d;
+ }),
+ "actors[0].grow.fromPct is 60 and toPct is 40; growth runs outwards");
+
+ yield return Refused("a grow whose start is wrong is not also called backwards", Growing(grow =>
+ {
+ grow.FromPct = -1d;
+ grow.ToPct = 40d;
+ }),
+ "actors[0].grow.fromPct is -1, and the range is 0 to 100");
+
+ yield return Refused("a grow whose end is wrong is not also called backwards", Growing(grow =>
+ {
+ grow.FromPct = 60d;
+ grow.ToPct = null;
+ }),
+ "actors[0].grow.toPct is missing");
+
+ // retreat
+ ProfileScenePayload Retreating(Action change)
+ => Scene(Wash(actor =>
+ {
+ actor.Retreat = Retreat();
+ change(actor.Retreat);
+ }));
+
+ yield return Refused("a retreat that never starts", Retreating(retreat => retreat.AtMs = null), "actors[0].retreat.atMs is missing");
+
+ yield return Refused("a retreat before the card opens", Retreating(retreat => retreat.AtMs = -1),
+ "actors[0].retreat.atMs is -1, and the range is 0 to 600000");
+
+ yield return Refused("a retreat that takes no time", Retreating(retreat => retreat.DurationMs = null),
+ "actors[0].retreat.durationMs is missing");
+
+ yield return Refused("a retreat too quick", Retreating(retreat => retreat.DurationMs = 239),
+ "actors[0].retreat.durationMs is 239, and the range is 240 to 600000");
+
+ yield return Refused("an edge strip past half", Retreating(retreat => retreat.EdgePct = 51d),
+ "actors[0].retreat.edgePct is 51, and the range is 0 to 50");
+
+ yield return Refused("a foot strip past half", Retreating(retreat => retreat.FootPct = 51d),
+ "actors[0].retreat.footPct is 51, and the range is 0 to 50");
+
+ yield return Refused("a soft edge past a quarter", Retreating(retreat => retreat.SoftPct = 26d),
+ "actors[0].retreat.softPct is 26, and the range is 0 to 25");
+
+ yield return Refused("a retreat that leaves everything", Retreating(retreat => retreat.Remain = 1d),
+ "actors[0].retreat.remain is 1; a retreat that leaves everything behind is not a retreat");
+
+ yield return Refused("a retreat that leaves less than nothing", Retreating(retreat => retreat.Remain = -0.5d),
+ "actors[0].retreat.remain is -0.5, and the range is 0 to 1");
+
+ // shadow
+ ProfileScenePayload Shadowed(Action change)
+ => Scene(Wash(actor =>
+ {
+ actor.Shadow = new CosmeticSceneShadow { DxPct = 0.5d, DyPct = 1d, BlurPct = 1d, Alpha = 0.3d };
+ change(actor.Shadow);
+ }));
+
+ yield return Refused("a shadow thrown nowhere across", Shadowed(shadow => shadow.DxPct = null), "actors[0].shadow.dxPct is missing");
+
+ yield return Refused("a shadow thrown past half a card", Shadowed(shadow => shadow.DxPct = 51d),
+ "actors[0].shadow.dxPct is 51, and the range is -50 to 50");
+
+ yield return Refused("a shadow thrown up past half a card", Shadowed(shadow => shadow.DyPct = -51d),
+ "actors[0].shadow.dyPct is -51, and the range is -50 to 50");
+
+ yield return Refused("a shadow with no blur", Shadowed(shadow => shadow.BlurPct = null), "actors[0].shadow.blurPct is missing");
+
+ yield return Refused("a shadow blurred past half a card", Shadowed(shadow => shadow.BlurPct = 51d),
+ "actors[0].shadow.blurPct is 51, and the range is 0 to 50");
+
+ yield return Refused("a shadow with no alpha", Shadowed(shadow => shadow.Alpha = null), "actors[0].shadow.alpha is missing");
+
+ yield return Refused("a shadow past opaque", Shadowed(shadow => shadow.Alpha = 1.5d),
+ "actors[0].shadow.alpha is 1.5, and the range is 0 to 1");
+ }
+
+ private static IEnumerable AtlasRefusals()
+ {
+ ProfileScenePayload Cut(Action change) => Scene(Wash(actor => change(actor.Atlas!)));
+
+ yield return Refused("a rectangle left of the sheet", Cut(atlas => atlas.X = -1), "actors[0].atlas.x is -1, and the range is 0 to 4096");
+
+ yield return Refused("a rectangle below any texture", Cut(atlas => atlas.Y = 4097),
+ "actors[0].atlas.y is 4097, and the range is 0 to 4096",
+ "actors[0].atlas runs 3857px past the bottom of the sheet");
+
+ yield return Refused("a rectangle with no width, which is not measured", Cut(atlas =>
+ {
+ atlas.W = null;
+ atlas.X = 4000;
+ }),
+ "actors[0].atlas.w is missing");
+
+ yield return Refused("a rectangle of no height, which is not measured", Cut(atlas =>
+ {
+ atlas.H = 0;
+ atlas.Y = 4000;
+ }),
+ "actors[0].atlas.h is 0, and the range is 1 to 4096");
+
+ yield return Refused("one picture with columns", Cut(atlas => atlas.Columns = 4),
+ "actors[0].atlas names no frames, so it is one picture and has no columns, fps or still");
+
+ yield return Refused("one picture with a frame rate", Cut(atlas => atlas.Fps = 8),
+ "actors[0].atlas names no frames, so it is one picture and has no columns, fps or still");
+
+ yield return Refused("one picture with a still frame", Cut(atlas => atlas.Still = 0),
+ "actors[0].atlas names no frames, so it is one picture and has no columns, fps or still");
+
+ yield return Refused("one picture past the right edge", Cut(atlas => atlas.X = 248),
+ "actors[0].atlas runs 8px past the right edge of the sheet");
+
+ yield return Refused("one picture past the bottom", Cut(atlas => atlas.Y = 248),
+ "actors[0].atlas runs 8px past the bottom of the sheet");
+
+ yield return Refused("one picture with no corner, measured from the sheet's", Cut(atlas =>
+ {
+ atlas.X = null;
+ atlas.Y = null;
+ atlas.W = 300;
+ atlas.H = 260;
+ }),
+ "actors[0].atlas runs 44px past the right edge of the sheet",
+ "actors[0].atlas runs 4px past the bottom of the sheet");
+
+ ProfileScenePayload Strip(int? frames, int? columns, Action? change = null)
+ => Cut(atlas =>
+ {
+ atlas.Frames = frames;
+ atlas.Columns = columns;
+ change?.Invoke(atlas);
+ });
+
+ yield return Refused("a strip of no frames, which is checked no further", Strip(0, 4, atlas => atlas.Still = 9),
+ "actors[0].atlas.frames is 0, and the range is 1 to 64");
+
+ yield return Refused("a strip of sixty-five frames", Strip(65, 5), "actors[0].atlas.frames is 65, and the range is 1 to 64");
+
+ yield return Refused("a strip with no columns, which is checked no further", Strip(4, null, atlas => atlas.Still = 9),
+ "actors[0].atlas.columns is missing");
+
+ yield return Refused("a strip of no columns", Strip(4, 0), "actors[0].atlas.columns is 0, and the range is 1 to 64");
+
+ yield return Refused("a strip played too fast", Strip(4, 4, atlas => atlas.Fps = 61), "actors[0].atlas.fps is 61, and the range is 1 to 60");
+
+ yield return Refused("a strip played not at all", Strip(4, 4, atlas => atlas.Fps = 0), "actors[0].atlas.fps is 0, and the range is 1 to 60");
+
+ yield return Refused("more columns than frames", Strip(2, 4), "actors[0].atlas.frames must divide evenly into rows of 4");
+
+ yield return Refused("a last row that runs out, whose still is still checked", Strip(6, 4, atlas => atlas.Still = 6),
+ "actors[0].atlas.frames must divide evenly into rows of 4",
+ "actors[0].atlas.still names frame 6, and the strip has 6");
+
+ yield return Refused("a still frame past the strip", Strip(4, 4, atlas => atlas.Still = 4),
+ "actors[0].atlas.still names frame 4, and the strip has 4");
+
+ yield return Refused("a still frame before the strip", Strip(4, 4, atlas => atlas.Still = -1),
+ "actors[0].atlas.still names frame -1, and the strip has 4");
+
+ yield return Refused("a strip whose later frames run off the right", Strip(8, 4, atlas =>
+ {
+ atlas.W = 64;
+ atlas.X = 16;
+ }),
+ "actors[0].atlas runs 16px past the right edge of the sheet");
+
+ yield return Refused("a strip whose later rows run off the bottom", Strip(16, 4, atlas => atlas.H = 80),
+ "actors[0].atlas runs 64px past the bottom of the sheet");
+ }
+
+ private static IEnumerable Acceptances()
+ {
+ yield return Accepted("a choice with a default of content", Scene(Wash(), scene =>
+ {
+ scene.Reach = "choice";
+ scene.DefaultReach = "content";
+ }));
+
+ yield return Accepted("a reach of content", Scene(Wash(), scene => scene.Reach = "content"));
+ yield return Accepted("the smallest minimum width", Scene(Wash(), scene => scene.MinWidthPx = 0));
+ yield return Accepted("the largest minimum width", Scene(Wash(), scene => scene.MinWidthPx = 512));
+ yield return Accepted("eight actors", Scene(Wash(), scene => scene.Actors = Enumerable.Range(0, 8).Select(_ => Wash()).ToArray()));
+ yield return Accepted("a file and then a sheet reader", Scene(Band(), scene =>
+ {
+ scene.Actors = [Band(), Wash()];
+ scene.Sheet = new CosmeticSceneSheet { W = 256, H = 256 };
+ }));
+
+ yield return Accepted("only files and no sheet", Scene(Band(), scene => scene.Actors = [Band(), Wrap()]));
+ yield return Accepted("a covered wash that spills", Scene(Wash(actor =>
+ {
+ actor.SpillPct = 25d;
+ actor.Fit = "cover";
+ })));
+
+ yield return Accepted("a wash that spills with no fit named", Scene(Wash(actor => actor.SpillPct = 4d)));
+ yield return Accepted("a wash that spills with an empty fit", Scene(Wash(actor =>
+ {
+ actor.SpillPct = 4d;
+ actor.Fit = string.Empty;
+ })));
+
+ yield return Accepted("a contained wash that does not spill", Scene(Wash(actor =>
+ {
+ actor.SpillPct = 0d;
+ actor.Fit = "contain";
+ })));
+
+ yield return Accepted("empty words where a word may be left out", Scene(Sprite(actor =>
+ {
+ actor.Ease = string.Empty;
+ actor.Turn = string.Empty;
+ actor.BowAxis = string.Empty;
+ actor.Repeat = string.Empty;
+ actor.Depth = string.Empty;
+ actor.Slot = string.Empty;
+ actor.Occlude = [];
+ })));
+
+ yield return Accepted("a point nudged to the edge of its range", Scene(Sprite(actor =>
+ actor.To = new CosmeticScenePoint { Anchor = "center", DxPct = -400d, DyPct = 400d })));
+
+ yield return Accepted("a rectangle told not to replay", Scene(Wash(actor => actor.Replay = false)));
+ yield return Accepted("a file told to replay", Scene(Wash(actor =>
+ {
+ actor.Source = "file";
+ actor.Atlas = null;
+ actor.Replay = true;
+ actor.Slot = "Poster";
+ })));
+
+ foreach (var slot in Enum.GetNames())
+ {
+ yield return Accepted($"the {slot} slot", Scene(Wash(actor => actor.Slot = slot)));
+ }
+
+ yield return Accepted("hidden by the avatar", Scene(Wash(actor => actor.Occlude = ["avatar"])));
+ yield return Accepted("an envelope of eight stops",
+ Scene(Wash(actor => actor.Opacity = Enumerable.Range(0, 8).Select(index => new CosmeticSceneStop { At = index / 7d, V = 1d }).ToArray())));
+
+ yield return Accepted("a wrap of empty words", Scene(Wrap(actor => actor.Tile = string.Empty)));
+ yield return Accepted("a scatter spread exactly as wide as itself", Scene(Emitter(actor =>
+ {
+ actor.SizeVarPct = 4d;
+ actor.DurationVarMs = 9000;
+ actor.SwayPeriodMs = 1000;
+ actor.SwayVarMs = 1000;
+ })));
+
+ yield return Accepted("a sway spread with no sway period to spread", Scene(Emitter(actor => actor.SwayVarMs = 1001)));
+ yield return Accepted("a grow whose ends meet", Scene(Band(actor => actor.Grow = Changed(Grow(), grow =>
+ {
+ grow.FromPct = 50d;
+ grow.ToPct = 50d;
+ grow.LeadAxis = string.Empty;
+ grow.Repeat = string.Empty;
+ grow.HoldMs = 0;
+ grow.FromScale = 0.01d;
+ grow.WobbleDeg = 45d;
+ grow.Softness = 1d;
+ grow.Sway = new CosmeticSceneSway { Deg = 45d, Ms = 240 };
+ }))));
+
+ yield return Accepted("a retreat that keeps half of itself", Scene(Wash(actor => actor.Retreat = new CosmeticSceneRetreat
+ {
+ AtMs = 0,
+ DurationMs = 240,
+ EdgePct = 50d,
+ FootPct = 50d,
+ SoftPct = 25d,
+ Remain = 0.5d
+ })));
+
+ yield return Accepted("a strip that fills the sheet exactly", Scene(Wash(actor => actor.Atlas = new CosmeticSceneAtlas
+ {
+ X = 0,
+ Y = 0,
+ W = 64,
+ H = 128,
+ Frames = 8,
+ Columns = 4,
+ Fps = 60,
+ Still = 7
+ })));
+
+ yield return Accepted("a tuning that says nothing", new ProfileSceneTuning());
+ yield return Accepted("a tuning with an empty reach", new ProfileSceneTuning { Reach = string.Empty });
+ }
+}
diff --git a/tests/ArgonSharedLogicTest/CosmeticSceneWireTests.cs b/tests/ArgonSharedLogicTest/CosmeticSceneWireTests.cs
new file mode 100644
index 00000000..7bdfbfe7
--- /dev/null
+++ b/tests/ArgonSharedLogicTest/CosmeticSceneWireTests.cs
@@ -0,0 +1,626 @@
+namespace ArgonSharedLogicTest;
+
+using Argon.Features.Cosmetics;
+using ArgonContracts;
+
+///
+/// Every word and every number of a scene, as Ion carries it.
+///
+///
+/// The stored document says what a scene is in words; the wire says it in cases. Each word of
+/// every closed list has exactly one case, and a field the row left out arrives absent rather than
+/// as a default — the defaults are the renderer's.
+///
+/// The mapping is only ever handed a document that passed validation. The last few tests hand
+/// it one that did not, to pin what it does then: a word a scene cannot be drawn without stops the
+/// mapping outright, and an optional word it has no case for is left out.
+///
+[TestFixture]
+public class CosmeticSceneWireTests
+{
+ private static readonly string[] Anchors =
+ ["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"];
+
+ private static CosmeticSceneActor Wash() => new() { Type = "wash", Source = "atlas", Atlas = new CosmeticSceneAtlas { W = 16, H = 16 } };
+
+ private static CosmeticSceneActor Sprite() => new()
+ {
+ Type = "sprite",
+ SizePct = 20d,
+ DurationMs = 4000,
+ From = new CosmeticScenePoint { Anchor = "topRight" },
+ To = new CosmeticScenePoint { Anchor = "bottomLeft" }
+ };
+
+ private static CosmeticSceneActor Band() => new()
+ {
+ Type = "band",
+ Source = "file",
+ Edge = "bottom",
+ Slice = [1, 2, 3, 4],
+ ThicknessPct = 10d
+ };
+
+ private static CosmeticSceneActor Wrap() => new()
+ {
+ Type = "wrap",
+ Source = "file",
+ Slice = [8, 8, 8, 8],
+ WidthPct = [1d, 2d, 3d, 4d]
+ };
+
+ private static CosmeticSceneActor Emitter() => new()
+ {
+ Type = "emitter",
+ Edge = "top",
+ Count = 12,
+ SizePct = 4d,
+ DurationMs = 9000
+ };
+
+ private static CosmeticSceneGrow Grow() => new() { FromPct = 0d, ToPct = 100d, DurationMs = 2000 };
+
+ private static PayloadProfileScene Wire(ProfileScenePayload scene) => (PayloadProfileScene)scene.ToWire();
+
+ private static PayloadProfileScene Wire(params CosmeticSceneActor[] actors)
+ => Wire(new ProfileScenePayload { Reach = "card", Actors = actors });
+
+ private static T Only(CosmeticSceneActor actor) where T : ISceneActor => (T)Wire(actor).actors.Single();
+
+ private static CosmeticSceneActor With(CosmeticSceneActor actor, Action change)
+ {
+ change(actor);
+
+ return actor;
+ }
+
+ /// The same near misses the validation is held to; see CosmeticSceneRefusalTests.
+ private static IEnumerable NearMisses(params string[] words)
+ {
+ var misses = new HashSet { "~", string.Empty };
+
+ foreach (var word in words)
+ {
+ for (var index = 0; index < word.Length; index++)
+ {
+ misses.Add(string.Concat(word.AsSpan(0, index), "~", word.AsSpan(index + 1)));
+ }
+
+ misses.Add(word[..^1]);
+ misses.Add(word + "~");
+ }
+
+ misses.ExceptWith(words);
+
+ return misses;
+ }
+
+ [TestCase("content", SceneReachRule.Content)]
+ [TestCase("card", SceneReachRule.Card)]
+ [TestCase("choice", SceneReachRule.Choice)]
+ public void A_reach_rule_is_its_case(string reach, SceneReachRule expected)
+ => Assert.That(Wire(new ProfileScenePayload { Reach = reach, Actors = [Wash()] }).reach, Is.EqualTo(expected));
+
+ [TestCase("content", SceneReach.Content)]
+ [TestCase("card", SceneReach.Card)]
+ [TestCase(null, null)]
+ public void A_default_reach_is_its_case_or_absent(string? reach, SceneReach? expected)
+ => Assert.That(Wire(new ProfileScenePayload { Reach = "choice", DefaultReach = reach, Actors = [Wash()] }).defaultReach, Is.EqualTo(expected));
+
+ [Test]
+ public void The_scene_numbers_arrive_as_the_row_said_them()
+ {
+ var said = Wire(new ProfileScenePayload { Reach = "card", MinWidthPx = 240, Sheet = new CosmeticSceneSheet { W = 512, H = 64 }, Actors = [Wash()] });
+ var silent = Wire(new ProfileScenePayload { Reach = "card", Actors = [Band()] });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(said.minWidthPx, Is.EqualTo((ushort)240));
+ Assert.That(said.sheet, Is.EqualTo(new SceneSheet(512, 64)));
+ Assert.That(silent.minWidthPx, Is.Null);
+ Assert.That(silent.sheet, Is.Null);
+ });
+ }
+
+ [TestCase(CosmeticAssetSlot.Primary, AssetSlot.Primary)]
+ [TestCase(CosmeticAssetSlot.Secondary, AssetSlot.Secondary)]
+ [TestCase(CosmeticAssetSlot.Tertiary, AssetSlot.Tertiary)]
+ [TestCase(CosmeticAssetSlot.Quaternary, AssetSlot.Quaternary)]
+ [TestCase(CosmeticAssetSlot.Poster, AssetSlot.Poster)]
+ public void A_slot_is_named_as_the_asset_store_names_it(CosmeticAssetSlot slot, AssetSlot expected)
+ => Assert.That(Only(With(Band(), actor => actor.Slot = slot.ToString())).slot, Is.EqualTo(expected));
+
+ [TestCase(null)]
+ [TestCase("")]
+ public void An_actor_that_names_no_slot_has_none(string? slot)
+ => Assert.That(Only(With(Band(), actor => actor.Slot = slot)).slot, Is.Null);
+
+ [TestCase("atlas", SceneSource.Atlas)]
+ [TestCase("file", SceneSource.File)]
+ [TestCase(null, null)]
+ public void A_source_is_its_case_or_absent(string? source, SceneSource? expected)
+ => Assert.That(Only(With(Wash(), actor => actor.Source = source)).source, Is.EqualTo(expected));
+
+ [TestCase("deep", SceneDepth.Deep)]
+ [TestCase("over", SceneDepth.Over)]
+ [TestCase("front", SceneDepth.Front)]
+ [TestCase(null, null)]
+ public void A_depth_is_its_case_or_absent(string? depth, SceneDepth? expected)
+ => Assert.That(Only(With(Wash(), actor => actor.Depth = depth)).depth, Is.EqualTo(expected));
+
+ [TestCase("cover", SceneFit.Cover)]
+ [TestCase("contain", SceneFit.Contain)]
+ [TestCase("stretch", SceneFit.Stretch)]
+ [TestCase(null, null)]
+ public void A_fit_is_its_case_or_absent(string? fit, SceneFit? expected)
+ => Assert.That(Only(With(Wash(), actor => actor.Fit = fit)).fit, Is.EqualTo(expected));
+
+ [TestCase("loop", SceneRepeat.Loop)]
+ [TestCase("once", SceneRepeat.Once)]
+ [TestCase("pingPong", SceneRepeat.PingPong)]
+ [TestCase(null, null)]
+ public void A_repeat_is_its_case_or_absent_on_every_sort_that_has_one(string? repeat, SceneRepeat? expected)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Only(With(Wash(), actor => actor.Repeat = repeat)).repeat, Is.EqualTo(expected));
+ Assert.That(Only(With(Sprite(), actor => actor.Repeat = repeat)).repeat, Is.EqualTo(expected));
+ Assert.That(Only(With(Emitter(), actor => actor.Repeat = repeat)).repeat, Is.EqualTo(expected));
+
+ var grow = Grow();
+
+ grow.Repeat = repeat;
+
+ Assert.That(Only(With(Band(), actor => actor.Grow = grow)).grow!.repeat, Is.EqualTo(expected));
+ });
+ }
+
+ [TestCase("linear", SceneEase.Linear)]
+ [TestCase("in", SceneEase.In)]
+ [TestCase("out", SceneEase.Out)]
+ [TestCase("inOut", SceneEase.InOut)]
+ [TestCase(null, null)]
+ public void An_ease_is_its_case_or_absent(string? ease, SceneEase? expected)
+ => Assert.That(Only(With(Sprite(), actor => actor.Ease = ease)).ease, Is.EqualTo(expected));
+
+ [TestCase("none", SceneTurn.None)]
+ [TestCase("mirror", SceneTurn.Mirror)]
+ [TestCase(null, null)]
+ public void A_turn_is_its_case_or_absent(string? turn, SceneTurn? expected)
+ => Assert.That(Only(With(Sprite(), actor => actor.Turn = turn)).turn, Is.EqualTo(expected));
+
+ [TestCase("auto", SceneBowAxis.Auto)]
+ [TestCase("x", SceneBowAxis.X)]
+ [TestCase("y", SceneBowAxis.Y)]
+ [TestCase(null, null)]
+ public void A_bow_axis_is_its_case_or_absent(string? axis, SceneBowAxis? expected)
+ => Assert.That(Only(With(Sprite(), actor => actor.BowAxis = axis)).bowAxis, Is.EqualTo(expected));
+
+ [TestCase("topLeft", SceneAnchor.TopLeft)]
+ [TestCase("top", SceneAnchor.Top)]
+ [TestCase("topRight", SceneAnchor.TopRight)]
+ [TestCase("left", SceneAnchor.Left)]
+ [TestCase("center", SceneAnchor.Center)]
+ [TestCase("right", SceneAnchor.Right)]
+ [TestCase("bottomLeft", SceneAnchor.BottomLeft)]
+ [TestCase("bottom", SceneAnchor.Bottom)]
+ [TestCase("bottomRight", SceneAnchor.BottomRight)]
+ public void An_anchor_is_its_case_at_either_end_of_a_path(string anchor, SceneAnchor expected)
+ {
+ var sprite = Only(With(Sprite(), actor =>
+ {
+ actor.From = new CosmeticScenePoint { Anchor = anchor, DxPct = 1.5d };
+ actor.To = new CosmeticScenePoint { Anchor = anchor, DyPct = -2.5d };
+ }));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(sprite.from, Is.EqualTo(new ScenePoint(expected, 1.5d, null)));
+ Assert.That(sprite.to, Is.EqualTo(new ScenePoint(expected, null, -2.5d)));
+ });
+ }
+
+ [TestCase("top", SceneEdge.Top)]
+ [TestCase("right", SceneEdge.Right)]
+ [TestCase("bottom", SceneEdge.Bottom)]
+ [TestCase("left", SceneEdge.Left)]
+ public void An_edge_is_its_case_on_a_band_and_a_scatter(string edge, SceneEdge expected)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Only(With(Band(), actor => actor.Edge = edge)).edge, Is.EqualTo(expected));
+ Assert.That(Only(With(Emitter(), actor => actor.Edge = edge)).edge, Is.EqualTo(expected));
+ });
+ }
+
+ [TestCase("stretch", SceneTile.Stretch)]
+ [TestCase("repeat", SceneTile.Repeat)]
+ [TestCase("round", SceneTile.Round)]
+ [TestCase("space", SceneTile.Space)]
+ [TestCase(null, null)]
+ public void A_tile_is_its_case_or_absent_on_both_nine_slices(string? tile, SceneTile? expected)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(Only(With(Band(), actor => actor.Tile = tile)).tile, Is.EqualTo(expected));
+ Assert.That(Only(With(Wrap(), actor => actor.Tile = tile)).tile, Is.EqualTo(expected));
+ });
+ }
+
+ [TestCase("y", SceneLeadAxis.Y)]
+ [TestCase("x", SceneLeadAxis.X)]
+ [TestCase("both", SceneLeadAxis.Both)]
+ [TestCase(null, null)]
+ public void A_lead_axis_is_its_case_or_absent(string? axis, SceneLeadAxis? expected)
+ {
+ var grow = Grow();
+
+ grow.LeadAxis = axis;
+
+ Assert.That(Only(With(Wash(), actor => actor.Grow = grow)).grow!.leadAxis, Is.EqualTo(expected));
+ }
+
+ [TestCase(null, null)]
+ [TestCase("edge", SceneGrowOrigin.Edge)]
+ [TestCase("topLeft", SceneGrowOrigin.TopLeft)]
+ [TestCase("top", SceneGrowOrigin.Top)]
+ [TestCase("topRight", SceneGrowOrigin.TopRight)]
+ [TestCase("left", SceneGrowOrigin.Left)]
+ [TestCase("center", SceneGrowOrigin.Center)]
+ [TestCase("right", SceneGrowOrigin.Right)]
+ [TestCase("bottomLeft", SceneGrowOrigin.BottomLeft)]
+ [TestCase("bottom", SceneGrowOrigin.Bottom)]
+ [TestCase("bottomRight", SceneGrowOrigin.BottomRight)]
+ public void A_grow_origin_is_its_case_or_absent(string? origin, SceneGrowOrigin? expected)
+ {
+ var grow = Grow();
+
+ grow.From = origin;
+
+ Assert.That(Only(With(Wrap(), actor => actor.Grow = grow)).grow!.from, Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void What_every_sort_shares_arrives_as_the_row_said_it()
+ {
+ var wash = Only(With(Wash(), actor =>
+ {
+ actor.Slot = "Primary";
+ actor.Replay = false;
+ actor.Occlude = ["avatar"];
+ actor.DelayMs = -500;
+ actor.Opacity = [new CosmeticSceneStop { At = 0d, V = 0.25d }, new CosmeticSceneStop { At = 1d, V = 1d }];
+ actor.Shadow = new CosmeticSceneShadow { DxPct = 0.5d, DyPct = -1d, BlurPct = 2d, Alpha = 0.3d };
+ }));
+
+ var bare = Only(With(Wash(), actor => actor.Atlas = null));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(wash.slot, Is.EqualTo(AssetSlot.Primary));
+ Assert.That(wash.replay, Is.False);
+ Assert.That(wash.occlude, Is.EqualTo(new[] { SceneOccluder.Avatar }));
+ Assert.That(wash.delayMs, Is.EqualTo(-500));
+ Assert.That(wash.opacity, Is.EqualTo(new[] { new SceneStop(0d, 0.25d), new SceneStop(1d, 1d) }));
+ Assert.That(wash.shadow, Is.EqualTo(new SceneShadow(0.5d, -1d, 2d, 0.3d)));
+
+ Assert.That(bare.atlas, Is.Null);
+ Assert.That(bare.replay, Is.Null);
+ Assert.That(bare.occlude, Is.Null);
+ Assert.That(bare.delayMs, Is.Null);
+ Assert.That(bare.opacity, Is.Null);
+ Assert.That(bare.shadow, Is.Null);
+ });
+ }
+
+ [Test]
+ public void An_atlas_says_only_what_the_row_said()
+ {
+ var strip = Only(With(Wash(), actor => actor.Atlas = new CosmeticSceneAtlas
+ {
+ X = 16,
+ Y = 32,
+ W = 64,
+ H = 48,
+ Frames = 8,
+ Columns = 4,
+ Fps = 12,
+ Still = 3
+ }));
+
+ var cell = Only(Wash());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(strip.atlas, Is.EqualTo(new SceneAtlas(16, 32, 64, 48, 8, 4, 12, 3)));
+ Assert.That(cell.atlas, Is.EqualTo(new SceneAtlas(null, null, 16, 16, null, null, null, null)));
+ });
+ }
+
+ [Test]
+ public void A_wash_arrives_with_every_number_it_carries()
+ {
+ var said = Only(With(Wash(), actor =>
+ {
+ actor.PeriodMs = 6000;
+ actor.Scale = [new CosmeticSceneStop { At = 0d, V = 1d }, new CosmeticSceneStop { At = 1d, V = 1.5d }];
+ actor.SpillPct = 4d;
+ actor.Grow = new CosmeticSceneGrow
+ {
+ FromPct = 10d,
+ ToPct = 90d,
+ DurationMs = 2000,
+ HoldMs = 500,
+ FromScale = 0.2d,
+ WobbleDeg = 6d,
+ Softness = 0.4d,
+ Sway = new CosmeticSceneSway { Deg = 3d, Ms = 5000 }
+ };
+
+ actor.Retreat = new CosmeticSceneRetreat { AtMs = 3000, DurationMs = 800, EdgePct = 10d, FootPct = 20d, SoftPct = 5d, Remain = 0.2d };
+ }));
+
+ var silent = Only(Wash());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(said.periodMs, Is.EqualTo(6000));
+ Assert.That(said.scale, Is.EqualTo(new[] { new SceneStop(0d, 1d), new SceneStop(1d, 1.5d) }));
+ Assert.That(said.spillPct, Is.EqualTo(4d));
+ Assert.That(said.grow, Is.EqualTo(new SceneGrow(10d, 90d, 2000, 500, null, 0.2d, null, 6d, 0.4d, new SceneSway(3d, 5000), null)));
+ Assert.That(said.retreat, Is.EqualTo(new SceneRetreat(3000, 800, 10d, 20d, 5d, 0.2d)));
+
+ Assert.That(silent.periodMs, Is.Null);
+ Assert.That(silent.scale, Is.Null);
+ Assert.That(silent.spillPct, Is.Null);
+ Assert.That(silent.grow, Is.Null);
+ Assert.That(silent.retreat, Is.Null);
+ });
+ }
+
+ [Test]
+ public void A_sprite_arrives_with_every_number_it_carries()
+ {
+ var said = Only(With(Sprite(), actor =>
+ {
+ actor.BobPct = 2d;
+ actor.Rotate = [new CosmeticSceneStop { At = 0d, V = -10d }, new CosmeticSceneStop { At = 1d, V = 10d }];
+ actor.BowPct = 30d;
+ actor.BankDeg = 12d;
+ actor.Scale = [new CosmeticSceneStop { At = 0d, V = 1d }, new CosmeticSceneStop { At = 1d, V = 2d }];
+ actor.SpillPct = 5d;
+ actor.Retreat = new CosmeticSceneRetreat { AtMs = 1000, DurationMs = 400 };
+ }));
+
+ var silent = Only(Sprite());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(said.sizePct, Is.EqualTo(20d));
+ Assert.That(said.durationMs, Is.EqualTo(4000));
+ Assert.That(said.bobPct, Is.EqualTo(2d));
+ Assert.That(said.rotate, Is.EqualTo(new[] { new SceneStop(0d, -10d), new SceneStop(1d, 10d) }));
+ Assert.That(said.bowPct, Is.EqualTo(30d));
+ Assert.That(said.bankDeg, Is.EqualTo(12d));
+ Assert.That(said.scale, Is.EqualTo(new[] { new SceneStop(0d, 1d), new SceneStop(1d, 2d) }));
+ Assert.That(said.spillPct, Is.EqualTo(5d));
+ Assert.That(said.retreat, Is.EqualTo(new SceneRetreat(1000, 400, null, null, null, null)));
+
+ Assert.That(silent.source, Is.Null);
+ Assert.That(silent.atlas, Is.Null);
+ Assert.That(silent.bobPct, Is.Null);
+ Assert.That(silent.rotate, Is.Null);
+ Assert.That(silent.bowPct, Is.Null);
+ Assert.That(silent.bankDeg, Is.Null);
+ Assert.That(silent.scale, Is.Null);
+ Assert.That(silent.spillPct, Is.Null);
+ Assert.That(silent.retreat, Is.Null);
+ });
+ }
+
+ [Test]
+ public void A_band_arrives_with_every_number_it_carries()
+ {
+ var said = Only(With(Band(), actor =>
+ {
+ actor.CornerPct = 12d;
+ actor.OutsetPct = 3d;
+ actor.Retreat = new CosmeticSceneRetreat { AtMs = 0, DurationMs = 240 };
+ }));
+
+ var silent = Only(Band());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(said.slice, Is.EqualTo(new SceneSlice(1, 2, 3, 4)));
+ Assert.That(said.thicknessPct, Is.EqualTo(10d));
+ Assert.That(said.cornerPct, Is.EqualTo(12d));
+ Assert.That(said.outsetPct, Is.EqualTo(3d));
+ Assert.That(said.retreat, Is.EqualTo(new SceneRetreat(0, 240, null, null, null, null)));
+
+ Assert.That(silent.cornerPct, Is.Null);
+ Assert.That(silent.outsetPct, Is.Null);
+ Assert.That(silent.grow, Is.Null);
+ Assert.That(silent.retreat, Is.Null);
+ });
+ }
+
+ [Test]
+ public void A_wrap_arrives_with_its_four_sides()
+ {
+ var said = Only(With(Wrap(), actor =>
+ {
+ actor.OutsetPct = 2d;
+ actor.Retreat = new CosmeticSceneRetreat { AtMs = 0, DurationMs = 240 };
+ }));
+
+ var silent = Only(Wrap());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(said.slice, Is.EqualTo(new SceneSlice(8, 8, 8, 8)));
+ Assert.That(said.widthPct, Is.EqualTo(new SceneWidths(1d, 2d, 3d, 4d)));
+ Assert.That(said.outsetPct, Is.EqualTo(2d));
+ Assert.That(said.retreat, Is.EqualTo(new SceneRetreat(0, 240, null, null, null, null)));
+
+ Assert.That(silent.outsetPct, Is.Null);
+ Assert.That(silent.grow, Is.Null);
+ Assert.That(silent.retreat, Is.Null);
+ });
+ }
+
+ [Test]
+ public void A_scatter_arrives_with_every_number_it_carries()
+ {
+ var said = Only(With(Emitter(), actor =>
+ {
+ actor.Seed = 7;
+ actor.BobPct = 1d;
+ actor.SizeVarPct = 2d;
+ actor.DurationVarMs = 1500;
+ actor.DriftPct = 30d;
+ actor.SwayPct = 8d;
+ actor.SwayPeriodMs = 2400;
+ actor.SwayVarMs = 600;
+ actor.SpinDeg = 180d;
+ actor.FlutterDeg = 40d;
+ actor.DepthSpreadPct = 60d;
+ }));
+
+ var silent = Only(Emitter());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(said.count, Is.EqualTo((ushort)12));
+ Assert.That(said.seed, Is.EqualTo((ushort)7));
+ Assert.That(said.sizePct, Is.EqualTo(4d));
+ Assert.That(said.durationMs, Is.EqualTo(9000));
+ Assert.That(said.bobPct, Is.EqualTo(1d));
+ Assert.That(said.sizeVarPct, Is.EqualTo(2d));
+ Assert.That(said.durationVarMs, Is.EqualTo(1500));
+ Assert.That(said.driftPct, Is.EqualTo(30d));
+ Assert.That(said.swayPct, Is.EqualTo(8d));
+ Assert.That(said.swayPeriodMs, Is.EqualTo(2400));
+ Assert.That(said.swayVarMs, Is.EqualTo(600));
+ Assert.That(said.spinDeg, Is.EqualTo(180d));
+ Assert.That(said.flutterDeg, Is.EqualTo(40d));
+ Assert.That(said.depthSpreadPct, Is.EqualTo(60d));
+
+ Assert.That(silent.seed, Is.Null);
+ Assert.That(silent.bobPct, Is.Null);
+ Assert.That(silent.sizeVarPct, Is.Null);
+ Assert.That(silent.durationVarMs, Is.Null);
+ Assert.That(silent.driftPct, Is.Null);
+ Assert.That(silent.swayPct, Is.Null);
+ Assert.That(silent.swayPeriodMs, Is.Null);
+ Assert.That(silent.swayVarMs, Is.Null);
+ Assert.That(silent.spinDeg, Is.Null);
+ Assert.That(silent.flutterDeg, Is.Null);
+ Assert.That(silent.depthSpreadPct, Is.Null);
+ });
+ }
+
+ [Test]
+ public void An_unvalidated_actor_of_no_known_sort_stops_the_mapping()
+ => Assert.That(() => Wire(With(Wash(), actor => actor.Type = "cloud")),
+ Throws.TypeOf().With.Message.Contains("a scene actor this build has no case for"));
+
+ [Test]
+ public void An_unvalidated_word_a_scene_cannot_be_drawn_without_stops_the_mapping()
+ {
+ Assert.Multiple(() =>
+ {
+ foreach (var miss in NearMisses("content", "card", "choice"))
+ {
+ Assert.That(() => Wire(new ProfileScenePayload { Reach = miss, Actors = [Wash()] }),
+ Throws.TypeOf(), $"reach '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("top", "right", "bottom", "left"))
+ {
+ Assert.That(() => Wire(With(Band(), actor => actor.Edge = miss)), Throws.TypeOf(), $"edge '{miss}'");
+ }
+
+ foreach (var miss in NearMisses(Anchors))
+ {
+ Assert.That(() => Wire(With(Sprite(), actor => actor.From = new CosmeticScenePoint { Anchor = miss })),
+ Throws.TypeOf(), $"anchor '{miss}'");
+ }
+
+ foreach (var miss in NearMisses(["edge", .. Anchors]))
+ {
+ var grow = Grow();
+
+ grow.From = miss;
+
+ Assert.That(() => Wire(With(Band(), actor => actor.Grow = grow)), Throws.TypeOf(), $"grow from '{miss}'");
+ }
+ });
+ }
+
+ [Test]
+ public void An_unvalidated_optional_word_with_no_case_is_left_out()
+ {
+ Assert.Multiple(() =>
+ {
+ foreach (var miss in NearMisses("content", "card"))
+ {
+ Assert.That(Wire(new ProfileScenePayload { Reach = "choice", DefaultReach = miss, Actors = [Wash()] }).defaultReach,
+ Is.Null, $"defaultReach '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("atlas", "file"))
+ {
+ Assert.That(Only(With(Wash(), actor => actor.Source = miss)).source, Is.Null, $"source '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("deep", "over", "front"))
+ {
+ Assert.That(Only(With(Wash(), actor => actor.Depth = miss)).depth, Is.Null, $"depth '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("cover", "contain", "stretch"))
+ {
+ Assert.That(Only(With(Wash(), actor => actor.Fit = miss)).fit, Is.Null, $"fit '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("loop", "once", "pingPong"))
+ {
+ Assert.That(Only(With(Wash(), actor => actor.Repeat = miss)).repeat, Is.Null, $"repeat '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("linear", "in", "out", "inOut"))
+ {
+ Assert.That(Only(With(Sprite(), actor => actor.Ease = miss)).ease, Is.Null, $"ease '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("none", "mirror"))
+ {
+ Assert.That(Only(With(Sprite(), actor => actor.Turn = miss)).turn, Is.Null, $"turn '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("auto", "x", "y"))
+ {
+ Assert.That(Only(With(Sprite(), actor => actor.BowAxis = miss)).bowAxis, Is.Null, $"bowAxis '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("stretch", "repeat", "round", "space"))
+ {
+ Assert.That(Only(With(Band(), actor => actor.Tile = miss)).tile, Is.Null, $"tile '{miss}'");
+ }
+
+ foreach (var miss in NearMisses("y", "x", "both"))
+ {
+ var grow = Grow();
+
+ grow.LeadAxis = miss;
+
+ Assert.That(Only(With(Band(), actor => actor.Grow = grow)).grow!.leadAxis, Is.Null, $"leadAxis '{miss}'");
+ }
+
+ // A part of the card that hides nothing is not carried, and the parts that do still are.
+ Assert.That(Only(With(Wash(), actor => actor.Occlude = ["glass", "avatar"])).occlude,
+ Is.EqualTo(new[] { SceneOccluder.Avatar }));
+ });
+ }
+}
diff --git a/tests/ArgonSharedLogicTest/CosmeticWornProjectionTests.cs b/tests/ArgonSharedLogicTest/CosmeticWornProjectionTests.cs
index 98136631..03fb96d3 100644
--- a/tests/ArgonSharedLogicTest/CosmeticWornProjectionTests.cs
+++ b/tests/ArgonSharedLogicTest/CosmeticWornProjectionTests.cs
@@ -57,6 +57,15 @@ public void A_worn_item_goes_out_as_its_id_and_nothing_else()
Assert.That(worn.Single(), Is.EqualTo(new WornItem(halo.Id)));
}
+ /// A row of a kind with rows that names none is half-written, and half a thing is not drawn.
+ [Test]
+ public void A_row_of_an_item_kind_that_names_no_item_is_left_out()
+ {
+ var halo = Item("avatar.decoration");
+
+ Assert.That(CosmeticWornProjection.Project([Worn(halo) with { CosmeticItemId = null }], Servable(halo), Registry, AllKinds()), Is.Empty);
+ }
+
/// Unpublished, switched off or out of its window: the read left it out of the servable set.
[Test]
public void An_item_that_may_not_be_served_is_left_out()
@@ -77,7 +86,7 @@ public void A_kind_that_is_switched_off_is_left_out()
[Test]
public void A_kind_this_build_does_not_declare_is_left_out()
{
- var old = Item("profile.background");
+ var old = Item("profile.aurora");
Assert.That(CosmeticWornProjection.Project([Worn(old)], Servable(old), Registry, AllKinds()), Is.Empty);
}
@@ -172,4 +181,74 @@ public void Cosmetics_arrive_in_compositing_order()
Assert.That(((WornItem)worn[0]).itemId, Is.EqualTo(halo.Id));
}
+
+ /// Badges are worn several at once, and the order is the wearer's: slot by slot.
+ [Test]
+ public void Rows_of_a_kind_worn_several_at_once_arrive_in_slot_order()
+ {
+ var first = Item("profile.badge", "first");
+ var second = Item("profile.badge", "second");
+
+ var worn = CosmeticWornProjection.Project(
+ [Worn(second) with { SlotIndex = 1 }, Worn(first)], Servable(first, second), Registry, AllKinds());
+
+ Assert.That(worn, Is.EqualTo(new IWornCosmetic[] { new WornItem(first.Id), new WornItem(second.Id) }));
+ }
+
+ /// A scene row that leaves its reach to the wearer, which is the only kind a worn reach is read on.
+ private static CosmeticItemEntity ChoiceScene()
+ => Item(ProfileSceneKind.Key) with { Payload = """{"reach":"choice","defaultReach":"content"}""" };
+
+ /// A scene whose wearer chose its reach carries the choice; one with none is a plain item.
+ [Test]
+ public void A_scene_carries_its_reach_only_when_one_was_chosen()
+ {
+ var scene = ChoiceScene();
+
+ var chosen = Worn(scene) with { Tuning = """{"reach":"card"}""" };
+ var deferred = Worn(scene);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(CosmeticWornProjection.Project([chosen], Servable(scene), Registry, AllKinds()).Single(),
+ Is.EqualTo(new WornScene(scene.Id, SceneReach.Card)));
+ Assert.That(CosmeticWornProjection.Project([deferred], Servable(scene), Registry, AllKinds()).Single(),
+ Is.EqualTo(new WornItem(scene.Id)));
+ });
+ }
+
+ /// Either reach the wearer can choose arrives as chosen; a tuning this build cannot read is no choice.
+ [Test]
+ public void A_scene_carries_either_reach_and_an_unreadable_tuning_is_no_choice()
+ {
+ var scene = ChoiceScene();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(CosmeticWornProjection.Project([Worn(scene) with { Tuning = """{"reach":"content"}""" }], Servable(scene), Registry, AllKinds()).Single(),
+ Is.EqualTo(new WornScene(scene.Id, SceneReach.Content)));
+ Assert.That(CosmeticWornProjection.Project([Worn(scene) with { Tuning = "not json" }], Servable(scene), Registry, AllKinds()).Single(),
+ Is.EqualTo(new WornItem(scene.Id)));
+ });
+ }
+
+ ///
+ /// A row re-authored to a fixed reach outranks a choice made while it offered one. The choice is
+ /// not sent, and it is not erased either: offering the choice again brings it back.
+ ///
+ [Test]
+ public void A_reach_chosen_on_a_row_that_has_since_fixed_its_own_is_not_sent()
+ {
+ var offered = ChoiceScene();
+ var fixedAt = offered with { Payload = """{"reach":"content"}""" };
+ var worn = Worn(offered) with { Tuning = """{"reach":"card"}""" };
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(CosmeticWornProjection.Project([worn], Servable(fixedAt), Registry, AllKinds()).Single(),
+ Is.EqualTo(new WornItem(offered.Id)));
+ Assert.That(CosmeticWornProjection.Project([worn], Servable(offered), Registry, AllKinds()).Single(),
+ Is.EqualTo(new WornScene(offered.Id, SceneReach.Card)));
+ });
+ }
}
diff --git a/tests/ArgonSharedLogicTest/ProfileSceneKindTests.cs b/tests/ArgonSharedLogicTest/ProfileSceneKindTests.cs
new file mode 100644
index 00000000..a350893a
--- /dev/null
+++ b/tests/ArgonSharedLogicTest/ProfileSceneKindTests.cs
@@ -0,0 +1,60 @@
+namespace ArgonSharedLogicTest;
+
+using Argon.Features.Cosmetics;
+using Argon.Features.Cosmetics.Kinds;
+using ArgonContracts;
+
+///
+/// A worn scene's reach: what the wearer's choice is stored as, and what the stored choice goes out as.
+///
+///
+/// The two directions have to agree, because a profile carries back exactly what was put on. A choice
+/// that cannot be stored stores nothing, and a stored nothing goes out as a plain worn item that defers
+/// to the row's own default.
+///
+[TestFixture]
+public class ProfileSceneKindTests
+{
+ private static readonly Guid Scene = Guid.NewGuid();
+
+ [TestCase(SceneReach.Content, "content")]
+ [TestCase(SceneReach.Card, "card")]
+ public void A_chosen_reach_is_stored_as_its_word(SceneReach reach, string word)
+ => Assert.That(ProfileSceneKind.TuningOf(reach).Reach, Is.EqualTo(word));
+
+ /// The wire's enum is open: a reach a newer client knows and this build does not is no word at all.
+ [Test]
+ public void A_reach_this_build_has_no_word_for_is_stored_as_nothing()
+ => Assert.That(ProfileSceneKind.TuningOf((SceneReach)9).Reach, Is.Null);
+
+ [TestCase("content", SceneReach.Content)]
+ [TestCase("card", SceneReach.Card)]
+ public void A_stored_reach_goes_out_as_a_worn_scene(string word, SceneReach reach)
+ => Assert.That(ProfileSceneKind.ToWire(Scene, new ProfileSceneTuning { Reach = word }), Is.EqualTo(new WornScene(Scene, reach)));
+
+ [TestCase(null)]
+ [TestCase("far")]
+ public void A_stored_tuning_that_names_no_reach_goes_out_as_a_plain_item(string? word)
+ => Assert.That(ProfileSceneKind.ToWire(Scene, new ProfileSceneTuning { Reach = word }), Is.EqualTo(new WornItem(Scene)));
+
+ [Test]
+ public void No_stored_tuning_goes_out_as_a_plain_item()
+ => Assert.That(ProfileSceneKind.ToWire(Scene, null), Is.EqualTo(new WornItem(Scene)));
+
+ [Test]
+ public void A_row_that_offers_the_choice_leaves_the_reach_to_its_wearer()
+ => Assert.That(ProfileSceneKind.LeavesReachToWearer("""{"reach":"choice","defaultReach":"card"}"""), Is.True);
+
+ /// A fixed reach is the row's answer, and a document that cannot be read offers nothing.
+ [TestCase("""{"reach":"content"}""")]
+ [TestCase("""{"reach":"card"}""")]
+ [TestCase("{}")]
+ [TestCase("not a document")]
+ public void A_row_that_fixes_its_reach_leaves_the_wearer_none(string payload)
+ => Assert.That(ProfileSceneKind.LeavesReachToWearer(payload), Is.False);
+
+ [TestCase(SceneReach.Content)]
+ [TestCase(SceneReach.Card)]
+ public void A_choice_survives_being_stored_and_sent(SceneReach reach)
+ => Assert.That(ProfileSceneKind.ToWire(Scene, ProfileSceneKind.TuningOf(reach)), Is.EqualTo(new WornScene(Scene, reach)));
+}
diff --git a/tests/ArgonSharedLogicTest/WornCosmeticWireSizeTests.cs b/tests/ArgonSharedLogicTest/WornCosmeticWireSizeTests.cs
index abedac85..b77c50f4 100644
--- a/tests/ArgonSharedLogicTest/WornCosmeticWireSizeTests.cs
+++ b/tests/ArgonSharedLogicTest/WornCosmeticWireSizeTests.cs
@@ -80,21 +80,45 @@ public void The_heaviest_name_still_fits_in_a_hundred_bytes()
Assert.That(size, Is.LessThanOrEqualTo(100));
}
- /// A frame, a decoration and a styled name — what somebody who has bought everything wears.
[Test]
- public void Everything_this_build_can_put_on_one_person_stays_under_a_hundred_and_fifty_bytes()
+ public void A_scene_with_its_reach_is_its_id_and_one_more_number()
{
- var worn = new IonArray([
- new WornItem(Guid.NewGuid()),
- new WornNickname(Guid.NewGuid(), Guid.NewGuid(),
- new IonArray([unchecked((int)0xFFFF0000), unchecked((int)0xFF0000FF)]), 90, null, null, 700, null),
- new WornItem(Guid.NewGuid())
- ]);
+ var size = Encoded(new IonArray([new WornScene(Guid.NewGuid(), SceneReach.Card)]));
+
+ TestContext.Out.WriteLine($"one worn scene with a reach: {size} bytes");
+
+ Assert.That(size, Is.LessThanOrEqualTo(28));
+ }
+
+ ///
+ /// A background, eight badges, a frame, a scene with its reach, a decoration, an orbit and a styled
+ /// name — what somebody who has bought everything wears.
+ ///
+ ///
+ /// Thirteen references where there were three, which is what the budget grew by: the badges alone
+ /// are eight rows, and each is an id.
+ ///
+ [Test]
+ public void Everything_this_build_can_put_on_one_person_stays_under_four_hundred_bytes()
+ {
+ var worn = new List { new WornItem(Guid.NewGuid()) };
+
+ for (var badge = 0; badge < 8; badge++)
+ {
+ worn.Add(new WornItem(Guid.NewGuid()));
+ }
+
+ worn.Add(new WornItem(Guid.NewGuid()));
+ worn.Add(new WornScene(Guid.NewGuid(), SceneReach.Content));
+ worn.Add(new WornItem(Guid.NewGuid()));
+ worn.Add(new WornItem(Guid.NewGuid()));
+ worn.Add(new WornNickname(Guid.NewGuid(), Guid.NewGuid(),
+ new IonArray([unchecked((int)0xFFFF0000), unchecked((int)0xFF0000FF)]), 90, null, null, 700, null));
- var size = Encoded(worn);
+ var size = Encoded(new IonArray(worn));
TestContext.Out.WriteLine($"a fully dressed profile: {size} bytes");
- Assert.That(size, Is.LessThanOrEqualTo(150));
+ Assert.That(size, Is.LessThanOrEqualTo(400));
}
}