fix: complete retail parity stability pass
This commit is contained in:
parent
d3df4cb20a
commit
f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions
|
|
@ -97,6 +97,33 @@ public sealed class RuntimeOptionsSessionConfigTests
|
|||
Assert.Null(options.StatusFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionConfigPreservesExplicitRetailUiOptOut()
|
||||
{
|
||||
var config = new SessionConfiguration { Version = 1 };
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "no-ui",
|
||||
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
|
||||
Account = "account",
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "X",
|
||||
},
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\dat",
|
||||
key => key == "ACDREAM_RETAIL_UI" ? "0" : null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"password");
|
||||
|
||||
Assert.False(options.RetailUi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ public sealed class LaunchOptionsDocumentationTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The four flags that default ON. All are retail behaviors wearing an
|
||||
/// The five flags that default ON. All are product/retail behaviors wearing an
|
||||
/// A/B off-switch (<c>=0</c> disables) — none is a diagnostic. FROZEN:
|
||||
/// a diagnostic that activates without its env var set taxes every run
|
||||
/// and every measurement silently, so growing this set fails.
|
||||
|
|
@ -119,6 +119,7 @@ public sealed class LaunchOptionsDocumentationTests
|
|||
"ACDREAM_CAMERA_COLLIDE",
|
||||
"ACDREAM_CAMERA_ALIGN_SLOPE",
|
||||
"ACDREAM_RETAIL_CLOSE_DEGRADES",
|
||||
"ACDREAM_RETAIL_UI",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -133,7 +134,7 @@ public sealed class LaunchOptionsDocumentationTests
|
|||
RegexOptions.Compiled);
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheFourRetailBehaviorFlagsDefaultOn()
|
||||
public void OnlyTheFiveProductBehaviorFlagsDefaultOn()
|
||||
{
|
||||
var defaultOn = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach ((string path, _) in SourceFiles())
|
||||
|
|
|
|||
|
|
@ -101,6 +101,39 @@ public sealed class LiveSessionCommandRouterTests
|
|||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdministrationBindings_UseTheSameActivationAndDisposalGuard()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
ClientCommandController.Bindings client = NewClientBindings() with
|
||||
{
|
||||
Administration = NewAdministrationBindings() with
|
||||
{
|
||||
SetMotd = text => calls.Add("motd:" + text),
|
||||
BootSpecificHouseGuest = name => calls.Add("boot:" + name),
|
||||
},
|
||||
};
|
||||
var router = NewRouter(clientBindings: client);
|
||||
|
||||
Publish();
|
||||
router.Activate();
|
||||
Publish();
|
||||
router.Dispose();
|
||||
Publish();
|
||||
|
||||
Assert.Equal(["motd:Welcome", "boot:Lord Bob"], calls);
|
||||
|
||||
void Publish()
|
||||
{
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.AllegianceMotd,
|
||||
"set Welcome"));
|
||||
router.Publish(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.HouseBoot,
|
||||
"Lord Bob"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InactiveAndDisposedRouter_CannotReachTransport()
|
||||
{
|
||||
|
|
@ -831,6 +864,7 @@ public sealed class LiveSessionCommandRouterTests
|
|||
ToggleFrameRate: () => { },
|
||||
ToggleUiLock: () => { },
|
||||
ShowSystemMessage: _ => { },
|
||||
ShowClientLocalMessage: _ => { },
|
||||
ShowWeenieError: _ => { },
|
||||
PlayerPublicWeenieBitfield: () => null,
|
||||
ClientVersion: () => "test",
|
||||
|
|
@ -879,5 +913,49 @@ public sealed class LiveSessionCommandRouterTests
|
|||
LeaveGmChannel: _ => { },
|
||||
RecallAllegianceHometown: () => { },
|
||||
RequestAllegianceInfo: _ => { },
|
||||
AbandonHouse: () => { });
|
||||
AbandonHouse: () => { },
|
||||
Administration: NewAdministrationBindings(),
|
||||
IsPersistentDaylight: () => false,
|
||||
SetPersistentDaylight: _ => { },
|
||||
SetLandscapeRadius: _ => { },
|
||||
SetFieldOfView: _ => { });
|
||||
|
||||
private static ClientCommandController.AdministrationBindings
|
||||
NewAdministrationBindings() => new(
|
||||
BreakAllegianceBoot: (_, _) => { },
|
||||
AllegianceChatBoot: (_, _) => { },
|
||||
AllegianceChatGag: (_, _) => { },
|
||||
AllegianceBroadcast: _ => { },
|
||||
ListAllegianceBans: () => { },
|
||||
AddAllegianceBan: _ => { },
|
||||
RemoveAllegianceBan: _ => { },
|
||||
ListAllegianceOfficers: () => { },
|
||||
ClearAllegianceOfficers: () => { },
|
||||
SetAllegianceOfficer: (_, _) => { },
|
||||
RemoveAllegianceOfficer: _ => { },
|
||||
ListAllegianceOfficerTitles: () => { },
|
||||
ClearAllegianceOfficerTitles: () => { },
|
||||
SetAllegianceOfficerTitle: (_, _) => { },
|
||||
QueryAllegianceName: () => { },
|
||||
SetAllegianceName: _ => { },
|
||||
ClearAllegianceName: () => { },
|
||||
AllegianceLockAction: _ => { },
|
||||
SetAllegianceApprovedVassal: _ => { },
|
||||
AllegianceHouseAction: _ => { },
|
||||
QueryMotd: () => { },
|
||||
SetMotd: _ => { },
|
||||
ClearMotd: () => { },
|
||||
SetOpenHouseStatus: _ => { },
|
||||
AddPermanentGuest: _ => { },
|
||||
RemovePermanentGuest: _ => { },
|
||||
RemoveAllPermanentGuests: () => { },
|
||||
ChangeStoragePermission: (_, _) => { },
|
||||
AddAllStoragePermission: () => { },
|
||||
RemoveAllStoragePermission: () => { },
|
||||
RequestFullGuestList: () => { },
|
||||
BootSpecificHouseGuest: _ => { },
|
||||
BootEveryone: () => { },
|
||||
SetHooksVisibility: _ => { },
|
||||
ModifyAllegianceGuestPermission: _ => { },
|
||||
ModifyAllegianceStoragePermission: _ => { });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Frozen;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.CharGen;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
|
@ -258,22 +259,26 @@ public sealed class RetailSkillFormulaTests
|
|||
AttributeId attributeId)
|
||||
{
|
||||
const uint skillId = 0x10u;
|
||||
var skillTable = new SkillTable();
|
||||
skillTable.Skills.Add((SkillId)skillId, new SkillBase
|
||||
var options = ChargenOptions.Empty with
|
||||
{
|
||||
Formula = new SkillFormula
|
||||
GlobalSkillDetailsBySkillId = new Dictionary<uint, ChargenSkillDetail>
|
||||
{
|
||||
AdditiveBonus = 0,
|
||||
Attribute1Multiplier = 1,
|
||||
Attribute2Multiplier = 0,
|
||||
Divisor = 1,
|
||||
Attribute1 = attributeId,
|
||||
// Attribute2 deliberately left at its zero default (Strength) —
|
||||
// Attribute2Multiplier=0 means whatever it reads contributes
|
||||
// nothing, so it cannot mask a wrong Attribute1 case.
|
||||
},
|
||||
});
|
||||
var resolver = new ChargenSkillScoreResolver(skillTable);
|
||||
[skillId] = new ChargenSkillDetail(
|
||||
skillId,
|
||||
MinLevel: 1u,
|
||||
Description: string.Empty,
|
||||
new ChargenSkillFormula(
|
||||
AdditiveBonus: 0,
|
||||
Attribute1Multiplier: 1,
|
||||
Attribute2Multiplier: 0,
|
||||
Divisor: 1,
|
||||
Attribute1: (uint)attributeId,
|
||||
// Attribute2Multiplier=0 means the second attribute
|
||||
// cannot mask a wrong Attribute1 mapping.
|
||||
Attribute2: 0u)),
|
||||
}.ToFrozenDictionary(),
|
||||
};
|
||||
var resolver = new ChargenSkillScoreResolver(options);
|
||||
ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42);
|
||||
|
||||
uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained);
|
||||
|
|
@ -281,6 +286,19 @@ public sealed class RetailSkillFormulaTests
|
|||
Assert.Equal(42u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargenSkillScoreResolver_MissingProjectedSkill_ReturnsZero()
|
||||
{
|
||||
var resolver = new ChargenSkillScoreResolver(ChargenOptions.Empty);
|
||||
|
||||
uint result = resolver.Resolve(
|
||||
0x10u,
|
||||
new ChargenAttributeValues(10, 20, 30, 40, 50, 60),
|
||||
ChargenSkillAdvancementClass.Specialized);
|
||||
|
||||
Assert.Equal(0u, result);
|
||||
}
|
||||
|
||||
private static ChargenAttributeValues AttributeValuesWith(AttributeId attributeId, int value) =>
|
||||
attributeId switch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
// ── Scenario 2: landing packet (the preserved rows 2a/2b asymmetry) ─
|
||||
|
||||
[Fact]
|
||||
public void LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved()
|
||||
public void LandingPacket_PlayerGuid_QueueClearedAndShadowPublished()
|
||||
{
|
||||
using var fixture = new Fixture(PlayerGuid);
|
||||
EntityPhysicsHost host = fixture.InstallHost();
|
||||
|
|
@ -253,14 +253,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
// Row 2a, PRESERVED for player guids: the interp queue IS cleared.
|
||||
Assert.False(fixture.Remote.Interp.IsActive);
|
||||
|
||||
// Row 2b / #316, PRESERVED (NOT fixed): the shadow is NOT
|
||||
// republished for a player-guid landing — it stays at whatever it
|
||||
// was before this packet.
|
||||
// #316: the same resolved landing pose must publish to collision in
|
||||
// this packet, not wait for a later physics tick to self-heal.
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
fixture.Shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == fixture.Entity.Id);
|
||||
Assert.Equal(spawnShadowPos, shadowEntry.Position);
|
||||
Assert.NotEqual(landingPos, shadowEntry.Position);
|
||||
Assert.Equal(landingPos, shadowEntry.Position);
|
||||
Assert.NotEqual(spawnShadowPos, shadowEntry.Position);
|
||||
|
||||
// AP-135's cell-adopt bookkeeping still ran (via the post-routing
|
||||
// wire-cell adopt, not suppressed for AirborneSnap).
|
||||
|
|
@ -574,6 +573,26 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
_ = host;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionPackVelocity_DoesNotOverwriteAuthoritativeBodyVelocity()
|
||||
{
|
||||
using var fixture = new Fixture(CreatureGuid);
|
||||
Vector3 authoritativeVectorUpdate = new(4f, 5f, 6f);
|
||||
fixture.Remote.Body.Velocity = authoritativeVectorUpdate;
|
||||
|
||||
// Retail's airborne/non-teleport Position arm performs no placement,
|
||||
// isolating the PositionPack velocity rule from contact resolution.
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
new Vector3(12f, 14f, SpawnHeight + 2f),
|
||||
SourceCell,
|
||||
teleportSequence: 1,
|
||||
guid: CreatureGuid,
|
||||
isGrounded: false,
|
||||
velocity: new Vector3(0.7f, 0.2f, -0.1f)));
|
||||
|
||||
Assert.Equal(authoritativeVectorUpdate, fixture.Remote.Body.Velocity);
|
||||
}
|
||||
|
||||
// ── Sabotage check (contract §5, one-time, manual) ──────────────────
|
||||
//
|
||||
// Performed by hand during implementation, not committed as a test (a
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace AcDream.App.Tests.Physics;
|
|||
|
||||
public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
||||
{
|
||||
private const uint Guid = 0x70000071u;
|
||||
private const uint Guid = 0x50000071u;
|
||||
private const uint SourceCell = 0xA9B40039u;
|
||||
private const uint DestinationCell = 0xAAB40001u;
|
||||
|
||||
|
|
@ -37,7 +37,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = SourceCell,
|
||||
});
|
||||
},
|
||||
isLocalPlayer: true);
|
||||
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
|
||||
|
||||
var remote = new AcDream.Runtime.Physics.RemoteMotion
|
||||
|
|
@ -84,6 +85,17 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
|||
Assert.True(entity.Position.X > 192f);
|
||||
Assert.Equal(entity.Position, record.PhysicsBody.Position);
|
||||
Assert.Equal(epoch, record.ObjectClockEpoch);
|
||||
|
||||
// #320: retiring the landblock the player walked out of must not
|
||||
// sweep the still-live player into a DeferredCell park. Before the
|
||||
// ordinary-cell commit existed, FullCellId remained SourceCell and
|
||||
// this exact retirement selected the player as an affected spatial
|
||||
// root.
|
||||
live.Physics.SetPosition.ParkCollisionResidents(
|
||||
SourceCell,
|
||||
includeOutdoorCells: true);
|
||||
Assert.Equal(DestinationCell, record.FullCellId);
|
||||
Assert.True(live.Physics.IsSpatialRoot(record.Canonical));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -109,4 +109,36 @@ public class InteriorEntityPartitionTests
|
|||
Assert.Empty(result.Dynamics);
|
||||
Assert.Empty(result.OutdoorStatic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrustumRejectsWholeLandblockBeforeWalkingItsEntities()
|
||||
{
|
||||
const uint culledLandblock = 0xA8B4FFFFu;
|
||||
const uint cameraLandblock = 0xA9B4FFFFu;
|
||||
var culled = Ent(10, serverGuid: 0x80000010u, parentCell: OutdoorCell);
|
||||
var camera = Ent(11, serverGuid: 0x80000011u, parentCell: OutdoorCell);
|
||||
var entries = new[]
|
||||
{
|
||||
(culledLandblock,
|
||||
new Vector3(10f),
|
||||
new Vector3(11f),
|
||||
(IReadOnlyList<WorldEntity>)new[] { culled },
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null),
|
||||
(cameraLandblock,
|
||||
new Vector3(10f),
|
||||
new Vector3(11f),
|
||||
(IReadOnlyList<WorldEntity>)new[] { camera },
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null),
|
||||
};
|
||||
|
||||
InteriorEntityPartition.Result result =
|
||||
InteriorEntityPartition.Partition(
|
||||
new HashSet<uint>(),
|
||||
entries,
|
||||
FrustumPlanes.FromViewProjection(Matrix4x4.Identity),
|
||||
neverCullLandblockId: cameraLandblock);
|
||||
|
||||
Assert.DoesNotContain(culled, result.Dynamics);
|
||||
Assert.Equal(camera, Assert.Single(result.Dynamics));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,6 +381,50 @@ public sealed class LiveEntityAnimationPresenterTests
|
|||
Assert.Equal(0.5f, fixture.State.CurrFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyCycle_WrapsAndInterpolatesThroughSharedRetailPlayback()
|
||||
{
|
||||
var fixture = Build(partCount: 1);
|
||||
fixture.State.Sequencer = null;
|
||||
fixture.State.LowFrame = 0;
|
||||
fixture.State.HighFrame = 2;
|
||||
fixture.State.Framerate = 2f;
|
||||
fixture.State.CurrFrame = 2.5f;
|
||||
var animation = new Animation();
|
||||
foreach (float x in new[] { 0f, 10f, 20f })
|
||||
{
|
||||
var frame = new AnimationFrame(1);
|
||||
frame.Frames.Add(new Frame
|
||||
{
|
||||
Origin = new Vector3(x, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
});
|
||||
animation.PartFrames.Add(frame);
|
||||
}
|
||||
fixture.State.Animation = animation;
|
||||
var schedule = new LiveEntityAnimationSchedule(
|
||||
SequenceFrames: null,
|
||||
LegacyAdvanceSeconds: 0.5f,
|
||||
ComposeParts: true,
|
||||
fixture.Record,
|
||||
fixture.Entity,
|
||||
fixture.State,
|
||||
fixture.Record.ObjectClockEpoch,
|
||||
fixture.Record.ProjectionMutationVersion,
|
||||
fixture.State.PresentationRevision);
|
||||
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context());
|
||||
|
||||
presenter.Present(new Dictionary<RuntimeEntityKey, LiveEntityAnimationSchedule>
|
||||
{
|
||||
[fixture.Record.ProjectionKey!.Value] = schedule,
|
||||
});
|
||||
|
||||
// Legacy arithmetic: 2.5 + (0.5 * 2) = 3.5, wrapped over the
|
||||
// inclusive 0..2 span to 0.5, then interpolated halfway 0 -> 10.
|
||||
Assert.Equal(0.5f, fixture.State.CurrFrame);
|
||||
Assert.Equal(new Vector3(5f, 0f, 0f), fixture.Entity.MeshRefs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
private static LiveEntityAnimationPresenter Presenter(
|
||||
LiveEntityRuntime live,
|
||||
EntityEffectPoseRegistry poses,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using AcDream.App.Rendering.Sky;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Sky;
|
||||
|
||||
public sealed class RainAnimationClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnimationPhaseUsesMonotonicStopwatchTicks()
|
||||
{
|
||||
const long start = 1234;
|
||||
|
||||
Assert.Equal(
|
||||
1f,
|
||||
SkyRenderer.ElapsedAnimationSeconds(start, start + Stopwatch.Frequency));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendererDoesNotReadTheAdjustableSystemClock()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.cs"));
|
||||
|
||||
Assert.Contains("Stopwatch.GetTimestamp()", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DateTime.UtcNow", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
|
|
@ -22,20 +23,59 @@ public sealed class SkyPesFrameControllerTests
|
|||
{
|
||||
private const uint AuroraSetup = 0x02000714u;
|
||||
private const uint AuroraScript = 0x330007DBu;
|
||||
private const uint LightningEmitter = 0x320002C2u;
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
private sealed class RecordingHookSink : IAnimationHookSink
|
||||
{
|
||||
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = [];
|
||||
|
||||
public void OnHook(
|
||||
uint entityId,
|
||||
Vector3 entityWorldPosition,
|
||||
AnimationHook hook) =>
|
||||
Calls.Add((entityId, entityWorldPosition, hook));
|
||||
}
|
||||
|
||||
public readonly List<uint> ResolvedScriptIds = [];
|
||||
public readonly List<string> Diagnostics = [];
|
||||
public readonly List<(uint EntityId, Vector3 Position, AnimationHook Hook)> HookCalls;
|
||||
public readonly PhysicsScriptRunner Runner;
|
||||
public readonly SkyPesFrameController Controller;
|
||||
public readonly ParticleSystem Particles;
|
||||
|
||||
public Harness()
|
||||
public Harness(AnimationHook? hook = null, double hookTime = 0.0)
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry, new Random(42));
|
||||
registry.Register(new EmitterDesc
|
||||
{
|
||||
DatId = LightningEmitter,
|
||||
Type = ParticleType.Still,
|
||||
Flags = EmitterFlags.Billboard,
|
||||
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
||||
MaxParticles = 2,
|
||||
InitialParticles = 1,
|
||||
LifetimeMin = 0.01f,
|
||||
LifetimeMax = 0.01f,
|
||||
Lifespan = 0.01f,
|
||||
StartSize = 1f,
|
||||
EndSize = 1f,
|
||||
StartAlpha = 1f,
|
||||
EndAlpha = 1f,
|
||||
Birthrate = 1000f,
|
||||
});
|
||||
Particles = new ParticleSystem(registry, new Random(42));
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var sink = new ParticleHookSink(system, poses);
|
||||
var sink = new ParticleHookSink(Particles, poses)
|
||||
{
|
||||
DiagnosticSink = Diagnostics.Add,
|
||||
};
|
||||
var recording = new RecordingHookSink();
|
||||
HookCalls = recording.Calls;
|
||||
var router = new AnimationHookRouter();
|
||||
router.Register(sink);
|
||||
router.Register(recording);
|
||||
Runner = new PhysicsScriptRunner(
|
||||
id =>
|
||||
{
|
||||
|
|
@ -43,12 +83,12 @@ public sealed class SkyPesFrameControllerTests
|
|||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 0.0,
|
||||
Hook = new SoundHook(),
|
||||
StartTime = hookTime,
|
||||
Hook = hook ?? new SoundHook(),
|
||||
});
|
||||
return script;
|
||||
},
|
||||
sink);
|
||||
router);
|
||||
Controller = new SkyPesFrameController(
|
||||
Runner,
|
||||
sink,
|
||||
|
|
@ -169,4 +209,52 @@ public sealed class SkyPesFrameControllerTests
|
|||
h.Controller.Update(0.1f, null, Vector3.Zero);
|
||||
Assert.Equal(0, h.Runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LightningPartZeroUsesTheLiveCarrierPoseAndStopsOnDayGroupFlip()
|
||||
{
|
||||
var create = new CreateParticleHook
|
||||
{
|
||||
EmitterInfoId = LightningEmitter,
|
||||
EmitterId = 1u,
|
||||
PartIndex = 0u,
|
||||
Offset = new Frame(),
|
||||
};
|
||||
var h = new Harness(create);
|
||||
|
||||
h.Controller.Update(0.1f, Group(Carrier()), new Vector3(4f, 5f, 6f));
|
||||
h.Runner.Tick(0.0);
|
||||
|
||||
Assert.Equal(1, h.Particles.ActiveEmitterCount);
|
||||
Assert.Empty(h.Diagnostics);
|
||||
|
||||
h.Controller.Update(0.1f, null, Vector3.Zero);
|
||||
h.Runner.Tick(1.0);
|
||||
|
||||
Assert.Equal(0, h.Runner.ActiveScriptCount);
|
||||
Assert.Empty(h.Diagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersistentWeatherCarrier_RefreshesSoundAnchorToCurrentCamera()
|
||||
{
|
||||
var sound = new SoundTweakedHook
|
||||
{
|
||||
SoundId = 0x0A00038Bu,
|
||||
Volume = 0.1f,
|
||||
Priority = 1f,
|
||||
};
|
||||
var h = new Harness(sound, hookTime: 1.0);
|
||||
var initialCamera = new Vector3(10f, 20f, 30f);
|
||||
var currentCamera = new Vector3(410f, 520f, 630f);
|
||||
|
||||
h.Controller.Update(0.3f, Group(Carrier()), initialCamera);
|
||||
h.Controller.Update(0.4f, Group(Carrier()), currentCamera);
|
||||
h.Runner.Tick(1.0);
|
||||
|
||||
var call = Assert.Single(h.HookCalls);
|
||||
Assert.Same(sound, call.Hook);
|
||||
Assert.Equal(currentCamera, call.Position);
|
||||
Assert.Single(h.ResolvedScriptIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,25 @@ using AcDream.App.Tests.Rendering.Gpu;
|
|||
using AcDream.Content;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using CullMode = DatReaderWriter.Enums.CullMode;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public class EnvCellRendererTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CullMode.Landblock)]
|
||||
[InlineData(CullMode.None)]
|
||||
[InlineData(CullMode.Clockwise)]
|
||||
[InlineData(CullMode.CounterClockwise)]
|
||||
public void CellShellCullPolicy_UsesRetailConstructedMeshClockwiseCull(
|
||||
CullMode sourceSidesType)
|
||||
{
|
||||
Assert.Equal(
|
||||
CullMode.Clockwise,
|
||||
EnvCellRenderer.ResolveRetailCellShellCullMode(sourceSidesType));
|
||||
}
|
||||
|
||||
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
||||
{
|
||||
public PreparedAssetSourceStats Stats => default;
|
||||
|
|
|
|||
|
|
@ -300,6 +300,56 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
Assert.Contains(hiddenLight, lighting.PointSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersistentAtDay_UsesNoonLandscapeLightingWithoutChangingTheSkyClock()
|
||||
{
|
||||
SkyStateProvider sky = SkyStateProvider.Default();
|
||||
var clock = new WorldTimeService(sky);
|
||||
clock.PinnedDayFraction = 0f;
|
||||
var lighting = new LightManager();
|
||||
bool persistentDaylight = true;
|
||||
var environment = new RuntimeWorldFrameEnvironmentPreparation(
|
||||
RuntimeOptions.Parse("test-dat", _ => null),
|
||||
clock,
|
||||
lighting,
|
||||
dispatcher: null,
|
||||
environmentCells: null,
|
||||
lightingUbo: null,
|
||||
new WorldRenderRangeState(4, 12),
|
||||
skyPes: null,
|
||||
persistentDaylight: () => persistentDaylight);
|
||||
WorldCameraFrame camera = CameraFrame(new FlyCamera());
|
||||
WorldRootFrame roots = default;
|
||||
SkyKeyframe midnight = sky.Interpolate(0f);
|
||||
SkyKeyframe noon = sky.Interpolate(0.5f);
|
||||
var foundation = new RenderFrameFoundation(
|
||||
PortalViewportVisible: false,
|
||||
Sky: midnight,
|
||||
Atmosphere: default);
|
||||
|
||||
environment.Prepare(
|
||||
in camera,
|
||||
in roots,
|
||||
in foundation,
|
||||
activeDayGroup: null);
|
||||
|
||||
Assert.Equal(noon.AmbientColor, lighting.CurrentAmbient.AmbientColor);
|
||||
Assert.NotNull(lighting.Sun);
|
||||
Assert.Equal(noon.SunColor, lighting.Sun!.ColorLinear);
|
||||
Assert.Equal(0d, clock.DayFraction, precision: 5);
|
||||
|
||||
persistentDaylight = false;
|
||||
environment.Prepare(
|
||||
in camera,
|
||||
in roots,
|
||||
in foundation,
|
||||
activeDayGroup: null);
|
||||
|
||||
Assert.Equal(midnight.AmbientColor, lighting.CurrentAmbient.AmbientColor);
|
||||
Assert.NotNull(lighting.Sun);
|
||||
Assert.Equal(midnight.SunColor, lighting.Sun!.ColorLinear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ namespace AcDream.App.Tests;
|
|||
public class RuntimeOptionsRetailUiTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_ReadsRetailUiAndAcDir()
|
||||
public void Parse_RetailUiIsDefaultOnAndReadsAcDir()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["ACDREAM_RETAIL_UI"] = "1",
|
||||
["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call",
|
||||
};
|
||||
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
|
||||
|
|
@ -19,13 +18,23 @@ public class RuntimeOptionsRetailUiTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DefaultsRetailUiOffAndAcDirNull()
|
||||
public void Parse_DefaultsRetailUiOnAndAcDirNull()
|
||||
{
|
||||
var opts = RuntimeOptions.Parse("dats", _ => null);
|
||||
Assert.False(opts.RetailUi);
|
||||
Assert.True(opts.RetailUi);
|
||||
Assert.Null(opts.AcDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LiteralZeroOptsOutOfRetailUi()
|
||||
{
|
||||
var opts = RuntimeOptions.Parse(
|
||||
"dats",
|
||||
key => key == "ACDREAM_RETAIL_UI" ? "0" : null);
|
||||
|
||||
Assert.False(opts.RetailUi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ReadsUiProbeOptions()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,40 @@ namespace AcDream.App.Tests.Settings;
|
|||
|
||||
public sealed class RuntimeSettingsControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(3, 3, 3)]
|
||||
[InlineData(5, 4, 5)]
|
||||
[InlineData(8, 4, 8)]
|
||||
[InlineData(12, 4, 12)]
|
||||
[InlineData(25, 4, 25)]
|
||||
public void LandscapeDrawDistance_IsTheRetailFarRadiusValue(
|
||||
int value,
|
||||
int expectedNear,
|
||||
int expectedFar)
|
||||
{
|
||||
QualitySettings result = RuntimeSettingsController
|
||||
.ApplyLandscapeDrawDistance(
|
||||
QualitySettings.From(QualityPreset.High),
|
||||
value);
|
||||
|
||||
Assert.Equal(expectedNear, result.NearRadius);
|
||||
Assert.Equal(expectedFar, result.FarRadius);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
[InlineData(26)]
|
||||
public void InvalidStoredLandscapeDistance_PreservesThePreset(int value)
|
||||
{
|
||||
QualitySettings original = QualitySettings.From(QualityPreset.High);
|
||||
|
||||
QualitySettings result = RuntimeSettingsController
|
||||
.ApplyLandscapeDrawDistance(original, value);
|
||||
|
||||
Assert.Equal(original, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionLoadsEachBagOnceAndPublishesOneStartupSnapshot()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -552,11 +553,355 @@ public sealed class ClientCommandControllerTests
|
|||
Assert.Equal("my chat log.txt", match.Arguments);
|
||||
}
|
||||
|
||||
// ── #361: retail's @day / @render ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Day_TogglesThePersistentOptionAndPrintsRetailsExactLines()
|
||||
{
|
||||
bool persistentDaylight = false;
|
||||
var values = new List<bool>();
|
||||
var messages = new List<string>();
|
||||
ClientCommandController controller = NewController(
|
||||
messages: messages,
|
||||
isPersistentDaylight: () => persistentDaylight,
|
||||
setPersistentDaylight: value =>
|
||||
{
|
||||
persistentDaylight = value;
|
||||
values.Add(value);
|
||||
});
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.TogglePersistentDaylight,
|
||||
"ignored exactly like retail"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.TogglePersistentDaylight,
|
||||
string.Empty));
|
||||
|
||||
Assert.Equal([true, false], values);
|
||||
Assert.Equal(
|
||||
["Let there be light!", "Normality has been restored."],
|
||||
messages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("radius 5", 5)]
|
||||
[InlineData("RADIUS 25 extra ignored", 25)]
|
||||
[InlineData("radius 12suffix", 12)]
|
||||
public void RenderRadius_AcceptsRetailRangeAndAtoiPrefix(
|
||||
string arguments,
|
||||
int expected)
|
||||
{
|
||||
var radii = new List<int>();
|
||||
var messages = new List<string>();
|
||||
ClientCommandController controller = NewController(
|
||||
messages: messages,
|
||||
setLandscapeRadius: radii.Add);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.RenderOption,
|
||||
arguments));
|
||||
|
||||
Assert.Equal([expected], radii);
|
||||
Assert.Equal(["Landscape radius set"], messages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fov 10", 10f)]
|
||||
[InlineData("FOV 160 extra", 160f)]
|
||||
[InlineData("fov 91degrees", 91f)]
|
||||
public void RenderFov_AcceptsRetailRangeAndAtoiPrefix(
|
||||
string arguments,
|
||||
float expected)
|
||||
{
|
||||
var values = new List<float>();
|
||||
var messages = new List<string>();
|
||||
ClientCommandController controller = NewController(
|
||||
messages: messages,
|
||||
setFieldOfView: values.Add);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.RenderOption,
|
||||
arguments));
|
||||
|
||||
Assert.Equal([expected], values);
|
||||
Assert.Equal(["Field of view set"], messages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("radius", "Must specify a radius")]
|
||||
[InlineData("radius 4", "Radius must be between 5 and 25")]
|
||||
[InlineData("radius nope", "Radius must be between 5 and 25")]
|
||||
[InlineData("fov", "Must specify a field of view")]
|
||||
[InlineData("fov 161", "Field of view must be between 10 and 160")]
|
||||
public void Render_InvalidValuesPrintRetailsExactReply(
|
||||
string arguments,
|
||||
string expected)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
ClientCommandController controller = NewController(
|
||||
calls,
|
||||
messages: messages);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.RenderOption,
|
||||
arguments));
|
||||
|
||||
Assert.DoesNotContain(calls, call =>
|
||||
call.StartsWith("radius:", StringComparison.Ordinal)
|
||||
|| call.StartsWith("fov:", StringComparison.Ordinal));
|
||||
Assert.Equal([expected], messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_UsageAndUnknownOptionMatchRetail()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
ClientCommandController controller = NewController(messages: messages);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.RenderOption,
|
||||
string.Empty));
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.RenderOption,
|
||||
"usage"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.RenderOption,
|
||||
"unknown 1"));
|
||||
|
||||
string usage = RetailCommandHelpTable.Render.TrimEnd('\n');
|
||||
Assert.Equal([usage, usage], messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllegianceAdministration_ExecutesEveryRetailDispatcherBranch()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var system = new List<string>();
|
||||
var clientLocal = new List<string>();
|
||||
ClientCommandController controller = NewController(
|
||||
calls: calls,
|
||||
messages: system,
|
||||
clientLocalMessages: clientLocal);
|
||||
|
||||
Execute(ClientCommandId.AllegianceInfo, "Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceBoot, "Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceBoot, "-account Account Bob");
|
||||
// Retail validates non-empty BEFORE removing -account, so this odd
|
||||
// form deliberately sends an empty name with accountBoot=true.
|
||||
Execute(ClientCommandId.AllegianceBoot, "-account");
|
||||
Execute(ClientCommandId.AllegianceBan, "list ignored");
|
||||
Execute(ClientCommandId.AllegianceBan, "add Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceBan, "remove Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceChat, "on");
|
||||
Execute(ClientCommandId.AllegianceChat, "off");
|
||||
Execute(ClientCommandId.AllegianceChat, "kick Bob");
|
||||
Execute(ClientCommandId.AllegianceChat, "kick Bob, Bad manners");
|
||||
Execute(ClientCommandId.AllegianceChat, "kick ");
|
||||
Execute(ClientCommandId.AllegianceChat, "gag Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceChat, "ungag Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceBroadcast, "Hear ye");
|
||||
Execute(ClientCommandId.AllegianceOfficer, "");
|
||||
Execute(ClientCommandId.AllegianceOfficer, "clear ignored");
|
||||
Execute(ClientCommandId.AllegianceOfficer, "remove Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceOfficer, "add 0x2 Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceOfficer, "set 03 Aunt Alice");
|
||||
Execute(ClientCommandId.AllegianceOfficerTitle, "");
|
||||
Execute(ClientCommandId.AllegianceOfficerTitle, "clear ignored");
|
||||
Execute(ClientCommandId.AllegianceOfficerTitle, "set 0x2 High Regent");
|
||||
Execute(ClientCommandId.AllegianceOfficerTitle, "set 1");
|
||||
Execute(ClientCommandId.AllegianceName, "");
|
||||
Execute(ClientCommandId.AllegianceName, "set The Best Allegiance");
|
||||
Execute(ClientCommandId.AllegianceName, "set");
|
||||
Execute(ClientCommandId.AllegianceName, "clear ignored");
|
||||
Execute(ClientCommandId.AllegianceLock, "");
|
||||
Execute(ClientCommandId.AllegianceLock, "off");
|
||||
Execute(ClientCommandId.AllegianceLock, "on");
|
||||
Execute(ClientCommandId.AllegianceLock, "toggle");
|
||||
Execute(ClientCommandId.AllegianceLock, "check");
|
||||
Execute(ClientCommandId.AllegianceLock, "bypass");
|
||||
Execute(ClientCommandId.AllegianceLock, "bypass clear");
|
||||
Execute(ClientCommandId.AllegianceLock, "bypass Lord Bob");
|
||||
Execute(ClientCommandId.AllegianceHouse, "");
|
||||
Execute(ClientCommandId.AllegianceHouse, "guest open");
|
||||
Execute(ClientCommandId.AllegianceHouse, "guest close");
|
||||
Execute(ClientCommandId.AllegianceHouse, "storage open");
|
||||
Execute(ClientCommandId.AllegianceHouse, "storage close");
|
||||
Execute(ClientCommandId.AllegianceMotd, "");
|
||||
Execute(ClientCommandId.AllegianceMotd, "set Welcome everyone");
|
||||
Execute(ClientCommandId.AllegianceMotd, "set");
|
||||
Execute(ClientCommandId.AllegianceMotd, "clear ignored");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"alleginfo:Lord Bob",
|
||||
"allegboot:Lord Bob:False",
|
||||
"allegboot:Account Bob:True",
|
||||
"allegboot::True",
|
||||
"allegban:list",
|
||||
"allegban:add:Lord Bob",
|
||||
"allegban:remove:Lord Bob",
|
||||
"charoption:27:True",
|
||||
"charoption:27:False",
|
||||
"allegchatboot:Bob:No reason given.",
|
||||
"allegchatboot:Bob:Bad manners",
|
||||
"allegchatboot::No reason given.",
|
||||
"allegchatgag:Lord Bob:True",
|
||||
"allegchatgag:Lord Bob:False",
|
||||
"allegbroadcast:Hear ye",
|
||||
"allegofficer:list",
|
||||
"allegofficer:clear",
|
||||
"allegofficer:remove:Lord Bob",
|
||||
"allegofficer:set:2:Lord Bob",
|
||||
"allegofficer:set:3:Aunt Alice",
|
||||
"allegtitle:list",
|
||||
"allegtitle:clear",
|
||||
"allegtitle:set:2:High Regent",
|
||||
"allegtitle:set:1:",
|
||||
"allegname:query",
|
||||
"allegname:set:The Best Allegiance",
|
||||
"allegname:set:",
|
||||
"allegname:clear",
|
||||
"alleglock:4",
|
||||
"alleglock:1",
|
||||
"alleglock:2",
|
||||
"alleglock:3",
|
||||
"alleglock:4",
|
||||
"alleglock:5",
|
||||
"alleglock:6",
|
||||
"alleglock:bypass:Lord Bob",
|
||||
"alleghouse:1",
|
||||
"alleghouse:2",
|
||||
"alleghouse:3",
|
||||
"alleghouse:4",
|
||||
"alleghouse:5",
|
||||
"motd:query",
|
||||
"motd:set:Welcome everyone",
|
||||
"motd:set:",
|
||||
"motd:clear",
|
||||
],
|
||||
calls);
|
||||
Assert.Equal(
|
||||
[
|
||||
"Attempting to boot Lord Bob...",
|
||||
"Attempting to boot Account Bob (Account)...",
|
||||
"Attempting to boot (Account)...",
|
||||
],
|
||||
system);
|
||||
Assert.Empty(clientLocal);
|
||||
|
||||
void Execute(ClientCommandId command, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HouseAdministration_ExecutesEveryRetailDispatcherBranch()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
ClientCommandController controller = NewController(calls: calls);
|
||||
|
||||
Execute(ClientCommandId.HouseOpenStatus, "open");
|
||||
Execute(ClientCommandId.HouseOpenStatus, "close");
|
||||
Execute(ClientCommandId.HouseGuests, "add Lord Bob");
|
||||
Execute(ClientCommandId.HouseGuests, "remove Lord Bob");
|
||||
Execute(ClientCommandId.HouseGuests, "remove_all ignored");
|
||||
Execute(ClientCommandId.HouseGuests, "list ignored");
|
||||
Execute(ClientCommandId.HouseGuests, "show ignored");
|
||||
Execute(ClientCommandId.HouseGuests, "add_allegiance ignored");
|
||||
Execute(ClientCommandId.HouseGuests, "remove_allegiance ignored");
|
||||
Execute(ClientCommandId.HouseStorage, "add Lord Bob");
|
||||
Execute(ClientCommandId.HouseStorage, "remove Lord Bob");
|
||||
Execute(ClientCommandId.HouseStorage, "add -all");
|
||||
Execute(ClientCommandId.HouseStorage, "remove -all");
|
||||
Execute(ClientCommandId.HouseStorage, "remove_all ignored");
|
||||
Execute(ClientCommandId.HouseStorage, "list ignored");
|
||||
Execute(ClientCommandId.HouseStorage, "show ignored");
|
||||
Execute(ClientCommandId.HouseStorage, "add_allegiance ignored");
|
||||
Execute(ClientCommandId.HouseStorage, "remove_allegiance ignored");
|
||||
Execute(ClientCommandId.HouseBoot, "Lord Bob");
|
||||
Execute(ClientCommandId.HouseBoot, "-all");
|
||||
Execute(ClientCommandId.HouseBootAll, "ignored");
|
||||
Execute(ClientCommandId.HouseHooks, "on ignored");
|
||||
Execute(ClientCommandId.HouseHooks, "off ignored");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"houseopen:True",
|
||||
"houseopen:False",
|
||||
"houseguest:add:Lord Bob",
|
||||
"houseguest:remove:Lord Bob",
|
||||
"houseguest:remove_all",
|
||||
"houseguest:list",
|
||||
"houseguest:list",
|
||||
"houseguest:allegiance:True",
|
||||
"houseguest:allegiance:False",
|
||||
"housestorage:True:Lord Bob",
|
||||
"housestorage:False:Lord Bob",
|
||||
"housestorage:add_all",
|
||||
"housestorage:remove_all",
|
||||
"housestorage:remove_all",
|
||||
"houseguest:list",
|
||||
"houseguest:list",
|
||||
"housestorage:allegiance:True",
|
||||
"housestorage:allegiance:False",
|
||||
"houseboot:Lord Bob",
|
||||
"houseboot:all",
|
||||
"houseboot:all",
|
||||
"househooks:True",
|
||||
"househooks:False",
|
||||
],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId command, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ClientCommandId.AllegianceInfo, "", "Please specify an actual name.")]
|
||||
[InlineData(ClientCommandId.AllegianceBoot, "", "Please specify an actual name.")]
|
||||
[InlineData(ClientCommandId.AllegianceBan, "add", "Please specify an actual name.")]
|
||||
[InlineData(ClientCommandId.AllegianceChat, "gag", "Please specify an actual name.")]
|
||||
[InlineData(ClientCommandId.AllegianceBroadcast, "", "Please see @help Allegiance for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.AllegianceOfficer, "remove", "Please specify the name of an allegiance member.")]
|
||||
[InlineData(ClientCommandId.AllegianceOfficer, "add nope Bob", "Please specify a valid officer level as a number between 1 and 3. Check the game help files for more information on officer levels.")]
|
||||
[InlineData(ClientCommandId.AllegianceOfficer, "add 2", "Please specify the name of an allegiance member.")]
|
||||
[InlineData(ClientCommandId.AllegianceOfficerTitle, "set 4 Regent", "Please specify a valid officer level as a number between 1 and 3.")]
|
||||
[InlineData(ClientCommandId.AllegianceName, "nope", "Please see @help Allegiance for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.AllegianceLock, "nope", "Please see @help Allegiance for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.AllegianceHouse, "guest nope", "Please see @help Allegiance for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.AllegianceMotd, "nope", "Please see @help Allegiance for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.AllegianceUnrecognizedSubcommand, "nope", "Please see @help Allegiance for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.HouseGuests, "add", "Please specify the guest's name.")]
|
||||
[InlineData(ClientCommandId.HouseStorage, "add", "Please specify an actual name.")]
|
||||
[InlineData(ClientCommandId.HouseBoot, "", "Please see @help House for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.HouseHooks, "maybe", "Please see @help House for more information on how to use this command.")]
|
||||
[InlineData(ClientCommandId.HouseUnrecognizedSubcommand, "nope", "Please see @help House for more information on how to use this command.")]
|
||||
public void AdministrationInvalidForms_EmitExactRetailClientLocalText(
|
||||
ClientCommandId command,
|
||||
string arguments,
|
||||
string expected)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var system = new List<string>();
|
||||
var clientLocal = new List<string>();
|
||||
ClientCommandController controller = NewController(
|
||||
calls: calls,
|
||||
messages: system,
|
||||
clientLocalMessages: clientLocal);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.Empty(system);
|
||||
Assert.Equal([expected], clientLocal);
|
||||
}
|
||||
|
||||
private static ClientCommandController NewController(
|
||||
List<string>? calls = null,
|
||||
List<uint>? errors = null,
|
||||
uint? playerBitfield = 0x02000028u,
|
||||
List<string>? messages = null,
|
||||
List<string>? clientLocalMessages = null,
|
||||
bool isAway = false,
|
||||
bool acceptsLootPermits = true,
|
||||
FriendsState? friends = null,
|
||||
|
|
@ -569,11 +914,16 @@ public sealed class ClientCommandControllerTests
|
|||
// Defaults to "always accept" so every pre-existing single-stage
|
||||
// test (Die, etc.) keeps its original behavior unchanged.
|
||||
Queue<bool>? confirmationResponses = null,
|
||||
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
|
||||
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null,
|
||||
Func<bool>? isPersistentDaylight = null,
|
||||
Action<bool>? setPersistentDaylight = null,
|
||||
Action<int>? setLandscapeRadius = null,
|
||||
Action<float>? setFieldOfView = null)
|
||||
{
|
||||
calls ??= [];
|
||||
errors ??= [];
|
||||
messages ??= [];
|
||||
clientLocalMessages ??= messages;
|
||||
return new ClientCommandController(new ClientCommandController.Bindings(
|
||||
() => calls.Add("ls"),
|
||||
() => calls.Add("mp"),
|
||||
|
|
@ -586,6 +936,7 @@ public sealed class ClientCommandControllerTests
|
|||
() => calls.Add("fps"),
|
||||
() => calls.Add("lock"),
|
||||
messages.Add,
|
||||
clientLocalMessages.Add,
|
||||
errors.Add,
|
||||
() => playerBitfield,
|
||||
() => "1.2.3",
|
||||
|
|
@ -653,6 +1004,61 @@ public sealed class ClientCommandControllerTests
|
|||
channelId => calls.Add("off:" + channelId),
|
||||
() => calls.Add("alh"),
|
||||
name => calls.Add("alleginfo:" + name),
|
||||
() => calls.Add("houseabandon")));
|
||||
() => calls.Add("houseabandon"),
|
||||
NewAdministrationBindings(calls),
|
||||
isPersistentDaylight ?? (() => false),
|
||||
setPersistentDaylight ?? (value => calls.Add("day:" + value)),
|
||||
setLandscapeRadius ?? (value => calls.Add("radius:" + value)),
|
||||
setFieldOfView ?? (value => calls.Add(
|
||||
"fov:" + value.ToString(CultureInfo.InvariantCulture)))));
|
||||
}
|
||||
|
||||
private static ClientCommandController.AdministrationBindings
|
||||
NewAdministrationBindings(List<string> calls) => new(
|
||||
BreakAllegianceBoot: (name, account) =>
|
||||
calls.Add($"allegboot:{name}:{account}"),
|
||||
AllegianceChatBoot: (name, reason) =>
|
||||
calls.Add($"allegchatboot:{name}:{reason}"),
|
||||
AllegianceChatGag: (name, enabled) =>
|
||||
calls.Add($"allegchatgag:{name}:{enabled}"),
|
||||
AllegianceBroadcast: text => calls.Add("allegbroadcast:" + text),
|
||||
ListAllegianceBans: () => calls.Add("allegban:list"),
|
||||
AddAllegianceBan: name => calls.Add("allegban:add:" + name),
|
||||
RemoveAllegianceBan: name => calls.Add("allegban:remove:" + name),
|
||||
ListAllegianceOfficers: () => calls.Add("allegofficer:list"),
|
||||
ClearAllegianceOfficers: () => calls.Add("allegofficer:clear"),
|
||||
SetAllegianceOfficer: (name, level) =>
|
||||
calls.Add($"allegofficer:set:{level}:{name}"),
|
||||
RemoveAllegianceOfficer: name =>
|
||||
calls.Add("allegofficer:remove:" + name),
|
||||
ListAllegianceOfficerTitles: () => calls.Add("allegtitle:list"),
|
||||
ClearAllegianceOfficerTitles: () => calls.Add("allegtitle:clear"),
|
||||
SetAllegianceOfficerTitle: (level, title) =>
|
||||
calls.Add($"allegtitle:set:{level}:{title}"),
|
||||
QueryAllegianceName: () => calls.Add("allegname:query"),
|
||||
SetAllegianceName: name => calls.Add("allegname:set:" + name),
|
||||
ClearAllegianceName: () => calls.Add("allegname:clear"),
|
||||
AllegianceLockAction: action => calls.Add("alleglock:" + action),
|
||||
SetAllegianceApprovedVassal: name =>
|
||||
calls.Add("alleglock:bypass:" + name),
|
||||
AllegianceHouseAction: action => calls.Add("alleghouse:" + action),
|
||||
QueryMotd: () => calls.Add("motd:query"),
|
||||
SetMotd: text => calls.Add("motd:set:" + text),
|
||||
ClearMotd: () => calls.Add("motd:clear"),
|
||||
SetOpenHouseStatus: open => calls.Add("houseopen:" + open),
|
||||
AddPermanentGuest: name => calls.Add("houseguest:add:" + name),
|
||||
RemovePermanentGuest: name => calls.Add("houseguest:remove:" + name),
|
||||
RemoveAllPermanentGuests: () => calls.Add("houseguest:remove_all"),
|
||||
ChangeStoragePermission: (name, enabled) =>
|
||||
calls.Add($"housestorage:{enabled}:{name}"),
|
||||
AddAllStoragePermission: () => calls.Add("housestorage:add_all"),
|
||||
RemoveAllStoragePermission: () => calls.Add("housestorage:remove_all"),
|
||||
RequestFullGuestList: () => calls.Add("houseguest:list"),
|
||||
BootSpecificHouseGuest: name => calls.Add("houseboot:" + name),
|
||||
BootEveryone: () => calls.Add("houseboot:all"),
|
||||
SetHooksVisibility: visible => calls.Add("househooks:" + visible),
|
||||
ModifyAllegianceGuestPermission: enabled =>
|
||||
calls.Add("houseguest:allegiance:" + enabled),
|
||||
ModifyAllegianceStoragePermission: enabled =>
|
||||
calls.Add("housestorage:allegiance:" + enabled));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1059,8 +1059,8 @@ public sealed class CharacterCreationLiveDatTests
|
|||
/// GF-13: the BEHAVIOR half — after the real controller mounts through
|
||||
/// <see cref="CharacterCreationUiController.CreateDetached"/> (not a raw
|
||||
/// <see cref="LayoutImporter.Build"/> call), the two authored-invisible
|
||||
/// elements are not <see cref="UiElement.Visible"/>. Exercises
|
||||
/// <c>HideAuthoredInvisibleElements</c>'s real chargen-scoped honor path,
|
||||
/// elements are not <see cref="UiElement.Visible"/>. Exercises the shared
|
||||
/// importer-wide #408 behavior through a real chargen controller mount,
|
||||
/// not just the data plumbing the sibling test above pins.
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ public sealed class CharacterCreationUiControllerTests
|
|||
/// Center default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap()
|
||||
public void SkillsPage_InfoBoxPanes_InheritRetailTopDefault_ToAvoidTitleDescriptionOverlap()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Controller.Open();
|
||||
|
|
|
|||
|
|
@ -168,6 +168,31 @@ public sealed class CharacterManagementUiControllerTests
|
|||
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreditsButton_QueuesTheCreditsMode_AndPresentationCanBeSuppressedForIt()
|
||||
{
|
||||
int openCalls = 0;
|
||||
using var environment = new EnvironmentHarness(() => openCalls++);
|
||||
CharacterManagementUiController controller = environment.Controller;
|
||||
UiButton credits = environment.Button(
|
||||
CharacterManagementUiController.CreditsElementId);
|
||||
|
||||
Assert.True(credits.Visible);
|
||||
Assert.True(credits.Enabled);
|
||||
Assert.NotNull(credits.OnClick);
|
||||
credits.OnClick!();
|
||||
Assert.Equal(1, openCalls);
|
||||
|
||||
controller.SetPresentationSuppressed(true);
|
||||
Assert.False(controller.Root.Visible);
|
||||
Assert.Null(environment.Host.FixedCanvasSize);
|
||||
|
||||
controller.SetPresentationSuppressed(false);
|
||||
Assert.True(controller.Root.Visible);
|
||||
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
|
||||
Assert.Equal(3, controller.Rows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
|
||||
{
|
||||
|
|
@ -882,7 +907,7 @@ public sealed class CharacterManagementUiControllerTests
|
|||
|
||||
private sealed class EnvironmentHarness : IDisposable
|
||||
{
|
||||
public EnvironmentHarness()
|
||||
public EnvironmentHarness(Action? openCredits = null)
|
||||
{
|
||||
Host = new UiRoot { Width = 800f, Height = 600f };
|
||||
Screen = BuildScreen();
|
||||
|
|
@ -901,7 +926,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
static (_, _) => BuildRow(),
|
||||
Dialogs,
|
||||
Runtime.Bindings,
|
||||
TestStrings()));
|
||||
TestStrings(),
|
||||
openCredits));
|
||||
}
|
||||
|
||||
public UiRoot Host { get; }
|
||||
|
|
|
|||
|
|
@ -1115,7 +1115,7 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
2, // 19 Landscape Texture Detail
|
||||
1, // 20 Environment Texture Detail
|
||||
1, // 21 Texture Filtering
|
||||
8, // 22 Landscape Draw Distance (opaque — AP-198 sub-note)
|
||||
8, // 22 Landscape Draw Distance (retail radius payload)
|
||||
true, // 23 Building Detail Textures
|
||||
false, // 24 Multi-Pass Alpha
|
||||
|
||||
|
|
@ -1418,7 +1418,7 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
|
||||
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
|
||||
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
|
||||
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
|
||||
(26, RowKind.Menu, false, "Landscape Draw Distance"), // LIVE — #361
|
||||
(27, RowKind.Toggle, false, "Building Detail Textures"), // LIVE — #226
|
||||
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
|
||||
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
|
||||
|
|
@ -1485,6 +1485,27 @@ public sealed class ConfigOptionsPageControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandscapeDrawDistance_UsesRetailRadiusPayloads_AndAppliesSelection()
|
||||
{
|
||||
(OptionsPanelController controller, FakeBindings bindings, bool bound) = BindReal();
|
||||
Assert.True(bound);
|
||||
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
List<UiMenu> menus = CollectMenus(configSlot);
|
||||
UiMenu drawDistance = menus[5];
|
||||
|
||||
Assert.Equal(8, drawDistance.Selected);
|
||||
Assert.Equal(
|
||||
[3, 5, 8, 11, 15, 25],
|
||||
drawDistance.Items.Select(item => Assert.IsType<int>(item.Payload)).ToArray());
|
||||
|
||||
drawDistance.OnSelect!(25);
|
||||
controller.ConfigPage.Apply();
|
||||
|
||||
Assert.Equal(25, bindings.Display.LandscapeDrawDistance);
|
||||
}
|
||||
|
||||
// ── #412-class regression: Config tab content escaping the window frame ──
|
||||
//
|
||||
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's
|
||||
|
|
|
|||
110
tests/AcDream.App.Tests/UI/Layout/CreditsLiveDatTests.cs
Normal file
110
tests/AcDream.App.Tests/UI/Layout/CreditsLiveDatTests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Installed-retail-DAT oracle for issue #400's <c>gmCreditsUI</c> port.
|
||||
/// Retail creates two independent enum-table-5 roots: the picture strip
|
||||
/// and the scrolling text field.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class CreditsLiveDatTests
|
||||
{
|
||||
[InstalledDatFact]
|
||||
public void Category4_ResolvesAuthoredPictureAndTextRoots()
|
||||
{
|
||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
|
||||
|
||||
// CreateAndAddRootElement(layoutEnum=0x10000004, rootElementId)
|
||||
// uses the ordinary table-5 layout map, exactly like character
|
||||
// management's (0x10000005, 0x1000039A) pair.
|
||||
uint pictureLayout = RetailDataIdResolver.Resolve(dats, 0x10000004u, 5u);
|
||||
Assert.Equal(0x21000003u, pictureLayout);
|
||||
|
||||
ElementInfo picture = Assert.IsType<ElementInfo>(
|
||||
LayoutImporter.ImportInfos(dats, pictureLayout, 0x10000413u));
|
||||
ElementInfo text = Assert.IsType<ElementInfo>(
|
||||
LayoutImporter.ImportInfos(dats, pictureLayout, 0x10000410u));
|
||||
|
||||
Assert.Equal(CreditsUiController.PictureRootElementId, picture.Id);
|
||||
Assert.Equal(3u, picture.Type);
|
||||
Assert.Equal((0f, 0f, 400f, 600f),
|
||||
(picture.X, picture.Y, picture.Width, picture.Height));
|
||||
Assert.Equal(CreditsUiController.TextRootElementId, text.Id);
|
||||
Assert.Equal(3u, text.Type);
|
||||
Assert.Equal((400f, 0f, 400f, 600f),
|
||||
(text.X, text.Y, text.Width, text.Height));
|
||||
|
||||
Assert.True(TryDataId(text, 0x10000002u, out uint textAreaId));
|
||||
Assert.Equal(CreditsUiController.TextAreaElementId, textAreaId);
|
||||
Assert.True(TryDataId(text, 0x10000003u, out uint stringTableId));
|
||||
Assert.Equal(0x23000008u, stringTableId);
|
||||
Assert.True(text.TryGetEffectiveFloat(0x10000004u, out float seconds));
|
||||
Assert.Equal(20f, seconds);
|
||||
|
||||
ElementInfo textArea = Assert.Single(text.Children);
|
||||
Assert.Equal(CreditsUiController.TextAreaElementId, textArea.Id);
|
||||
Assert.Equal(12u, textArea.Type);
|
||||
Assert.Equal(0x40000000u, textArea.FontDid);
|
||||
Assert.Equal(HJustify.Center, textArea.HJustify);
|
||||
Assert.Equal(VJustify.Center, textArea.VJustify);
|
||||
|
||||
Assert.True(picture.TryGetEffectiveProperty(
|
||||
0x10000005u,
|
||||
out UiPropertyValue pictureArray));
|
||||
Assert.Equal(UiPropertyKind.Array, pictureArray.Kind);
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, 7).Select(index => 0x06005F14u + (uint)index),
|
||||
pictureArray.ArrayValue.Select(static value => (uint)value.UnsignedValue));
|
||||
|
||||
ImportedLayout builtText = Assert.IsType<ImportedLayout>(
|
||||
LayoutImporter.Import(
|
||||
dats,
|
||||
pictureLayout,
|
||||
CreditsUiController.TextRootElementId,
|
||||
static id => (id, 1, 1),
|
||||
null));
|
||||
Assert.IsType<UiText>(builtText.FindElement(
|
||||
CreditsUiController.TextAreaElementId));
|
||||
|
||||
var strings = new DatStringResolver(dats);
|
||||
int creditsCount = 0;
|
||||
for (int i = 1; i <= 4096; i++)
|
||||
{
|
||||
string? value = strings.Resolve(
|
||||
stringTableId,
|
||||
DatStringResolver.ComputeHash($"ID_Credits{i}"));
|
||||
if (value is null)
|
||||
break;
|
||||
creditsCount++;
|
||||
}
|
||||
Assert.Equal(2345, creditsCount);
|
||||
Assert.NotNull(strings.Resolve(
|
||||
0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_Wait_PleaseWait")));
|
||||
}
|
||||
|
||||
private static bool TryDataId(
|
||||
ElementInfo info,
|
||||
uint propertyId,
|
||||
out uint value)
|
||||
{
|
||||
if (info.TryGetEffectiveProperty(propertyId, out UiPropertyValue property)
|
||||
&& property.Kind is UiPropertyKind.DataId or UiPropertyKind.Enum)
|
||||
{
|
||||
value = checked((uint)property.UnsignedValue);
|
||||
return true;
|
||||
}
|
||||
value = 0u;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
166
tests/AcDream.App.Tests/UI/Layout/CreditsUiControllerTests.cs
Normal file
166
tests/AcDream.App.Tests/UI/Layout/CreditsUiControllerTests.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class CreditsUiControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Activate_UsesAuthoredCanvas_TextTiming_AndCyclicPictureStrip()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CreditsUiController controller = environment.Controller;
|
||||
|
||||
controller.Activate();
|
||||
|
||||
Assert.True(controller.IsActive);
|
||||
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
|
||||
Assert.True(controller.PictureRoot.Visible);
|
||||
Assert.True(controller.TextRoot.Visible);
|
||||
Assert.Equal(600f, controller.TextArea.Top);
|
||||
Assert.Equal(48f, controller.TextArea.Height);
|
||||
Assert.Single(controller.Pictures);
|
||||
Assert.Equal(601f, controller.Pictures[0].Top);
|
||||
Assert.Equal(0x06000001u, controller.Pictures[0].BackgroundSprite);
|
||||
Assert.Equal(
|
||||
20d * (600d + 48d) / (600d + 48d / 3d),
|
||||
controller.DurationSeconds,
|
||||
precision: 5);
|
||||
|
||||
environment.Now += controller.DurationSeconds * 0.5d;
|
||||
controller.Tick();
|
||||
|
||||
Assert.Equal(276f, controller.TextArea.Top);
|
||||
Assert.True(controller.Pictures[0].Top < 600f);
|
||||
Assert.Equal(2, controller.Pictures.Count);
|
||||
Assert.Equal(0x06000002u, controller.Pictures[1].BackgroundSprite);
|
||||
Assert.Equal(
|
||||
controller.Pictures[0].Top + controller.Pictures[0].Height + 1f,
|
||||
controller.Pictures[1].Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyKey_ShowsWaitForAFrame_ThenReturnsToCharacterManagement()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CreditsUiController controller = environment.Controller;
|
||||
controller.Activate();
|
||||
|
||||
environment.Host.OnKeyDown(123);
|
||||
Assert.Equal(1, environment.Dialogs.ActiveCount);
|
||||
|
||||
controller.Tick();
|
||||
Assert.True(controller.IsActive);
|
||||
Assert.Equal(0, environment.ReturnCalls);
|
||||
|
||||
controller.Tick();
|
||||
Assert.False(controller.IsActive);
|
||||
Assert.Equal(1, environment.ReturnCalls);
|
||||
Assert.Equal(0, environment.Dialogs.ActiveCount);
|
||||
Assert.Null(environment.Host.FixedCanvasSize);
|
||||
Assert.False(controller.PictureRoot.Visible);
|
||||
Assert.False(controller.TextRoot.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaturalCompletion_UsesTheSameWaitAndReturnPath()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CreditsUiController controller = environment.Controller;
|
||||
controller.Activate();
|
||||
|
||||
environment.Now += controller.DurationSeconds;
|
||||
controller.Tick();
|
||||
Assert.Equal(1, environment.Dialogs.ActiveCount);
|
||||
|
||||
controller.Tick();
|
||||
Assert.True(controller.IsActive);
|
||||
controller.Tick();
|
||||
|
||||
Assert.False(controller.IsActive);
|
||||
Assert.Equal(1, environment.ReturnCalls);
|
||||
Assert.Equal(0, environment.Dialogs.ActiveCount);
|
||||
}
|
||||
|
||||
private sealed class EnvironmentHarness : IDisposable
|
||||
{
|
||||
public EnvironmentHarness()
|
||||
{
|
||||
Host = new UiRoot { Width = 1280f, Height = 720f };
|
||||
Dialogs = new RetailDialogFactory(
|
||||
Host,
|
||||
RetailDialogFactoryTests.BuildDialogLayout);
|
||||
CreditsUiResources resources = BuildResources();
|
||||
Controller = Assert.IsType<CreditsUiController>(
|
||||
CreditsUiController.CreateDetached(
|
||||
Host,
|
||||
resources,
|
||||
Dialogs,
|
||||
() => Now,
|
||||
ResolveSprite,
|
||||
() => ReturnCalls++));
|
||||
}
|
||||
|
||||
public UiRoot Host { get; }
|
||||
public RetailDialogFactory Dialogs { get; }
|
||||
public CreditsUiController Controller { get; }
|
||||
public double Now { get; set; } = 100d;
|
||||
public int ReturnCalls { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Controller.Dispose();
|
||||
Dialogs.Dispose();
|
||||
}
|
||||
|
||||
private static (uint tex, int w, int h) ResolveSprite(uint id)
|
||||
=> (id, 400, 300);
|
||||
|
||||
private static CreditsUiResources BuildResources()
|
||||
{
|
||||
ImportedLayout picture = LayoutImporter.Build(
|
||||
new ElementInfo
|
||||
{
|
||||
Id = CreditsUiController.PictureRootElementId,
|
||||
Type = 3u,
|
||||
X = 0f,
|
||||
Y = 0f,
|
||||
Width = 400f,
|
||||
Height = 600f,
|
||||
},
|
||||
ResolveSprite,
|
||||
null);
|
||||
var textRoot = new ElementInfo
|
||||
{
|
||||
Id = CreditsUiController.TextRootElementId,
|
||||
Type = 3u,
|
||||
X = 400f,
|
||||
Y = 0f,
|
||||
Width = 400f,
|
||||
Height = 600f,
|
||||
};
|
||||
textRoot.Children.Add(new ElementInfo
|
||||
{
|
||||
Id = CreditsUiController.TextAreaElementId,
|
||||
Type = 12u,
|
||||
Width = 0f,
|
||||
Height = 0f,
|
||||
HJustify = HJustify.Center,
|
||||
VJustify = VJustify.Center,
|
||||
});
|
||||
ImportedLayout text = LayoutImporter.Build(
|
||||
textRoot,
|
||||
ResolveSprite,
|
||||
null);
|
||||
return new CreditsUiResources(
|
||||
0x21000003u,
|
||||
picture,
|
||||
text,
|
||||
["One\n", "Two\n"],
|
||||
[0x06000001u, 0x06000002u],
|
||||
20f,
|
||||
"Please Wait");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1111,7 +1111,7 @@ public class DatWidgetFactoryTests
|
|||
// ── Justification build-time application (new for importer Fix A) ────────
|
||||
|
||||
/// <summary>
|
||||
/// A Type-12 text element with HJustify=Center (the default) must produce a
|
||||
/// A Type-12 text element with authored HJustify=Center must produce a
|
||||
/// UiText with Centered=true and RightAligned=false at build time.
|
||||
/// This proves BuildText applies the dat's HJustify at construction without a
|
||||
/// controller binding step.
|
||||
|
|
@ -1123,7 +1123,18 @@ public class DatWidgetFactoryTests
|
|||
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
||||
Assert.True(t.Centered);
|
||||
Assert.False(t.RightAligned);
|
||||
Assert.Equal(VJustify.Center, t.VerticalJustify);
|
||||
Assert.Equal(VJustify.Top, t.VerticalJustify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildText_UnauthoredJustification_UsesRetailNearEdges()
|
||||
{
|
||||
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
|
||||
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
||||
|
||||
Assert.False(t.Centered);
|
||||
Assert.False(t.RightAligned);
|
||||
Assert.Equal(VJustify.Top, t.VerticalJustify);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -182,40 +182,40 @@ public class ElementReaderTests
|
|||
// ── HJustify / VJustify — Merge propagation ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// When the derived element has a non-Center HJustify, the derived value wins
|
||||
/// (same "non-default wins" rule as FontDid).
|
||||
/// Authored justification is presence-based: raw 3 on the derived element
|
||||
/// overrides raw 1 on its base.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
|
||||
{
|
||||
var base_ = new ElementInfo { HJustify = HJustify.Center };
|
||||
var derived = new ElementInfo { HJustify = HJustify.Right };
|
||||
ElementInfo base_ = WithJustification(0x14u, 1u);
|
||||
ElementInfo derived = WithJustification(0x14u, 3u);
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
Assert.Equal(HJustify.Right, merged.HJustify);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the derived element has the default HJustify (Center), the base value
|
||||
/// is inherited — Center from the derived does NOT override a Left base.
|
||||
/// Center is a real authored raw value, not an unset sentinel, and must
|
||||
/// therefore override a Left base.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
|
||||
public void Merge_DerivedAuthoredHJustifyCenter_OverridesBaseLeft()
|
||||
{
|
||||
var base_ = new ElementInfo { HJustify = HJustify.Left };
|
||||
var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
|
||||
ElementInfo base_ = WithJustification(0x14u, 2u);
|
||||
ElementInfo derived = WithJustification(0x14u, 1u);
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
Assert.Equal(HJustify.Left, merged.HJustify);
|
||||
Assert.Equal(HJustify.Center, merged.HJustify);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VJustify=Top from the base propagates when the derived element has no explicit
|
||||
/// (Center) vertical justification.
|
||||
/// VJustify=Top from the base propagates when the derived element authors
|
||||
/// no vertical justification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
|
||||
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsUnauthored()
|
||||
{
|
||||
var base_ = new ElementInfo { VJustify = VJustify.Top };
|
||||
var derived = new ElementInfo { VJustify = VJustify.Center }; // default
|
||||
ElementInfo base_ = WithJustification(0x15u, 4u);
|
||||
var derived = new ElementInfo();
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
Assert.Equal(VJustify.Top, merged.VJustify);
|
||||
}
|
||||
|
|
@ -226,8 +226,8 @@ public class ElementReaderTests
|
|||
[Fact]
|
||||
public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
|
||||
{
|
||||
var base_ = new ElementInfo { VJustify = VJustify.Center };
|
||||
var derived = new ElementInfo { VJustify = VJustify.Bottom };
|
||||
ElementInfo base_ = WithJustification(0x15u, 1u);
|
||||
ElementInfo derived = WithJustification(0x15u, 3u);
|
||||
var merged = ElementReader.Merge(base_, derived);
|
||||
Assert.Equal(VJustify.Bottom, merged.VJustify);
|
||||
}
|
||||
|
|
@ -380,6 +380,54 @@ public class ElementReaderTests
|
|||
return info;
|
||||
}
|
||||
|
||||
private static ElementInfo WithJustification(uint propertyId, uint raw)
|
||||
{
|
||||
ElementInfo info = WithDirectProperty(propertyId, EnumProp(raw));
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Justification_UnauthoredDefaultsMatchRetailConstructors()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
|
||||
Assert.Equal(HJustify.Left, info.HJustify);
|
||||
Assert.Equal(VJustify.Top, info.VJustify);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, HJustify.Left)]
|
||||
[InlineData(1u, HJustify.Center)]
|
||||
[InlineData(2u, HJustify.Left)]
|
||||
[InlineData(3u, HJustify.Right)]
|
||||
[InlineData(4u, HJustify.Left)]
|
||||
[InlineData(5u, HJustify.Right)]
|
||||
public void HorizontalJustification_AllRetailRawValues(
|
||||
uint raw,
|
||||
HJustify expected)
|
||||
{
|
||||
ElementInfo info = WithJustification(0x14u, raw);
|
||||
|
||||
Assert.Equal(expected, info.HJustify);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, VJustify.Top)]
|
||||
[InlineData(1u, VJustify.Center)]
|
||||
[InlineData(2u, VJustify.Top)]
|
||||
[InlineData(3u, VJustify.Bottom)]
|
||||
[InlineData(4u, VJustify.Top)]
|
||||
[InlineData(5u, VJustify.Bottom)]
|
||||
public void VerticalJustification_AllRetailRawValues(
|
||||
uint raw,
|
||||
VJustify expected)
|
||||
{
|
||||
ElementInfo info = WithJustification(0x15u, raw);
|
||||
|
||||
Assert.Equal(expected, info.VJustify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadTabTable_DecodesButtonPageDefaultInAuthoredOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// #408 client-wide blast-radius gate for DAT property 0x3B (Invisible).
|
||||
/// Retail applies the property in UIElement::OnSetAttribute for every imported
|
||||
/// element. Enumerate every installed LayoutDesc, then prove every corresponding
|
||||
/// widget which survives importer child-consumption starts hidden.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class LayoutImporterInvisibleSweepTests
|
||||
{
|
||||
private static string DatDirectory =>
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
|
||||
private readonly record struct Finding(uint LayoutId, uint ElementId);
|
||||
|
||||
[InstalledDatFact]
|
||||
public void EveryAuthoredInvisibleWidget_StartsHiddenAcrossAllLayouts()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
|
||||
var authored = new List<Finding>();
|
||||
var built = new List<Finding>();
|
||||
var incorrectlyVisible = new List<Finding>();
|
||||
|
||||
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(static id => id))
|
||||
{
|
||||
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
if (tree is null) continue;
|
||||
|
||||
CollectAuthored(layoutId, tree, authored);
|
||||
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
tree, _ => (0u, 0, 0), datFont: null, sourceLayoutDid: layoutId);
|
||||
CollectBuilt(layoutId, layout.Root, built, incorrectlyVisible);
|
||||
}
|
||||
|
||||
foreach (IGrouping<uint, Finding> group in authored
|
||||
.GroupBy(static f => f.LayoutId)
|
||||
.OrderBy(static g => g.Key))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[INVISIBLE] layout=0x{group.Key:X8} count={group.Count()} ids=["
|
||||
+ string.Join(",", group.Select(static f => $"0x{f.ElementId:X8}"))
|
||||
+ "]");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[INVISIBLE] authored={authored.Count} layouts="
|
||||
+ $"{authored.Select(static f => f.LayoutId).Distinct().Count()} "
|
||||
+ $"built={built.Count} incorrectlyVisible={incorrectlyVisible.Count}");
|
||||
|
||||
// Keep the global threshold resilient to an installed DAT revision while
|
||||
// pinning landmarks from independent screens in both data and widgets.
|
||||
Assert.True(authored.Count >= 1_000,
|
||||
$"Expected the known client-wide 0x3B population, found {authored.Count}.");
|
||||
Assert.Contains(authored, static f => f.ElementId == 0x10000403u); // chargen GM label
|
||||
Assert.Contains(authored, static f => f.ElementId == 0x10000494u); // chargen envoy label
|
||||
Assert.Contains(authored, static f => f.ElementId == 0x100006A4u); // combat root
|
||||
Assert.Contains(authored, static f => f.ElementId == 0x1000048Cu); // chat new-text indicator
|
||||
|
||||
Assert.Contains(built, static f => f.ElementId == 0x100006A4u);
|
||||
Assert.Contains(built, static f => f.ElementId == 0x1000048Cu);
|
||||
Assert.Empty(incorrectlyVisible);
|
||||
}
|
||||
|
||||
private static void CollectAuthored(uint layoutId, ElementInfo node, List<Finding> findings)
|
||||
{
|
||||
if (node.Invisible)
|
||||
findings.Add(new Finding(layoutId, node.Id));
|
||||
|
||||
foreach (ElementInfo child in node.Children)
|
||||
CollectAuthored(layoutId, child, findings);
|
||||
}
|
||||
|
||||
private static void CollectBuilt(
|
||||
uint layoutId,
|
||||
UiElement node,
|
||||
List<Finding> built,
|
||||
List<Finding> incorrectlyVisible)
|
||||
{
|
||||
if (node.AuthoredInvisible)
|
||||
{
|
||||
var finding = new Finding(layoutId, node.DatElementId);
|
||||
built.Add(finding);
|
||||
if (node.Visible)
|
||||
incorrectlyVisible.Add(finding);
|
||||
}
|
||||
|
||||
foreach (UiElement child in node.Children)
|
||||
CollectBuilt(layoutId, child, built, incorrectlyVisible);
|
||||
}
|
||||
}
|
||||
|
|
@ -363,6 +363,29 @@ public class LayoutImporterTests
|
|||
Assert.Null(found.AuthoredTooltipDelaySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWidget_AuthoredInvisible_HidesInitiallyButDoesNotLatchVisibility()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
|
||||
var hidden = new ElementInfo
|
||||
{
|
||||
Id = 0x2,
|
||||
Type = 3,
|
||||
Width = 20,
|
||||
Height = 20,
|
||||
Invisible = true,
|
||||
};
|
||||
|
||||
ImportedLayout tree = LayoutImporter.BuildFromInfos(root, [hidden], NoTex, null);
|
||||
UiElement found = tree.FindElement(0x2)!;
|
||||
|
||||
Assert.True(found.AuthoredInvisible);
|
||||
Assert.False(found.Visible);
|
||||
|
||||
found.Visible = true;
|
||||
Assert.True(found.Visible);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -124,7 +126,18 @@ public sealed class MapHousePanelControllerTests
|
|||
/// UiButton hotspots above — a fresh instance per call, matching
|
||||
/// production's real resolver.</summary>
|
||||
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
|
||||
=> new UiText { Width = 280f, Height = 28f };
|
||||
=> new UiText
|
||||
{
|
||||
Width = 280f,
|
||||
Height = 28f,
|
||||
DefaultColor = new Vector4(0.1f, 0.1f, 0.1f, 1f),
|
||||
FontColorPalette =
|
||||
[
|
||||
new Vector4(1f, 1f, 1f, 1f),
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
new Vector4(1f, 0f, 0f, 1f),
|
||||
],
|
||||
};
|
||||
|
||||
private static MapHousePanelController.Callbacks MakeCallbacks(
|
||||
List<string>? calls = null,
|
||||
|
|
@ -132,7 +145,8 @@ public sealed class MapHousePanelControllerTests
|
|||
Func<uint>? playerCellId = null,
|
||||
Func<CreateObject.ServerPosition?>? housePosition = null,
|
||||
Func<IReadOnlyList<string>>? houseLines = null,
|
||||
Func<uint, uint, ElementInfo?>? templateInfoResolver = null)
|
||||
Func<uint, uint, ElementInfo?>? templateInfoResolver = null,
|
||||
Func<IReadOnlyList<HousePanelLine>>? housePanelLines = null)
|
||||
{
|
||||
calls ??= new List<string>();
|
||||
return new MapHousePanelController.Callbacks(
|
||||
|
|
@ -147,7 +161,8 @@ public sealed class MapHousePanelControllerTests
|
|||
House: new HousePageController.Bindings(
|
||||
Lines: houseLines ?? (static () => Array.Empty<string>()),
|
||||
OnShown: () => calls.Add("house-shown"),
|
||||
TemplateResolver: FakeHouseRowTemplate));
|
||||
TemplateResolver: FakeHouseRowTemplate,
|
||||
PanelLines: housePanelLines));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -377,4 +392,35 @@ public sealed class MapHousePanelControllerTests
|
|||
"You may buy another house immediately.",
|
||||
Assert.Single(row.LinesProvider()).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_UsesRetailHousePanelColorAsAuthoredPaletteIndex()
|
||||
{
|
||||
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
|
||||
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
||||
HousePanelLine[] lines =
|
||||
[
|
||||
new("paid", HousePanelTextColor.RentPaid),
|
||||
new("unpaid", HousePanelTextColor.RentNotPaid),
|
||||
];
|
||||
MapHousePanelController? controller = MapHousePanelController.Bind(
|
||||
rootInfo,
|
||||
layout,
|
||||
MakeCallbacks(housePanelLines: () => lines));
|
||||
Assert.NotNull(controller);
|
||||
|
||||
controller!.Tick(0.016);
|
||||
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId));
|
||||
UiScrollablePanel viewport = Assert.IsType<UiScrollablePanel>(
|
||||
listBox.ViewportForTest);
|
||||
Assert.Equal(2, viewport.Children.Count);
|
||||
Assert.Equal(
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
Assert.Single(Assert.IsType<UiText>(viewport.Children[0]).LinesProvider()).Color);
|
||||
Assert.Equal(
|
||||
new Vector4(1f, 0f, 0f, 1f),
|
||||
Assert.Single(Assert.IsType<UiText>(viewport.Children[1]).LinesProvider()).Color);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,9 +313,11 @@ public sealed class OptionsPanelLiveMountProbeTests
|
|||
/// LayoutDesc, and <c>Open @0x0046cc30</c> centers the POPUP over the
|
||||
/// button when bool attr 3 is authored. This probe measures all of that
|
||||
/// authored data for the Config option-menu chain (catalog 0x21000043,
|
||||
/// base 0x10000353) with vendor's dropdown (0x1000034F chain — visibly a
|
||||
/// FIXED 6-row + scrollbar popup in retail, user-gated during the vendor
|
||||
/// campaign) as the contrast control.</summary>
|
||||
/// base 0x10000353) and the vendor dropdown's sibling chain
|
||||
/// (0x1000034F). The latter was once treated as a fixed-six-row contrast;
|
||||
/// #386's named-retail trace corrected that reading: it follows the same
|
||||
/// docked size-to-content message route and authors scrollbar property
|
||||
/// 0x79 (hide when disabled).</summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void ProbeMenuPopupSizingAndTextStyle()
|
||||
|
|
@ -371,7 +373,7 @@ public sealed class OptionsPanelLiveMountProbeTests
|
|||
Console.WriteLine("[menuprobe3] row template 0x1000035A FAILED to import");
|
||||
}
|
||||
|
||||
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (fixed 6-row + scrollbar in retail) ===");
|
||||
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (content-sized; scrollbar 0x79 hides when disabled) ===");
|
||||
DumpDockAndSize(dats, 0x21000043u, 0x1000034Fu, "Vendor popup root");
|
||||
DumpDockAndSize(dats, 0x21000043u, 0x10000350u, "Vendor popup ListBox");
|
||||
ElementInfo? vendorBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000034Bu);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ public sealed class SpellcastingUiControllerTests
|
|||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
RetailCombatLayout.FitFavoriteSlots(layout);
|
||||
// The authored combat root starts Invisible=true. Production's
|
||||
// RetailWindowFrame/CombatUiController show it when combat mode opens
|
||||
// the window; this standalone pointer fixture must model that mount
|
||||
// edge explicitly now that #408 honors the DAT flag client-wide.
|
||||
layout.Root.Visible = true;
|
||||
var spellbook = new Spellbook();
|
||||
spellbook.OnSpellLearned(42u, 1f);
|
||||
spellbook.SetFavorite(0, 0, 42u);
|
||||
|
|
@ -199,6 +204,7 @@ public sealed class SpellcastingUiControllerTests
|
|||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
RetailCombatLayout.FitFavoriteSlots(layout);
|
||||
layout.Root.Visible = true;
|
||||
var spellbook = new Spellbook();
|
||||
spellbook.OnSpellLearned(1u, 1f);
|
||||
spellbook.OnSpellLearned(2u, 1f);
|
||||
|
|
@ -468,6 +474,7 @@ public sealed class SpellcastingUiControllerTests
|
|||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
RetailCombatLayout.FitFavoriteSlots(layout);
|
||||
layout.Root.Visible = true;
|
||||
var spellbook = new Spellbook();
|
||||
spellbook.OnSpellLearned(1u, 1f);
|
||||
spellbook.OnSpellLearned(2u, 1f);
|
||||
|
|
|
|||
|
|
@ -247,13 +247,12 @@ public class UiDatElementTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The state-0 fallback must NOT honor a base-DirectState Invisible
|
||||
/// (dat 0x3B) — that is the #408 construction-time class, gated
|
||||
/// separately; retail's per-state Invisible honor applies to NAMED
|
||||
/// authored states only.
|
||||
/// #408: retail's unauthored-state fallback commits state 0 and applies
|
||||
/// its properties, including Invisible, through the same OnSetAttribute
|
||||
/// path as a named state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TrySetRetailState_UnauthoredStateFallback_DoesNotHonorBaseInvisible()
|
||||
public void TrySetRetailState_UnauthoredStateFallback_RestoresBaseInvisible()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
var baseState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
|
|
@ -265,12 +264,12 @@ public class UiDatElementTests
|
|||
info.States[UiStateInfo.DirectStateId] = baseState;
|
||||
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
|
||||
var element = new UiDatElement(info, _ => (0u, 0, 0));
|
||||
Assert.True(element.Visible);
|
||||
element.Visible = true;
|
||||
|
||||
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||
|
||||
Assert.Equal("", element.ActiveState);
|
||||
Assert.True(element.Visible);
|
||||
Assert.False(element.Visible);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,10 @@ public sealed class VendorUiControllerTests
|
|||
Assert.Equal(6, h.TypeMenu.RowsPerColumn);
|
||||
Assert.Equal(18f, h.TypeMenu.RowHeight);
|
||||
Assert.Equal(100f, h.TypeMenu.ColumnWidth);
|
||||
Assert.True(h.TypeMenu.PopupSizeToContent);
|
||||
Assert.True(h.TypeMenu.PopupScrollbarHideWhenDisabled);
|
||||
Assert.Equal(2 * h.TypeMenu.RowHeight + 2 * 5f, h.TypeMenu.PopupOuterHeight);
|
||||
Assert.Equal(h.TypeMenu.ColumnWidth + 2 * 5f, h.TypeMenu.PopupOuterWidth);
|
||||
|
||||
// Open via the real widget event path.
|
||||
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5)));
|
||||
|
|
@ -1054,7 +1058,7 @@ public sealed class VendorUiControllerTests
|
|||
// where nothing lives; UiMenu treats it as an ordinary button click
|
||||
// and just re-closes the still-open menu instead of picking a row.
|
||||
const int border = 5;
|
||||
float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
|
||||
float outerH = h.TypeMenu.PopupOuterHeight;
|
||||
const int targetRow = 1;
|
||||
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
|
||||
float oldUpwardLy = iy - outerH + border;
|
||||
|
|
|
|||
|
|
@ -732,6 +732,65 @@ public class UiButtonTests
|
|||
Assert.True(kid.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetRetailState_AppliesInvisibleToButtonNamedAndDirectStates()
|
||||
{
|
||||
var info = ButtonInfo("Normal", "Ghosted");
|
||||
info.StateMedia[""] = (99u, 1);
|
||||
info.States[UiStateInfo.DirectStateId] = StateWithInvisible(
|
||||
UiStateInfo.DirectStateId, "", invisible: true);
|
||||
info.States[UiButtonStateMachine.Normal] = StateWithInvisible(
|
||||
UiButtonStateMachine.Normal, "Normal", invisible: false);
|
||||
info.States[UiButtonStateMachine.Ghosted] = StateWithInvisible(
|
||||
UiButtonStateMachine.Ghosted, "Ghosted", invisible: true);
|
||||
var button = CreateButton(info);
|
||||
|
||||
button.Visible = false;
|
||||
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||
Assert.True(button.Visible);
|
||||
|
||||
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Ghosted));
|
||||
Assert.False(button.Visible);
|
||||
|
||||
button.Visible = true;
|
||||
Assert.True(button.TrySetRetailState(UiStateInfo.DirectStateId));
|
||||
Assert.False(button.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiText_ReturningToDirectState_RestoresItsInvisibleProperty()
|
||||
{
|
||||
var info = new ElementInfo { Type = 12, Width = 20, Height = 20 };
|
||||
info.States[UiStateInfo.DirectStateId] = StateWithInvisible(
|
||||
UiStateInfo.DirectStateId, "", invisible: true);
|
||||
info.States[UiButtonStateMachine.Normal] = StateWithInvisible(
|
||||
UiButtonStateMachine.Normal, "Normal", invisible: false);
|
||||
var text = new UiText();
|
||||
text.ConfigureDatState(info);
|
||||
|
||||
text.Visible = true;
|
||||
Assert.True(text.TrySetRetailState(UiStateInfo.DirectStateId));
|
||||
Assert.False(text.Visible);
|
||||
Assert.True(text.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||
Assert.True(text.Visible);
|
||||
Assert.True(text.TrySetRetailState(UiStateInfo.DirectStateId));
|
||||
Assert.False(text.Visible);
|
||||
}
|
||||
|
||||
private static UiStateInfo StateWithInvisible(
|
||||
uint id,
|
||||
string name,
|
||||
bool invisible)
|
||||
{
|
||||
var state = new UiStateInfo { Id = id, Name = name };
|
||||
state.Properties.Values[0x3Bu] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Bool,
|
||||
BoolValue = invisible,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
private static ElementInfo ButtonInfo(params string[] states)
|
||||
{
|
||||
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
|
||||
|
|
|
|||
|
|
@ -177,9 +177,11 @@ public class UiMenuTests
|
|||
}
|
||||
|
||||
// ── G5 (vendor gate finding): Scrollable single-column popup ──────────────
|
||||
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a SCROLLABLE
|
||||
// single column with a docked scrollbar, not a column-major grid — see
|
||||
// VendorUiController's "G5 correction" class-doc paragraph. Chat's own popup
|
||||
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a scrollable
|
||||
// single-column ListBox with a docked scrollbar, not a column-major grid.
|
||||
// Its four-edge docking subsequently sizes the popup to content and property
|
||||
// 0x79 hides that scrollbar when disabled; the fixed-viewport tests below
|
||||
// retain coverage of UiMenu's actual overflow path. Chat's own popup
|
||||
// (exercised by every test above, Scrollable left at its false default) is
|
||||
// completely unaffected: it's a structurally different code path
|
||||
// (DrawGridPopup / the grid branch of OnEvent's MouseDown handling).
|
||||
|
|
@ -541,6 +543,45 @@ public class UiMenuTests
|
|||
Assert.False(menu.IsOpen); // picking closes, same as every other path
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SizeToContent_WithAuthoredHideWhenDisabled_HidesTheScrollbarAndItsInput()
|
||||
{
|
||||
UiMenu menu = MakeScrollableMenu(3, sizeToContent: true);
|
||||
menu.PopupScrollbarHideWhenDisabled = true;
|
||||
Assert.Equal(menu.ColumnWidth + 2 * 5f, menu.PopupOuterWidth);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
|
||||
// Configure the exact content/view extents used by both drawing and
|
||||
// pointer dispatch. Size-to-content makes them equal, so retail's
|
||||
// authored scrollbar property 0x79 hides the entire sibling widget.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0)));
|
||||
Assert.False(menu.PopupScroll.HasOverflow);
|
||||
Assert.False(menu.IsPopupScrollbarPresentationVisible);
|
||||
|
||||
// The old 16px sibling column is outside the popup now. A direct event
|
||||
// at that old coordinate must not page/line scroll or leave an invisible
|
||||
// control holding the popup open (UiRoot hit testing would not target the
|
||||
// menu there at all because PopupOuterWidth has already collapsed).
|
||||
float lx = 5f + menu.ColumnWidth + menu.ScrollbarWidth / 2f;
|
||||
float ly = menu.Height + 5f + menu.RowHeight / 2f;
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, (int)lx, (int)ly)));
|
||||
Assert.Equal(0, menu.PopupScroll.ScrollY);
|
||||
Assert.False(menu.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedViewport_WithOverflow_KeepsHideWhenDisabledScrollbarVisible()
|
||||
{
|
||||
UiMenu menu = MakeScrollableMenu(18, sizeToContent: false);
|
||||
menu.PopupScrollbarHideWhenDisabled = true;
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0)));
|
||||
|
||||
Assert.True(menu.PopupScroll.HasOverflow);
|
||||
Assert.True(menu.IsPopupScrollbarPresentationVisible);
|
||||
Assert.Equal(menu.ColumnWidth + menu.ScrollbarWidth + 2 * 5f, menu.PopupOuterWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyItems_ButtonClickDoesNotOpen()
|
||||
{
|
||||
|
|
@ -552,14 +593,16 @@ public class UiMenuTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TextStyleDefaults_PreserveChatAndVendorBehavior()
|
||||
public void MenuRetailBehaviorFlags_DefaultOff_ForMenusThatDoNotAuthorThem()
|
||||
{
|
||||
// The three 2026-08-13 additions are opt-in: chat's gold left-aligned
|
||||
// caption and vendor's fixed 6-row window are untouched by default.
|
||||
// These are per-menu authored behaviors. Chat's grid popup keeps the
|
||||
// generic left-aligned, non-resizing defaults; vendor opts into both
|
||||
// content sizing and property-0x79 scrollbar hiding in its controller.
|
||||
var menu = new UiMenu();
|
||||
Assert.False(menu.ButtonTextCentered);
|
||||
Assert.False(menu.ItemTextCentered);
|
||||
Assert.False(menu.PopupSizeToContent);
|
||||
Assert.False(menu.PopupScrollbarHideWhenDisabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -2290,6 +2290,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Assert.True(fixture.Runtime.TryGetRecord(splitResultGuid, out LiveEntityRecord record));
|
||||
Assert.NotNull(record.WorldEntity);
|
||||
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||
Assert.Equal(splitResultGuid, drop.Selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -2372,6 +2373,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Assert.False(secondRecovered);
|
||||
Assert.False(fixture.Runtime.TryGetSnapshot(secondUnknown, out _));
|
||||
Assert.True(fixture.Runtime.TryGetSnapshot(firstUnknown, out _));
|
||||
Assert.Equal(firstUnknown, drop.Selection.SelectedObjectId);
|
||||
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||
}
|
||||
|
||||
|
|
@ -2476,7 +2478,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
public readonly ItemInteractionController Interaction;
|
||||
public readonly InventoryWorldDropProjectionController Projection;
|
||||
public readonly StackSplitQuantityState SplitQuantity = new();
|
||||
public uint SelectedObject;
|
||||
public readonly AcDream.Core.Selection.SelectionState Selection = new();
|
||||
|
||||
public DropHarness(Fixture fixture)
|
||||
{
|
||||
|
|
@ -2498,7 +2500,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
sendWield: null,
|
||||
sendDrop: _ => { },
|
||||
sendSplitToWorld: (_, _) => { },
|
||||
selectedObjectId: () => SelectedObject,
|
||||
selectedObjectId: () => Selection.SelectedObjectId ?? 0u,
|
||||
stackSplitQuantity: SplitQuantity,
|
||||
playerOnGround: () => true);
|
||||
Projection = new InventoryWorldDropProjectionController(
|
||||
|
|
@ -2506,12 +2508,15 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
fixture.Objects,
|
||||
fixture.Runtime,
|
||||
fixture.Controller,
|
||||
Selection,
|
||||
() => 100.0);
|
||||
}
|
||||
|
||||
public bool DispatchSplit(uint itemGuid, uint stackSize, uint splitAmount)
|
||||
{
|
||||
SelectedObject = itemGuid;
|
||||
Selection.Select(
|
||||
itemGuid,
|
||||
AcDream.Core.Selection.SelectionChangeSource.Inventory);
|
||||
SplitQuantity.Reset(stackSize);
|
||||
SplitQuantity.SetValue(splitAmount);
|
||||
var payload = new ItemDragPayload(
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ internal static class LiveEntityTestFixture
|
|||
WorldSession.EntitySpawn spawn,
|
||||
Func<uint, WorldEntity>? factory = null,
|
||||
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World,
|
||||
Action<LiveEntityRecord>? initializeProjection = null)
|
||||
Action<LiveEntityRecord>? initializeProjection = null,
|
||||
bool isLocalPlayer = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
|
||||
LiveEntityRegistrationResult registration =
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
runtime.RegisterLiveEntity(spawn, isLocalPlayer);
|
||||
RuntimeEntityRecord canonical = registration.Canonical
|
||||
?? throw new InvalidOperationException(
|
||||
"The fixture cannot materialize a stale CreateObject.");
|
||||
|
|
|
|||
|
|
@ -356,6 +356,48 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
Assert.Equal(priorVisible, record.IsSpatiallyVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompleted_PoseOnlySupersessionDoesNotSnapSidecarBack()
|
||||
{
|
||||
Fixture fixture = Fixture.Create();
|
||||
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = entity.Position,
|
||||
Orientation = entity.Rotation,
|
||||
};
|
||||
record.Canonical.SetPhysicsBody(body);
|
||||
RuntimePlacementProjectionSnapshot stale = Placement(
|
||||
fixture,
|
||||
record,
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
body.Position,
|
||||
body.Orientation);
|
||||
|
||||
// #323: StoreAcceptedDestinationPose has this exact shape — it moves
|
||||
// the retained body without changing cell or PlacementCommitVersion.
|
||||
// A delayed initial-create receipt must acknowledge without restoring
|
||||
// the older pose to the graphical sidecar.
|
||||
var currentPosition = body.Position + new Vector3(25f, 10f, 3f);
|
||||
Quaternion currentOrientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
0.75f);
|
||||
body.Position = currentPosition;
|
||||
body.Orientation = currentOrientation;
|
||||
entity.SetPosition(currentPosition);
|
||||
entity.Rotation = currentOrientation;
|
||||
|
||||
Assert.True(fixture.Sink.TryApply(in stale));
|
||||
|
||||
Assert.Equal(currentPosition, entity.Position);
|
||||
Assert.Equal(currentOrientation, entity.Rotation);
|
||||
Assert.Equal(stale.Token.ExactCellId, record.FullCellId);
|
||||
Assert.Equal(
|
||||
stale.Token.PlacementCommitVersion,
|
||||
record.Canonical.PlacementCommitVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Place_RejectsStaleCanonicalVersionsWithoutChangingSidecar()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue