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)); } }