feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -424,6 +424,63 @@ public sealed class PlayerMouseLookMovementTests
Assert.False(controller.PrepareForAttackRequest());
}
[Fact]
public void PersistentCommandRetakesTurnAfterServerPostureAcknowledgement()
{
var controller = CreateController();
var command = new MovementInput(
TurnRight: true,
IsPersistentCommand: true);
MovementResult started = controller.Update(1f / 60f, command);
Assert.Equal(MotionCommand.TurnRight, started.TurnCommand);
// The live MossTank failure: ACE acknowledges an explicit combat-mode
// change with a non-autonomous Magic + Ready movement event after the
// plugin has already begun facing its target.
controller.SetLastMoveWasAutonomous(false);
controller.Motion.MoveToInterpretedState(new InboundInterpretedState
{
CurrentStyle = 0x80000049u,
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1f,
TurnCommand = 0u,
TurnSpeed = 1f,
});
Assert.Equal(0u, controller.Motion.InterpretedState.TurnCommand);
MovementResult resumed = controller.Update(1f / 60f, command);
Assert.True(resumed.ShouldSendMovementEvent);
Assert.Equal(
MotionCommand.TurnRight,
controller.Motion.InterpretedState.TurnCommand);
Assert.Equal(MotionCommand.TurnRight, resumed.TurnCommand);
}
[Fact]
public void PhysicalHeldKeyKeepsRetailEdgeBehaviorAfterServerPosture()
{
var controller = CreateController();
var physical = new MovementInput(TurnRight: true);
_ = controller.Update(1f / 60f, physical);
controller.SetLastMoveWasAutonomous(false);
controller.Motion.MoveToInterpretedState(new InboundInterpretedState
{
CurrentStyle = 0x80000049u,
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1f,
TurnCommand = 0u,
TurnSpeed = 1f,
});
MovementResult held = controller.Update(1f / 60f, physical);
Assert.False(held.ShouldSendMovementEvent);
Assert.Equal(0u, controller.Motion.InterpretedState.TurnCommand);
}
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();

View file

