commit 1ac123dd1b55daed8d5036ff8861be249a203f12 Author: iisaev Date: Sat Oct 31 15:28:48 2026 +0300 Update avatar_base implementation, assets and documentation diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f79a943 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Godot 4.x +.godot/ +.idea/ +sdk/ +bin/ +obj/ + +#ai +.omo/ +.opencode/ + +gen/ +obj/ +bin/ +art_sources/ + +*.tres~ +*.tscn~ + +# GDScript LSP +.godot/ + +# Export +*.exe +*.dmg +*.apk +*.aab +*.pck +*.zip +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +*.import +*.godot.uid diff --git a/docs/en.md b/docs/en.md new file mode 100644 index 0000000..b0d340b --- /dev/null +++ b/docs/en.md @@ -0,0 +1,137 @@ +# Avatar Engine foundation + +`avatar_base` owns renderer-independent avatar definitions and preset state. It has no +runtime dependency on dialogs, party characters, UI engine mods or Live2D. A visual novel, +a character preview or another UI can use the same logical instance. + +This first version implements resource loading, validated preset selections, deterministic +composition and serializable state. `IAvatarRenderer` defines the presentation boundary; +The `avatar_layers` mod implements the `layers` renderer and is an optional dependency of content +that uses it. Live2D adaptation, scenario actions, a visual editor and automatic save-system +integration are not implemented yet. Existing `first=` / `second=` dialog portraits are unaffected. + +## Resource format + +Content mods declare `depends: ["avatar_base>=1"]` (or `avatar_layers>=1` for layered images) and supply normal entity files, for example +`avatars/marao.json`. `Kind: avatar` is registered by `AvatarDefinition`. Definitions use the +standard entity resolver: folder/ZIP providers, overrides, `Extends` and patches work through +that pipeline; the catalog does not parse files or maintain a second resource cache. + +```json +{ + "Kind": "avatar", + "Items": [{ + "Id": "my_game/marao", + "Renderer": "layers", + "BaseValues": { + "layers/body": "marao/body.png", + "layers/eyes": "marao/eyes/opened.png" + }, + "Groups": { + "emotion": { + "Default": "neutral", + "Priority": 0, + "Presets": { + "neutral": {}, + "angry": {"Values": {"layers/eyes": "marao/eyes/narrowed.png"}} + } + }, + "eyes_state": { + "Default": "auto", + "Priority": 100, + "Presets": { + "auto": {}, + "opened": {"Values": {"layers/eyes": "marao/eyes/opened.png"}}, + "closed": {"Values": {"layers/eyes": "marao/eyes/closed.png"}} + } + } + } + }] +} +``` + +Group and preset names are author-defined, case-sensitive and not a fixed engine vocabulary. +Each group has one selected preset; `Default` must name an existing preset. Definitions with +no groups are valid static avatars. `auto` is an ordinary empty preset here, not a reserved word: +it contributes nothing, leaving the baseline and earlier groups in control. + +`Values` is a dictionary of opaque JSON targets. The layers adapter reads PNG paths; +a Live2D adapter can read numeric values such as `parameters/ParamEyeLOpen: 1`. The base does +not invent a universal numeric range, interpret binding names or load assets. Binding value +types, required bindings, compatibility of poses/layers and asset existence are the adapter's +responsibility. Values can also be objects or arrays for an adapter's structured configuration. + +## Applying presets + +```csharp +IAvatar avatar = AvatarCatalog.Create("my_game/marao"); +avatar.ApplyPresets(new Dictionary { + ["emotion"] = "angry", + ["eyes_state"] = "opened" +}); +AvatarFrame target = avatar.Resolve(); +using IAvatarRenderer renderer = AvatarRenderers.Create(avatar); +// Attach renderer.View to the host. After later changes: +renderer.Apply(target, transitionSeconds: 0.15); +AvatarState saved = avatar.CaptureState(); +IAvatar restored = AvatarCatalog.Create(saved.AvatarId, saved); +``` + +`ApplyPresets` is atomic: every supplied group/preset is checked before changing any selection. +Unmentioned groups retain their choices. Null, misspellings and unknown presets throw +`ArgumentException`; there is no silent fallback. `AvatarCatalog.Create` throws +`KeyNotFoundException` for an unknown avatar. Select the group's default explicitly to reset it. + +Every `Resolve` starts from `BaseValues` and applies all selected presets by ascending group +`Priority`, then ordinal group name. Later contributions replace earlier values for the same +binding. The order of scenario commands and JSON properties never determines precedence. +An individual JSON binding is replaced whole, not deep merged; null is a literal target value. +Thus explicit `eyes_state` at priority 100 overrides the eye contribution of `emotion` at 0. +Selecting `auto` delegates control back to the emotion/baseline on the next composition. + +The output is a complete immutable snapshot, not a delta. Leaving a preset removes its +contributions; adapters must reset bindings absent from a later target rather than retaining +stale values. Define adapter-required reset targets in `BaseValues`. There is no automatic +cross-group compatibility solver in this foundation. + +## Ownership and persistence + +An `AvatarInstance` validates and snapshots its definition. It never changes cached entity DTOs. +Two instances of the same definition have independent selections. Frames and captured states +are detached; editing the returned save DTO cannot alter a live instance. Active instances keep +their definition snapshot after resource changes; the owning host recreates them on reload. + +`AvatarState` stores the avatar ID and selected preset names. JSON round-trip it in the owning +subsystem's save data. Missing groups on restore use defaults; a mismatched avatar ID or unknown +selection is rejected. Content migrations must explicitly handle removed/renamed presets. +There is no global avatar roster or automatic registration with the game's save system. + +`IAvatarRenderer` exposes a renderer-owned `Control` and accepts complete targets with an optional +transition duration. Rendering mods implement it and decide how presets become images or model +parameters. They must validate compatible targets and finite nonnegative durations. Zero snaps; +a discrete adapter may also snap for positive duration. A new animated target interrupts the +previous transition from the currently displayed state. The host attaches `View` to its UI and +disposes the adapter before freeing that container. The adapter frees owned nodes/animations, +never shared cache textures. All visual operations run on the Godot main thread. + +The base owns no Godot objects or update loop. Pure logical instances do not need disposal. +`AvatarRenderers` discovers public, parameterless `IAvatarRendererFactory` implementations via +`ModTypeConsumer`, including normal unload notifications. Factories declare a unique case-sensitive +renderer ID and create an owned adapter. Duplicate IDs are rejected. `AvatarRenderers.Create(avatar)` +resolves the backend and applies the initial state, disposing the adapter if initialization fails. +The host explicitly applies subsequent `avatar.Resolve()` targets; there is no hidden subscription. +The host must dispose active adapters before their mod/scene unloads. Missing backends throw +`KeyNotFoundException`, making a missing content dependency visible. + +## Verification + +Tests use the engine's runtime mod compiler, not a separate project referencing mod sources: + +```text +godot --headless --path . -- --frontend --with-mods=avatar_base --run-tests --mod=avatar_base +godot --headless --path . -- --frontend --with-mods=avatar_base --itest=avatar_contracts --run-tests --mod=avatar_base__contracts +``` + +In this workspace run these through the shared Windows job bridge. The opt-in `contracts` test +suite supplies a sample `Kind: avatar` resource and checks the real catalog and independent +instances. Production mods contain no sample character or fake renderer implementation. diff --git a/docs/ru.md b/docs/ru.md new file mode 100644 index 0000000..2a1fc07 --- /dev/null +++ b/docs/ru.md @@ -0,0 +1,55 @@ +# Система аватаров: основа + +`avatar_base` реализует независимое состояние аватара: группы, именованные пресеты, +композицию целевых значений и JSON-состояние для сохранений. Зависимости от диалогов, +боевых персонажей и Live2D нет. Полный формат и контракт: [en.md](en.md). + +## Управление + +```csharp +IAvatar avatar = AvatarCatalog.Create("my_game/marao"); +avatar.ApplyPresets(new Dictionary { + ["emotion"] = "angry", + ["eyes_state"] = "opened" +}); +AvatarFrame target = avatar.Resolve(); +AvatarState saved = avatar.CaptureState(); +``` + +Контентный мод зависит от `avatar_base>=1` и объявляет сущности `Kind: avatar`. +У определения есть `Id`, `Renderer`, `BaseValues` и `Groups`. У группы — `Default`, +`Priority`, `Presets`; у пресета — `Values`. Имена групп и пресетов произвольные, +регистрозависимые. Значения пресетов — целевые привязки для конкретного рендера, +а не универсальные числа от 0 до 1. + +Каждая группа выбирает один пресет. Изменение группы сохраняет остальные группы. +Все изменения проверяются до применения: опечатка не оставляет частично изменённое +состояние. Неизвестные значения отклоняются, автоматических подмен нет. + +Композиция начинается с `BaseValues`, затем применяет группы по возрастанию +`Priority`, при равенстве — по имени (ordinal). Более поздняя группа перекрывает +одноимённую привязку целиком. Порядок команд ничего не меняет. Например, эмоция +имеет приоритет 0, явное состояние глаз — 100. Пустой пресет `auto` не вносит +привязок и тем самым возвращает управление эмоции. Это обычное имя, не ключевое слово. + +`Resolve()` возвращает полный неизменяемый снимок, без оставшихся значений прошлого +пресета. `CaptureState()` — отдельный DTO с ID аватара и выбором пресетов. При +восстановлении отсутствующие группы получают значения по умолчанию, неизвестные +группы/пресеты и чужой ID отклоняются. Изменения ресурсов не меняют уже созданный +экземпляр: владелец пересоздаёт его при перезагрузке. + +## Граница реализации + +`IAvatarRenderer` задаёт контракт визуального адаптера: корневой `Control`, применение +полного целевого состояния, время перехода и освобождение принадлежащих ему ресурсов. +Мод `avatar_layers` уже выбирает и отображает PNG-слои. Будущий адаптер Live2D будет +выбирать параметры модели. `AvatarRenderers.Create(avatar)` находит фабрику по `Renderer` +и применяет начальное состояние. После изменений владелец явно вызывает +`renderer.Apply(avatar.Resolve())`. Фабрики автоматически регистрируются и удаляются +вместе с модами через стандартный `ModTypeConsumer`. + +В этой версии нет адаптера Live2D, сценарных команд, редактора, +проверки совместимости картинок и автоматической интеграции с сохранениями. +Существующие диалоговые аватарки работают как прежде. JSON-пример и правила владения +ресурсами приведены в английской документации. Тесты проверяют независимость +экземпляров, приоритеты, атомарность, сериализацию и загрузку настоящего ресурса. diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..8483cab --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_avatar_base_name = Avatar Engine +mod_avatar_base_desc = Reusable avatars controlled by named presets, independent of rendering technology. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..284820e --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_avatar_base_name = Система аватаров +mod_avatar_base_desc = Переиспользуемые аватары с именованными пресетами и независимым способом отображения. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b295f68 --- /dev/null +++ b/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "avatar_base", + "name": "Avatar Engine", + "version": "1.0.0", + "author": "ct", + "description": "Reusable avatar definitions and independent preset state, with renderer-neutral composition for layered images and animated models.", + "category": "engine, library", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": [], + "test_suites": [ + { + "id": "contracts", + "activate": [ + "--itest=avatar_contracts" + ] + } + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..7a928fa Binary files /dev/null and b/mod.jpg differ diff --git a/src/Data/AvatarDefinition.cs b/src/Data/AvatarDefinition.cs new file mode 100644 index 0000000..14bdfeb --- /dev/null +++ b/src/Data/AvatarDefinition.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Avatars +{ + /// + /// Mod entity describing an avatar, not a party character. Presets contain opaque renderer + /// bindings; the base never interprets a binding as a texture, a numeric parameter or a motion. + /// Entity resolver inheritance and patches apply before an instance snapshots this definition. + /// + [EntityKind("avatar")] + public sealed class AvatarDefinition : IIdentifiable + { + /// Entity identity used by content references and saved instance state. + [JsonPropertyName("Id")] + public string ID { get; set; } + + /// Renderer identifier; naming it does not load or require that renderer. + public string Renderer { get; set; } + + /// Baseline bindings restored on every composition, before selected presets. + public Dictionary BaseValues { get; set; } = new(); + + /// Author-defined groups, such as eyes_state, emotion or pose; keys are case-sensitive. + public Dictionary Groups { get; set; } = new(); + + /// Rejects incomplete definitions before any instance or renderer can consume them. + public void Validate() + { + if (string.IsNullOrWhiteSpace(ID)) throw new ArgumentException("Avatar Id is required."); + if (string.IsNullOrWhiteSpace(Renderer)) throw new ArgumentException($"Avatar '{ID}': Renderer is required."); + ValidateValues(BaseValues, "BaseValues"); + if (Groups == null) throw new ArgumentException($"Avatar '{ID}': Groups cannot be null."); + foreach (var entry in Groups) + { + var group = entry.Value; + if (string.IsNullOrWhiteSpace(entry.Key) || group == null) + throw new ArgumentException($"Avatar '{ID}': group names and definitions are required."); + if (group.Presets == null || group.Presets.Count == 0 || + string.IsNullOrWhiteSpace(group.Default) || !group.Presets.ContainsKey(group.Default)) + throw new ArgumentException($"Avatar '{ID}', group '{entry.Key}': Default must name an existing preset."); + foreach (var preset in group.Presets) + { + if (string.IsNullOrWhiteSpace(preset.Key) || preset.Value == null) + throw new ArgumentException($"Avatar '{ID}', group '{entry.Key}': preset names and definitions are required."); + ValidateValues(preset.Value.Values, entry.Key + "/" + preset.Key); + } + } + } + + private void ValidateValues(Dictionary values, string path) + { + if (values == null) throw new ArgumentException($"Avatar '{ID}', {path}: values cannot be null."); + foreach (var value in values) + if (string.IsNullOrWhiteSpace(value.Key) || value.Value.ValueKind == JsonValueKind.Undefined) + throw new ArgumentException($"Avatar '{ID}', {path}: binding names and JSON values are required."); + } + } +} diff --git a/src/Data/AvatarGroupDefinition.cs b/src/Data/AvatarGroupDefinition.cs new file mode 100644 index 0000000..3bd7e71 --- /dev/null +++ b/src/Data/AvatarGroupDefinition.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Avatars +{ + /// Exactly one preset is selected per group. Higher priority groups override lower ones. + public sealed class AvatarGroupDefinition + { + /// Initial selection; also the selection to request when resetting this group. + public string Default { get; set; } + + /// Composition order, ascending; ties use ordinal group names, never command order. + public int Priority { get; set; } + + /// Named states available to scenario authors. An empty preset can delegate to earlier groups. + public Dictionary Presets { get; set; } = new(); + } +} diff --git a/src/Data/AvatarPresetDefinition.cs b/src/Data/AvatarPresetDefinition.cs new file mode 100644 index 0000000..36531a9 --- /dev/null +++ b/src/Data/AvatarPresetDefinition.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace Cthangover.Mods.Avatars +{ + /// A named state contributes renderer-specific target bindings, not incremental changes. + public sealed class AvatarPresetDefinition + { + /// + /// Opaque binding values: a layers renderer may read image IDs, a Live2D renderer numbers. + /// A key is replaced as a whole; objects are not recursively merged and null is a literal value. + /// + public Dictionary Values { get; set; } = new(); + } +} diff --git a/src/Rendering/AvatarRenderers.cs b/src/Rendering/AvatarRenderers.cs new file mode 100644 index 0000000..e6a9be0 --- /dev/null +++ b/src/Rendering/AvatarRenderers.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Mods.Consumers; + +namespace Cthangover.Mods.Avatars +{ + /// + /// Mod-owned factory registry using normal assembly load/unload notifications. No dependency on + /// a concrete backend; duplicate IDs are errors rather than load-order-dependent replacements. + /// + public sealed class AvatarRenderers : ModTypeConsumer + { + private static readonly Dictionary factories = new(StringComparer.Ordinal); + /// + public override string Id => "avatar_renderers"; + + /// Creates the selected backend and applies the initial target; caller owns the result. + public static IAvatarRenderer Create(IAvatar avatar) + { + if (avatar == null) throw new ArgumentNullException(nameof(avatar)); + var target = avatar.Resolve(); + if (!factories.TryGetValue(target.Renderer, out var factory)) + throw new KeyNotFoundException($"Avatar renderer '{target.Renderer}' is not loaded."); + var renderer = factory.Create() ?? throw new InvalidOperationException($"Avatar renderer '{target.Renderer}' returned null."); + try { renderer.Apply(target); return renderer; } + catch { renderer.Dispose(); throw; } + } + + /// + protected override void Add(IAvatarRendererFactory instance, string modId) + { + if (string.IsNullOrWhiteSpace(instance.Id)) throw new ArgumentException($"Mod '{modId}' supplied an empty avatar renderer Id."); + if (!factories.TryAdd(instance.Id, instance)) + throw new InvalidOperationException($"Duplicate avatar renderer '{instance.Id}' from '{modId}'."); + } + + /// + protected override void Remove(IAvatarRendererFactory instance, string modId) + { + if (factories.TryGetValue(instance.Id, out var current) && ReferenceEquals(current, instance)) + factories.Remove(instance.Id); + } + } +} diff --git a/src/Rendering/IAvatarRenderer.cs b/src/Rendering/IAvatarRenderer.cs new file mode 100644 index 0000000..f8b4fae --- /dev/null +++ b/src/Rendering/IAvatarRenderer.cs @@ -0,0 +1,23 @@ +using System; +using Godot; + +namespace Cthangover.Mods.Avatars +{ + /// + /// Optional presentation adapter implemented by a rendering mod. The caller attaches View to + /// its UI container and disposes the adapter before that container is freed. The adapter owns + /// its nodes and animations, but must not dispose borrowed cache-owned textures. + /// + public interface IAvatarRenderer : IDisposable + { + /// Renderer-owned visual root, created on the Godot main thread. + Control View { get; } + + /// + /// Applies a complete compatible target. Seconds must be finite and nonnegative; zero snaps. + /// New targets interrupt previous transitions from the currently displayed state. Discrete + /// renderers may snap even for positive duration. Missing bindings must not retain stale values. + /// + void Apply(AvatarFrame target, double transitionSeconds = 0); + } +} diff --git a/src/Rendering/IAvatarRendererFactory.cs b/src/Rendering/IAvatarRendererFactory.cs new file mode 100644 index 0000000..ed538ec --- /dev/null +++ b/src/Rendering/IAvatarRendererFactory.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.Avatars +{ + /// Public parameterless implementations are discovered and removed with their owning mod. + public interface IAvatarRendererFactory + { + /// Renderer identifier used in avatar definitions, for example layers. + string Id { get; } + /// Creates an owned renderer; the caller must dispose it even if applying a target fails. + IAvatarRenderer Create(); + } +} diff --git a/src/Runtime/AvatarCatalog.cs b/src/Runtime/AvatarCatalog.cs new file mode 100644 index 0000000..ee94b82 --- /dev/null +++ b/src/Runtime/AvatarCatalog.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Cthangover.Core.Mods.Resolvers; + +namespace Cthangover.Mods.Avatars +{ + /// Uses the normal entity resolver, including folder/ZIP mods, inheritance and patches. + public static class AvatarCatalog + { + /// Creates independent state from the currently resolved resource; unknown IDs throw. + public static IAvatar Create(string id, AvatarState state = null) + { + if (string.IsNullOrWhiteSpace(id) || !Entities.Collect().TryGetValue(id, out var definition)) + throw new KeyNotFoundException($"Avatar definition '{id}' was not found."); + return new AvatarInstance(definition, state); + } + } +} diff --git a/src/Runtime/AvatarFrame.cs b/src/Runtime/AvatarFrame.cs new file mode 100644 index 0000000..3fcd5fa --- /dev/null +++ b/src/Runtime/AvatarFrame.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json; + +namespace Cthangover.Mods.Avatars +{ + /// Detached target snapshot. Renderers interpolate toward it or switch discrete assets. + public sealed class AvatarFrame + { + /// Identity of the avatar producing this frame. + public string AvatarId { get; } + /// Renderer identifier from the definition; consumers must check compatibility. + public string Renderer { get; } + /// All active preset choices, including defaults. + public IReadOnlyDictionary Presets { get; } + /// Complete composed targets. An absent binding is absent, not left over from a prior frame. + public IReadOnlyDictionary Values { get; } + + internal AvatarFrame(string id, string renderer, Dictionary presets, + Dictionary values) + { + AvatarId = id; + Renderer = renderer; + Presets = new ReadOnlyDictionary(new Dictionary(presets)); + var copy = new Dictionary(); + foreach (var value in values) copy.Add(value.Key, value.Value.Clone()); + Values = new ReadOnlyDictionary(copy); + } + } +} diff --git a/src/Runtime/AvatarInstance.cs b/src/Runtime/AvatarInstance.cs new file mode 100644 index 0000000..7cc5336 --- /dev/null +++ b/src/Runtime/AvatarInstance.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Cthangover.Mods.Avatars +{ + /// + /// Renderer-independent implementation. Owns a definition snapshot and recomposes full targets + /// so leaving a preset also removes its contributions. No shared entity data is ever mutated. + /// Instances are intended for their owning UI thread; they do not synchronize concurrent writes. + /// + public sealed class AvatarInstance : IAvatar + { + private readonly AvatarDefinition definition; + private readonly Dictionary selections = new(StringComparer.Ordinal); + + /// Creates defaults, optionally overlaid with validated saved selections. + public AvatarInstance(AvatarDefinition definition, AvatarState state = null) + { + if (definition == null) throw new ArgumentNullException(nameof(definition)); + definition.Validate(); + this.definition = JsonSerializer.Deserialize(JsonSerializer.Serialize(definition)); + foreach (var group in this.definition.Groups) selections.Add(group.Key, group.Value.Default); + if (state != null) + { + if (!string.Equals(state.AvatarId, AvatarId, StringComparison.Ordinal)) + throw new ArgumentException($"Saved avatar '{state.AvatarId}' does not match '{AvatarId}'."); + ApplyPresets(state.Presets); + } + } + + /// + public string AvatarId => definition.ID; + + /// + public void ApplyPresets(IReadOnlyDictionary changes) + { + if (changes == null) throw new ArgumentNullException(nameof(changes)); + // Snapshot the caller's map before validation; commit only after the entire patch is valid. + var pending = new Dictionary(changes, StringComparer.Ordinal); + foreach (var change in pending) + { + if (!definition.Groups.TryGetValue(change.Key, out var group)) + throw new ArgumentException($"Avatar '{AvatarId}': unknown group '{change.Key}'."); + if (change.Value == null || !group.Presets.ContainsKey(change.Value)) + throw new ArgumentException($"Avatar '{AvatarId}', group '{change.Key}': unknown preset '{change.Value}'."); + } + foreach (var change in pending) selections[change.Key] = change.Value; + } + + /// + public AvatarState CaptureState() => new() + { + AvatarId = AvatarId, + Presets = new Dictionary(selections, StringComparer.Ordinal) + }; + + /// + public AvatarFrame Resolve() + { + var values = new Dictionary(definition.BaseValues, StringComparer.Ordinal); + foreach (var group in definition.Groups.OrderBy(Priority).ThenBy(Name, StringComparer.Ordinal)) + foreach (var value in group.Value.Presets[selections[group.Key]].Values) + values[value.Key] = value.Value; + return new AvatarFrame(AvatarId, definition.Renderer, selections, values); + } + + private static int Priority(KeyValuePair group) => group.Value.Priority; + private static string Name(KeyValuePair group) => group.Key; + } +} diff --git a/src/Runtime/AvatarState.cs b/src/Runtime/AvatarState.cs new file mode 100644 index 0000000..91c9f7b --- /dev/null +++ b/src/Runtime/AvatarState.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Avatars +{ + /// + /// Serializable logical state only: no nodes, textures or interpolation progress. + /// Capture returns a detached copy; restoring a partial map fills missing groups from defaults. + /// + public sealed class AvatarState + { + /// Definition identity prevents accidentally restoring another avatar's selections. + public string AvatarId { get; set; } + + /// Selected preset per group. Unknown groups or presets are rejected on restore. + public Dictionary Presets { get; set; } = new(); + } +} diff --git a/src/Runtime/IAvatar.cs b/src/Runtime/IAvatar.cs new file mode 100644 index 0000000..0abdc53 --- /dev/null +++ b/src/Runtime/IAvatar.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Avatars +{ + /// + /// One independent logical avatar instance. Named presets are the public control surface; + /// rendering and transitions belong to an attached renderer, not to dialog or character data. + /// + public interface IAvatar + { + /// The resource whose defaults and bindings were snapshotted for this instance. + string AvatarId { get; } + + /// + /// Atomically replaces selections for specified groups and retains all other selections. + /// Invalid names throw ArgumentException without changing the instance. Null is not a reset. + /// + void ApplyPresets(IReadOnlyDictionary changes); + + /// Captures a detached, JSON-serializable logical state for persistence. + AvatarState CaptureState(); + + /// Composes a complete immutable renderer target from baseline and current presets. + AvatarFrame Resolve(); + } +} diff --git a/test_suites/contracts/avatars/contract.json b/test_suites/contracts/avatars/contract.json new file mode 100644 index 0000000..791027d --- /dev/null +++ b/test_suites/contracts/avatars/contract.json @@ -0,0 +1,26 @@ +{ + "Kind": "avatar", + "Items": [ + { + "Id": "avatar_contracts/sample", + "Renderer": "test", + "Groups": { + "eyes_state": { + "Default": "opened", + "Presets": { + "opened": { + "Values": { + "eye": 1 + } + }, + "closed": { + "Values": { + "eye": 0 + } + } + } + } + } + } + ] +} diff --git a/test_suites/contracts/manifest.json b/test_suites/contracts/manifest.json new file mode 100644 index 0000000..7184479 --- /dev/null +++ b/test_suites/contracts/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "avatar_base__contracts", + "name": "Avatar resource contract tests", + "version": "1.0.0", + "author": "ct", + "depends": [ + "avatar_base>=1" + ], + "test_sources": [ + "tests/*.cs" + ] +} diff --git a/test_suites/contracts/tests/AvatarCatalogTests.cs b/test_suites/contracts/tests/AvatarCatalogTests.cs new file mode 100644 index 0000000..4e6f00c --- /dev/null +++ b/test_suites/contracts/tests/AvatarCatalogTests.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using Cthangover.Core.Mods.Testing; +using Cthangover.Mods.Avatars; + +namespace Cthangover.Mods.Avatars.Contracts +{ + /// Exercises real Kind registration, mod entity resolution and independent catalog instances. + public sealed class AvatarCatalogTests + { + [ModTest("avatar_catalog/resolved_entity_creates_independent_instances")] + public static void Resource(ModTestContext ctx) + { + var first = AvatarCatalog.Create("avatar_contracts/sample"); + var second = AvatarCatalog.Create("avatar_contracts/sample"); + first.ApplyPresets(new Dictionary { ["eyes_state"] = "closed" }); + ModAssert.CloseTo(0, first.Resolve().Values["eye"].GetDouble()); + ModAssert.CloseTo(1, second.Resolve().Values["eye"].GetDouble()); + var restored = AvatarCatalog.Create("avatar_contracts/sample", first.CaptureState()); + ModAssert.Equal("closed", restored.CaptureState().Presets["eyes_state"]); + } + } +} diff --git a/tests/AvatarDefinitionTests.cs b/tests/AvatarDefinitionTests.cs new file mode 100644 index 0000000..43f4d3d --- /dev/null +++ b/tests/AvatarDefinitionTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Cthangover.Core.Mods.Testing; + +namespace Cthangover.Mods.Avatars.Tests +{ + /// Resource serialization preserves opaque renderer values and rejects malformed preset groups. + public sealed class AvatarDefinitionTests + { + [ModTest("avatar/definition_json_roundtrip_preserves_renderer_data")] + public static void RoundTrip(ModTestContext ctx) + { + var original = AvatarInstanceTests.Definition(); + original.Groups["eyes_state"].Presets["opened"].Values["layer"] = JsonSerializer.SerializeToElement("eyes/opened"); + original.BaseValues["options"] = JsonSerializer.SerializeToElement(new { enabled = true, offset = new[] { 1, 2 } }); + original.BaseValues["hidden"] = JsonSerializer.SerializeToElement(null); + var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(original)); + copy.Validate(); + ModAssert.Equal(original.ID, copy.ID); ModAssert.Equal(original.Renderer, copy.Renderer); + ModAssert.Equal(100, copy.Groups["eyes_state"].Priority); + ModAssert.Equal("auto", copy.Groups["eyes_state"].Default); + ModAssert.Equal("eyes/opened", copy.Groups["eyes_state"].Presets["opened"].Values["layer"].GetString()); + ModAssert.True(copy.BaseValues["options"].GetProperty("enabled").GetBoolean()); + ModAssert.Equal(2, copy.BaseValues["options"].GetProperty("offset")[1].GetInt32()); + ModAssert.True(copy.BaseValues["hidden"].ValueKind == JsonValueKind.Null); + ModAssert.CloseTo(0.3, copy.Groups["emotion"].Presets["angry"].Values["eye"].GetDouble()); + } + + [ModTest("avatar/invalid_definitions_are_rejected_before_instantiation")] + public static void Invalid(ModTestContext ctx) + { + Reject(new AvatarDefinition()); + var d = AvatarInstanceTests.Definition(); d.Renderer = " "; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups = null; Reject(d); + d = AvatarInstanceTests.Definition(); d.BaseValues = null; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Default = "missing"; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets = null; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups["emotion"] = null; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets["neutral"] = null; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets["neutral"].Values = null; Reject(d); + d = AvatarInstanceTests.Definition(); d.BaseValues["bad"] = default; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups[" "] = d.Groups["emotion"]; Reject(d); + d = AvatarInstanceTests.Definition(); d.Groups["emotion"].Presets[" "] = new(); Reject(d); + d = AvatarInstanceTests.Definition(); d.BaseValues[""] = JsonSerializer.SerializeToElement(1); Reject(d); + var nullRejected = false; + try { _ = new AvatarInstance(null); } catch (ArgumentNullException) { nullRejected = true; } + ModAssert.True(nullRejected); + } + + [ModTest("avatar/catalog_unknown_id_is_diagnosed")] + public static void Missing(ModTestContext ctx) + { + var rejected = false; + try { _ = AvatarCatalog.Create("avatar_tests/nonexistent"); } catch (KeyNotFoundException) { rejected = true; } + ModAssert.True(rejected); + } + + private static void Reject(AvatarDefinition definition) + { + var rejected = false; + try { definition.Validate(); } catch (ArgumentException) { rejected = true; } + ModAssert.True(rejected, "malformed definition is rejected"); + } + } +} diff --git a/tests/AvatarInstanceTests.cs b/tests/AvatarInstanceTests.cs new file mode 100644 index 0000000..5e71d44 --- /dev/null +++ b/tests/AvatarInstanceTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Cthangover.Core.Mods.Testing; + +namespace Cthangover.Mods.Avatars.Tests +{ + /// Public preset contract: independent instances, atomic changes and deterministic full targets. + public sealed class AvatarInstanceTests + { + [ModTest("avatar/defaults_partial_update_and_removed_contributions")] + public static void Selections(ModTestContext ctx) + { + IAvatar avatar = new AvatarInstance(Definition()); + ModAssert.Equal("test/avatar", avatar.AvatarId); + ModAssert.Equal("neutral", avatar.CaptureState().Presets["emotion"]); + ModAssert.Equal("test", avatar.Resolve().Renderer); + ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble()); + avatar.ApplyPresets(new Dictionary { ["emotion"] = "angry" }); + ModAssert.CloseTo(0.3, avatar.Resolve().Values["eye"].GetDouble()); + ModAssert.Equal("red", avatar.Resolve().Values["tint"].GetString()); + avatar.ApplyPresets(new Dictionary { ["eyes_state"] = "opened" }); + ModAssert.Equal("angry", avatar.CaptureState().Presets["emotion"], "unmentioned groups retained"); + ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble(), what: "explicit eyes override emotion"); + avatar.ApplyPresets(new Dictionary { ["emotion"] = "neutral", ["eyes_state"] = "auto" }); + ModAssert.False(avatar.Resolve().Values.ContainsKey("tint"), "leaving a preset removes its contributions"); + ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble(), what: "baseline restored"); + } + + [ModTest("avatar/composition_is_independent_of_command_order")] + public static void Order(ModTestContext ctx) + { + var a = new AvatarInstance(Definition()); var b = new AvatarInstance(Definition()); + a.ApplyPresets(new Dictionary { ["emotion"] = "angry" }); + a.ApplyPresets(new Dictionary { ["eyes_state"] = "opened" }); + b.ApplyPresets(new Dictionary { ["eyes_state"] = "opened" }); + b.ApplyPresets(new Dictionary { ["emotion"] = "angry" }); + ModAssert.CloseTo(a.Resolve().Values["eye"].GetDouble(), b.Resolve().Values["eye"].GetDouble()); + var tied = Definition(); tied.Groups["emotion"].Priority = 100; + var tie = new AvatarInstance(tied); + tie.ApplyPresets(new Dictionary { ["eyes_state"] = "opened", ["emotion"] = "angry" }); + ModAssert.CloseTo(1, tie.Resolve().Values["eye"].GetDouble(), what: "ordinal group name breaks equal priorities"); + } + + [ModTest("avatar/invalid_patch_is_atomic_and_names_are_case_sensitive")] + public static void Atomic(ModTestContext ctx) + { + IAvatar avatar = new AvatarInstance(Definition()); + Reject(avatar, new Dictionary { ["emotion"] = "angry", ["eyes_state"] = "missing" }); + Reject(avatar, new Dictionary { ["Emotion"] = "angry" }); + Reject(avatar, new Dictionary { ["emotion"] = "Angry" }); + Reject(avatar, new Dictionary { ["emotion"] = null }); + Reject(avatar, null); + ModAssert.Equal("neutral", avatar.CaptureState().Presets["emotion"]); + ModAssert.CloseTo(1, avatar.Resolve().Values["eye"].GetDouble()); + } + + [ModTest("avatar/definition_state_and_frame_do_not_alias_instances")] + public static void Isolation(ModTestContext ctx) + { + var definition = Definition(); + IAvatar a = new AvatarInstance(definition); IAvatar b = new AvatarInstance(definition); + var frame = a.Resolve(); var state = a.CaptureState(); + definition.Groups["emotion"].Presets.Clear(); definition.BaseValues.Clear(); + state.Presets["emotion"] = "missing"; + a.ApplyPresets(new Dictionary { ["emotion"] = "angry" }); + ModAssert.Equal("neutral", b.CaptureState().Presets["emotion"]); + ModAssert.Equal("neutral", frame.Presets["emotion"]); + ModAssert.CloseTo(1, frame.Values["eye"].GetDouble()); + ModAssert.CloseTo(0.3, a.Resolve().Values["eye"].GetDouble()); + var readOnly = false; + try { ((IDictionary)frame.Presets).Add("new", "value"); } + catch (NotSupportedException) { readOnly = true; } + ModAssert.True(readOnly, "render target selections cannot be mutated"); + } + + [ModTest("avatar/state_json_roundtrip_and_restore_validation")] + public static void Persistence(ModTestContext ctx) + { + var original = new AvatarInstance(Definition()); + original.ApplyPresets(new Dictionary { ["emotion"] = "angry", ["eyes_state"] = "opened" }); + var saved = JsonSerializer.Deserialize(JsonSerializer.Serialize(original.CaptureState())); + var restored = new AvatarInstance(Definition(), saved); + ModAssert.Equal(original.AvatarId, restored.AvatarId); + ModAssert.Equal("angry", restored.CaptureState().Presets["emotion"]); + ModAssert.Equal("opened", restored.CaptureState().Presets["eyes_state"]); + ModAssert.CloseTo(1, restored.Resolve().Values["eye"].GetDouble()); + saved.Presets.Clear(); + ModAssert.Equal("angry", restored.CaptureState().Presets["emotion"], "restored state is copied"); + var partial = new AvatarState { AvatarId = "test/avatar", Presets = new() { ["emotion"] = "angry" } }; + ModAssert.Equal("auto", new AvatarInstance(Definition(), partial).CaptureState().Presets["eyes_state"]); + RejectState(new AvatarState { AvatarId = "other" }); + RejectState(new AvatarState { AvatarId = "test/avatar", Presets = new() { ["emotion"] = "missing" } }); + RejectState(new AvatarState { AvatarId = "test/avatar", Presets = null }); + } + + internal static AvatarDefinition Definition() => JsonSerializer.Deserialize( + "{\"Id\":\"test/avatar\",\"Renderer\":\"test\",\"BaseValues\":{\"eye\":1},\"Groups\":{" + + "\"emotion\":{\"Default\":\"neutral\",\"Presets\":{\"neutral\":{},\"angry\":{\"Values\":{\"eye\":0.3,\"tint\":\"red\"}}}}," + + "\"eyes_state\":{\"Default\":\"auto\",\"Priority\":100,\"Presets\":{\"auto\":{},\"opened\":{\"Values\":{\"eye\":1}}}}}}"); + + private static void Reject(IAvatar avatar, IReadOnlyDictionary changes) + { + var rejected = false; + try { avatar.ApplyPresets(changes); } catch (ArgumentException) { rejected = true; } + ModAssert.True(rejected, "invalid changes are rejected"); + } + private static void RejectState(AvatarState state) + { + var rejected = false; + try { _ = new AvatarInstance(Definition(), state); } catch (ArgumentException) { rejected = true; } + ModAssert.True(rejected, "invalid restored state is rejected"); + } + } +}