Update avatar_base implementation, assets and documentation

This commit is contained in:
cthangover committed 2026-10-31 15:28:48 +03:00
commit 1ac123dd1b
24 files changed
+862

No files matched your search

+30
View File
@@ -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
+38
View File
@@ -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
+137
View File
@@ -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<string, string> {
["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.
+55
View File
@@ -0,0 +1,55 @@
# Система аватаров: основа
`avatar_base` реализует независимое состояние аватара: группы, именованные пресеты,
композицию целевых значений и JSON-состояние для сохранений. Зависимости от диалогов,
боевых персонажей и Live2D нет. Полный формат и контракт: [en.md](en.md).
## Управление
```csharp
IAvatar avatar = AvatarCatalog.Create("my_game/marao");
avatar.ApplyPresets(new Dictionary<string, string> {
["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-пример и правила владения
ресурсами приведены в английской документации. Тесты проверяют независимость
экземпляров, приоритеты, атомарность, сериализацию и загрузку настоящего ресурса.
+2
View File
@@ -0,0 +1,2 @@
mod_avatar_base_name = Avatar Engine
mod_avatar_base_desc = Reusable avatars controlled by named presets, independent of rendering technology.
+2
View File
@@ -0,0 +1,2 @@
mod_avatar_base_name = Система аватаров
mod_avatar_base_desc = Переиспользуемые аватары с именованными пресетами и независимым способом отображения.
+23
View File
@@ -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"
]
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

+62
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[EntityKind("avatar")]
public sealed class AvatarDefinition : IIdentifiable
{
/// <summary>Entity identity used by content references and saved instance state.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Renderer identifier; naming it does not load or require that renderer.</summary>
public string Renderer { get; set; }
/// <summary>Baseline bindings restored on every composition, before selected presets.</summary>
public Dictionary<string, JsonElement> BaseValues { get; set; } = new();
/// <summary>Author-defined groups, such as eyes_state, emotion or pose; keys are case-sensitive.</summary>
public Dictionary<string, AvatarGroupDefinition> Groups { get; set; } = new();
/// <summary>Rejects incomplete definitions before any instance or renderer can consume them.</summary>
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<string, JsonElement> 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.");
}
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Avatars
{
/// <summary>Exactly one preset is selected per group. Higher priority groups override lower ones.</summary>
public sealed class AvatarGroupDefinition
{
/// <summary>Initial selection; also the selection to request when resetting this group.</summary>
public string Default { get; set; }
/// <summary>Composition order, ascending; ties use ordinal group names, never command order.</summary>
public int Priority { get; set; }
/// <summary>Named states available to scenario authors. An empty preset can delegate to earlier groups.</summary>
public Dictionary<string, AvatarPresetDefinition> Presets { get; set; } = new();
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Text.Json;
namespace Cthangover.Mods.Avatars
{
/// <summary>A named state contributes renderer-specific target bindings, not incremental changes.</summary>
public sealed class AvatarPresetDefinition
{
/// <summary>
/// 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.
/// </summary>
public Dictionary<string, JsonElement> Values { get; set; } = new();
}
}
+44
View File
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Consumers;
namespace Cthangover.Mods.Avatars
{
/// <summary>
/// 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.
/// </summary>
public sealed class AvatarRenderers : ModTypeConsumer<IAvatarRendererFactory>
{
private static readonly Dictionary<string, IAvatarRendererFactory> factories = new(StringComparer.Ordinal);
/// <inheritdoc/>
public override string Id => "avatar_renderers";
/// <summary>Creates the selected backend and applies the initial target; caller owns the result.</summary>
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; }
}
/// <inheritdoc/>
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}'.");
}
/// <inheritdoc/>
protected override void Remove(IAvatarRendererFactory instance, string modId)
{
if (factories.TryGetValue(instance.Id, out var current) && ReferenceEquals(current, instance))
factories.Remove(instance.Id);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using System;
using Godot;
namespace Cthangover.Mods.Avatars
{
/// <summary>
/// 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.
/// </summary>
public interface IAvatarRenderer : IDisposable
{
/// <summary>Renderer-owned visual root, created on the Godot main thread.</summary>
Control View { get; }
/// <summary>
/// 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.
/// </summary>
void Apply(AvatarFrame target, double transitionSeconds = 0);
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.Avatars
{
/// <summary>Public parameterless implementations are discovered and removed with their owning mod.</summary>
public interface IAvatarRendererFactory
{
/// <summary>Renderer identifier used in avatar definitions, for example layers.</summary>
string Id { get; }
/// <summary>Creates an owned renderer; the caller must dispose it even if applying a target fails.</summary>
IAvatarRenderer Create();
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Mods.Avatars
{
/// <summary>Uses the normal entity resolver, including folder/ZIP mods, inheritance and patches.</summary>
public static class AvatarCatalog
{
/// <summary>Creates independent state from the currently resolved resource; unknown IDs throw.</summary>
public static IAvatar Create(string id, AvatarState state = null)
{
if (string.IsNullOrWhiteSpace(id) || !Entities.Collect<AvatarDefinition>().TryGetValue(id, out var definition))
throw new KeyNotFoundException($"Avatar definition '{id}' was not found.");
return new AvatarInstance(definition, state);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json;
namespace Cthangover.Mods.Avatars
{
/// <summary>Detached target snapshot. Renderers interpolate toward it or switch discrete assets.</summary>
public sealed class AvatarFrame
{
/// <summary>Identity of the avatar producing this frame.</summary>
public string AvatarId { get; }
/// <summary>Renderer identifier from the definition; consumers must check compatibility.</summary>
public string Renderer { get; }
/// <summary>All active preset choices, including defaults.</summary>
public IReadOnlyDictionary<string, string> Presets { get; }
/// <summary>Complete composed targets. An absent binding is absent, not left over from a prior frame.</summary>
public IReadOnlyDictionary<string, JsonElement> Values { get; }
internal AvatarFrame(string id, string renderer, Dictionary<string, string> presets,
Dictionary<string, JsonElement> values)
{
AvatarId = id;
Renderer = renderer;
Presets = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(presets));
var copy = new Dictionary<string, JsonElement>();
foreach (var value in values) copy.Add(value.Key, value.Value.Clone());
Values = new ReadOnlyDictionary<string, JsonElement>(copy);
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace Cthangover.Mods.Avatars
{
/// <summary>
/// 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.
/// </summary>
public sealed class AvatarInstance : IAvatar
{
private readonly AvatarDefinition definition;
private readonly Dictionary<string, string> selections = new(StringComparer.Ordinal);
/// <summary>Creates defaults, optionally overlaid with validated saved selections.</summary>
public AvatarInstance(AvatarDefinition definition, AvatarState state = null)
{
if (definition == null) throw new ArgumentNullException(nameof(definition));
definition.Validate();
this.definition = JsonSerializer.Deserialize<AvatarDefinition>(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);
}
}
/// <inheritdoc/>
public string AvatarId => definition.ID;
/// <inheritdoc/>
public void ApplyPresets(IReadOnlyDictionary<string, string> 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<string, string>(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;
}
/// <inheritdoc/>
public AvatarState CaptureState() => new()
{
AvatarId = AvatarId,
Presets = new Dictionary<string, string>(selections, StringComparer.Ordinal)
};
/// <inheritdoc/>
public AvatarFrame Resolve()
{
var values = new Dictionary<string, JsonElement>(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<string, AvatarGroupDefinition> group) => group.Value.Priority;
private static string Name(KeyValuePair<string, AvatarGroupDefinition> group) => group.Key;
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Avatars
{
/// <summary>
/// Serializable logical state only: no nodes, textures or interpolation progress.
/// Capture returns a detached copy; restoring a partial map fills missing groups from defaults.
/// </summary>
public sealed class AvatarState
{
/// <summary>Definition identity prevents accidentally restoring another avatar's selections.</summary>
public string AvatarId { get; set; }
/// <summary>Selected preset per group. Unknown groups or presets are rejected on restore.</summary>
public Dictionary<string, string> Presets { get; set; } = new();
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Avatars
{
/// <summary>
/// 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.
/// </summary>
public interface IAvatar
{
/// <summary>The resource whose defaults and bindings were snapshotted for this instance.</summary>
string AvatarId { get; }
/// <summary>
/// Atomically replaces selections for specified groups and retains all other selections.
/// Invalid names throw ArgumentException without changing the instance. Null is not a reset.
/// </summary>
void ApplyPresets(IReadOnlyDictionary<string, string> changes);
/// <summary>Captures a detached, JSON-serializable logical state for persistence.</summary>
AvatarState CaptureState();
/// <summary>Composes a complete immutable renderer target from baseline and current presets.</summary>
AvatarFrame Resolve();
}
}
@@ -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
}
}
}
}
}
}
]
}
+12
View File
@@ -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"
]
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Avatars;
namespace Cthangover.Mods.Avatars.Contracts
{
/// <summary>Exercises real Kind registration, mod entity resolution and independent catalog instances.</summary>
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<string, string> { ["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"]);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
namespace Cthangover.Mods.Avatars.Tests
{
/// <summary>Resource serialization preserves opaque renderer values and rejects malformed preset groups.</summary>
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<object>(null);
var copy = JsonSerializer.Deserialize<AvatarDefinition>(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");
}
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
namespace Cthangover.Mods.Avatars.Tests
{
/// <summary>Public preset contract: independent instances, atomic changes and deterministic full targets.</summary>
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<string, string> { ["emotion"] = "angry" });
ModAssert.CloseTo(0.3, avatar.Resolve().Values["eye"].GetDouble());
ModAssert.Equal("red", avatar.Resolve().Values["tint"].GetString());
avatar.ApplyPresets(new Dictionary<string, string> { ["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<string, string> { ["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<string, string> { ["emotion"] = "angry" });
a.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "opened" });
b.ApplyPresets(new Dictionary<string, string> { ["eyes_state"] = "opened" });
b.ApplyPresets(new Dictionary<string, string> { ["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<string, string> { ["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<string, string> { ["emotion"] = "angry", ["eyes_state"] = "missing" });
Reject(avatar, new Dictionary<string, string> { ["Emotion"] = "angry" });
Reject(avatar, new Dictionary<string, string> { ["emotion"] = "Angry" });
Reject(avatar, new Dictionary<string, string> { ["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<string, string> { ["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<string, string>)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<string, string> { ["emotion"] = "angry", ["eyes_state"] = "opened" });
var saved = JsonSerializer.Deserialize<AvatarState>(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<AvatarDefinition>(
"{\"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<string, string> 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");
}
}
}