@ -7,6 +7,33 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeActionStateTests
{
[Fact]
public void HealthActivityPublishesMonotonicRevisionAndAgeAndResets()
{
double now = 10d;
using var inventory = NewInventoryTransactions();
using var actions = RuntimeActionTestFactory.Create(inventory, () => now);
actions.Combat.OnUpdateHealth(0x50000001u, 0.75f);
now = 12.5d;
Assert.True(actions.TryGetHealthActivity(
0x50000001u, out long firstRevision, out double age));
Assert.Equal(1, firstRevision);
Assert.Equal(2.5d, age, 3);
actions.Combat.OnUpdateHealth(0x50000001u, 0.5f);
Assert.True(actions.TryGetHealthActivity(
0x50000001u, out long secondRevision, out age));
Assert.True(secondRevision > firstRevision);
Assert.Equal(0d, age, 3);
actions.ResetSession();
Assert.False(actions.TryGetHealthActivity(
0x50000001u, out _, out double missingAge));
Assert.True(double.IsPositiveInfinity(missingAge));
}
[Fact]
public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
{

View file

@ -183,6 +183,27 @@ public sealed class RuntimeCombatAttackStateTests
Assert.Equal(0, cancels);
}
[Fact]
public void AttackDonePublishesOneCompletionReceiptAndResetClearsIt()
{
var combat = new CombatState();
using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true);
combat.OnAttackDone(47u, 0x1234u);
Assert.Equal(1, controller.CompletionRevision);
Assert.Equal(47u, controller.CompletionSequence);
Assert.Equal(0x1234u, controller.CompletionWeenieError);
controller.ResetSession();
Assert.Equal(0, controller.CompletionRevision);
Assert.Equal(0u, controller.CompletionSequence);
Assert.Equal(0u, controller.CompletionWeenieError);
}
[Fact]
public void StartAttackRequest_PreparesPlayerMovementBeforePowerBuild()
{

View file

@ -96,6 +96,24 @@ public sealed class RuntimeCombatModeStateTests
Assert.Equal(["intent", "equipment", "send:Melee"], operations.Trace);
}
[Fact]
public void ExplicitRequestSendsChosenPluginModeWithoutEquipmentGuessing()
{
var combat = new CombatState();
var operations = new Operations();
combat.CombatModeChanged += mode =>
operations.Trace.Add($"state:{mode}");
var state = new RuntimeCombatModeState(combat, operations);
RuntimeCombatModeRequestResult result = state.Request(CombatMode.Magic);
Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
Assert.Equal(CombatMode.Magic, result.Mode);
Assert.Equal(
["intent", "send:Magic", "state:Magic"],
operations.Trace);
}
private sealed class Operations : IRuntimeCombatModeOperations
{
public bool IsInWorld { get; init; } = true;

View file

@ -3,6 +3,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
@ -172,6 +173,43 @@ public sealed class RuntimeHostileTargetQueryTests
Assert.Null(RuntimeHostileTargetQuery.FindClosest(runtime));
}
[Fact]
public void Capture_ProjectsRetailRelativeHeadingRangeNameAndHealth()
{
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, 0x01010001u, 10f, 10f, PlayerObject(Player));
ClientObject east = Hostile(0x50000020u);
east.Name = "East Drudge";
east.WeenieClassId = 1234u;
east.Properties.Ints[(uint)PropertyInt.CreatureType] = 4;
Add(runtime, east.ObjectId, 0x01010001u, 13f, 10f, east);
runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x70000020u,
Type = ItemType.Armor,
WielderId = east.ObjectId,
CurrentlyEquippedLocation = EquipMask.Shield,
});
runtime.ActionOwner.Combat.OnUpdateHealth(east.ObjectId, 0.75f);
IReadOnlyList<RuntimeHostileTargetSnapshot> captured =
RuntimeHostileTargetQuery.Capture(runtime, 4f);
RuntimeHostileTargetSnapshot target = Assert.Single(captured);
Assert.Equal(east.ObjectId, target.ObjectId);
Assert.Equal("East Drudge", target.Name);
Assert.Equal(1234u, target.WeenieClassId);
Assert.Equal(3f, target.Distance, 3);
Assert.Equal(90f, target.RelativeAngleDegrees, 3);
Assert.True(target.IsHealthKnown);
Assert.Equal(0.75f, target.HealthFraction, 3);
Assert.Equal(4, target.SpeciesId);
Assert.True(target.HasShield);
Assert.Equal(0, target.MaximumHealth);
Assert.Empty(RuntimeHostileTargetQuery.Capture(runtime, 2.9f));
}
private static GameRuntime Create()
{
var operations = new Operations();

View file

@ -54,6 +54,70 @@ public sealed class RuntimeInteractionTransactionStateTests
state.CompleteUse(0u);
Assert.Equal(0, inventory.BusyCount);
Assert.Equal(new RuntimeItemUseCompletion(1, Item, 0u, 0u),
state.LastItemUseCompletion);
Assert.False(state.CaptureOwnership().AwaitingItemUseCompletion);
}
[Fact]
public void TargetedItemUsePublishesExactAuthoritativeFailureReceipt()
{
using var inventory = NewInventory(out _);
using var state = new RuntimeInteractionTransactionState(inventory);
Assert.True(state.TryDispatchTargetedUse(
Item,
Container,
static (_, _) => { },
incrementBusy: true));
Assert.True(state.CaptureOwnership().AwaitingItemUseCompletion);
state.CompleteUse(0x0402u);
Assert.Equal(
new RuntimeItemUseCompletion(1, Item, Container, 0x0402u),
state.LastItemUseCompletion);
Assert.False(state.LastItemUseCompletion.IsSuccess);
Assert.False(state.CaptureOwnership().AwaitingItemUseCompletion);
}
[Fact]
public void UseDoneWithoutPluginItemRequestCannotFabricateItemReceipt()
{
using var inventory = NewInventory(out _);
using var state = new RuntimeInteractionTransactionState(inventory);
state.IncrementBusyCount();
state.CompleteUse(0u);
Assert.Equal(default, state.LastItemUseCompletion);
Assert.Equal(0, inventory.BusyCount);
}
[Fact]
public void ItemUseReceiptIsGenerationScoped()
{
using var inventory = NewInventory(out _);
using var state = new RuntimeInteractionTransactionState(inventory);
Assert.True(state.TryDispatchTargetedUse(
Item,
Container,
static (_, _) => { },
incrementBusy: true));
state.CompleteUse(0u);
Assert.True(state.LastItemUseCompletion.IsSuccess);
state.ResetSession();
Assert.Equal(default, state.LastItemUseCompletion);
Assert.True(state.TryDispatchTargetedUse(
Container,
Item,
static (_, _) => { },
incrementBusy: true));
state.CompleteUse(0u);
Assert.Equal(1, state.LastItemUseCompletion.Revision);
Assert.Equal(Container, state.LastItemUseCompletion.SourceObjectId);
}
[Theory]

View file

@ -54,6 +54,40 @@ public sealed class RuntimeLocalPlayerMovementStateTests
Assert.Equal(movement.Revision, snapshot.Revision);
}
[Fact]
public void ViewProjectsLiveBodyOrientationAfterLocalTurn()
{
var controller = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 0x50000001u,
};
controller.SeedPlacementForTest(
new Vector3(11f, 12f, 13f),
0xA9B40001u,
new Vector3(11f, 12f, 13f));
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
Quaternion turned = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
Quaternion.Identity,
90f);
controller.SetBodyOrientation(turned);
// PhysicsBody.CellPosition deliberately retains the carried frame
// rotation. RuntimeMovementSnapshot is the live consumer projection
// used by plugins and must expose the body's current facing instead.
Assert.NotEqual(turned, controller.CellPosition.Frame.Orientation);
RuntimeMovementSnapshot snapshot = movement.View.Snapshot;
Assert.Equal(turned, snapshot.Position.Frame.Orientation);
Assert.Equal(
90f,
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
snapshot.Position.Frame.Orientation),
precision: 4);
}
[Fact]
public void GraphicalAndDirectCommandsMutateOneAutorunLatch()
{

View file

@ -103,6 +103,40 @@ public sealed class RuntimeSpellCastStateTests
Assert.Null(state.LastRequestedSpellId);
}
[Fact]
public void CompleteUse_PublishesOneExactServerReceipt()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
var operations = new FakeOperations { LocalPlayerId = 42u };
RuntimeSpellCastState state = Create(book, operations, selected: 99u);
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
Assert.Equal(1u, state.PendingSpellId);
Assert.Equal(99u, state.PendingTargetId);
Assert.True(state.CompleteUse(0x0402u));
Assert.Equal(
new RuntimeSpellCastCompletion(1, 1u, 99u, 0x0402u),
state.LastCompletion);
Assert.Null(state.PendingSpellId);
Assert.False(state.CompleteUse(0u));
Assert.Equal(1, state.LastCompletion.Revision);
}
[Fact]
public void Cast_DoesNotOverwritePendingReceiptIdentity()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
var operations = new FakeOperations { LocalPlayerId = 42u };
RuntimeSpellCastState state = Create(book, operations, selected: 99u);
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
Assert.Equal(CastRequestResult.Unavailable, state.Cast(1));
Assert.Equal(1, operations.TargetedSends);
Assert.Equal(1u, state.PendingSpellId);
Assert.Equal(99u, state.PendingTargetId);
}
private static RuntimeSpellCastState Create(
Spellbook book,
FakeOperations operations,