fix #211: index login-equipped items under player
Project PlayerDescription equipment through the same contained-by-wielder ownership and ordered contents index as live WieldObject updates. Preserve equip masks and priorities so retail GetObjectAtLocation selects Missile for an already-equipped crossbow instead of sending a server-rejected Melee request. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
e74efc5c06
commit
ab98cda26b
6 changed files with 150 additions and 12 deletions
|
|
@ -46,9 +46,41 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #211 — Login-equipped missile weapon incorrectly requests melee combat
|
||||||
|
|
||||||
|
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate
|
||||||
|
**Severity:** HIGH
|
||||||
|
**Component:** inventory projection / combat mode
|
||||||
|
|
||||||
|
**Description:** After relaunching with a bow or crossbow already equipped,
|
||||||
|
the combat button and grave key briefly selected Melee before ACE restored
|
||||||
|
NonCombat, so the character could not enter combat.
|
||||||
|
|
||||||
|
**Root cause:** PlayerDescription recorded equipped entries with their equip
|
||||||
|
mask but no player ownership/container index. Live WieldObject updates did
|
||||||
|
index the same item under the player. `ToggleLiveCombatMode` reads the player's
|
||||||
|
ordered contents for retail `GetObjectAtLocation`, so it missed login equipment,
|
||||||
|
defaulted to Melee, and sent a mode incompatible with ACE's equipped missile
|
||||||
|
weapon. ACE correctly returned authoritative NonCombat.
|
||||||
|
|
||||||
|
**Resolution:** PlayerDescription equipment now uses the same
|
||||||
|
contained-by-wielder projection as live WieldObject while preserving its wire
|
||||||
|
order, equip mask, and layering priority. The production combat planner now
|
||||||
|
selects Missile for the login-equipped crossbow. The connected gate completed
|
||||||
|
NonCombat → Missile → NonCombat → Missile, including a Jump press, without a
|
||||||
|
server rejection.
|
||||||
|
|
||||||
|
**Research:** `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`
|
||||||
|
|
||||||
|
**Acceptance:** Relaunch with the crossbow already equipped. The combat button
|
||||||
|
and grave key enter Missile mode, the combat bar appears, returning to peace
|
||||||
|
hides it, and Jump followed by combat still works without a crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #210 — Mouse-up crashes when a UI callback changes pointer capture
|
## #210 — Mouse-up crashes when a UI callback changes pointer capture
|
||||||
|
|
||||||
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live gate
|
**Status:** DONE — 2026-07-13, user confirmed the crash no longer occurs (`e74efc5c`)
|
||||||
**Severity:** HIGH
|
**Severity:** HIGH
|
||||||
**Component:** retained UI / input lifecycle
|
**Component:** retained UI / input lifecycle
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,29 @@ GetDefaultCombatMode(showError):
|
||||||
returns the first entry whose location intersects the requested mask (and whose
|
returns the first entry whose location intersects the requested mask (and whose
|
||||||
priority intersects the requested priority when that priority is non-zero).
|
priority intersects the requested priority when that priority is non-zero).
|
||||||
|
|
||||||
|
### Login projection invariant
|
||||||
|
|
||||||
|
PlayerDescription carries loose inventory and equipped objects as two ordered
|
||||||
|
lists. They must feed one player-owned location lookup after parsing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OnPlayerDescription(player, inventory, equipped):
|
||||||
|
replace player-owned loose contents with inventory, preserving wire order
|
||||||
|
for each equipped entry in wire order:
|
||||||
|
record item as owned by player
|
||||||
|
record equipped location and layering priority
|
||||||
|
append it to the same ordered player-owned lookup
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the login equivalent of a live `WieldObject`, which also models the
|
||||||
|
item as contained by its wielder with a non-zero equipped location. Keeping
|
||||||
|
login equipment outside that lookup makes `GetDefaultCombatMode` miss an
|
||||||
|
already-equipped bow/crossbow and request Melee; ACE then correctly rejects the
|
||||||
|
mode because a missile weapon is equipped. ACE's
|
||||||
|
`Player.HandleActionChangeCombatMode_Inner` performs that rejection, while
|
||||||
|
holtburger independently retains PlayerDescription's `equipped_objects` list
|
||||||
|
for its suggested-combat-mode decision.
|
||||||
|
|
||||||
## ParentEvent wire and freshness
|
## ParentEvent wire and freshness
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -537,7 +537,13 @@ public static class GameEventWiring
|
||||||
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
|
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
|
||||||
}
|
}
|
||||||
foreach (var eq in p.Value.Equipped)
|
foreach (var eq in p.Value.Equipped)
|
||||||
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);
|
{
|
||||||
|
items.RecordMembership(
|
||||||
|
eq.Guid,
|
||||||
|
containerId: ownerGuid,
|
||||||
|
equip: (EquipMask)eq.EquipLocation,
|
||||||
|
priority: eq.Priority);
|
||||||
|
}
|
||||||
|
|
||||||
// D.5.1 Task 4: forward shortcut bar entries to the caller so the
|
// D.5.1 Task 4: forward shortcut bar entries to the caller so the
|
||||||
// toolbar can read them without holding a parser reference.
|
// toolbar can read them without holding a parser reference.
|
||||||
|
|
|
||||||
|
|
@ -481,11 +481,14 @@ public sealed class ClientObjectTable
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PlayerDescription manifest: record that this guid is the player's
|
/// PlayerDescription manifest: record that this guid is the player's
|
||||||
/// (in inventory or equipped at <paramref name="equip"/>), creating an
|
/// (in inventory or equipped at <paramref name="equip"/>), creating an
|
||||||
/// empty entry if CreateObject hasn't arrived yet. Never touches
|
/// empty entry if CreateObject hasn't arrived yet. Equipped entries may
|
||||||
/// icon/name/type/effects — that data comes from CreateObject.
|
/// specify the player as <paramref name="containerId"/> so login and live
|
||||||
|
/// WieldObject updates share the same contained-by-wielder projection.
|
||||||
|
/// Never touches icon/name/type/effects — that data comes from CreateObject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ClientObject RecordMembership(uint guid, uint containerId = 0,
|
public ClientObject RecordMembership(uint guid, uint containerId = 0,
|
||||||
EquipMask equip = EquipMask.None, uint? containerTypeHint = null)
|
EquipMask equip = EquipMask.None, uint? containerTypeHint = null,
|
||||||
|
uint? priority = null)
|
||||||
{
|
{
|
||||||
bool existed = _objects.TryGetValue(guid, out var obj);
|
bool existed = _objects.TryGetValue(guid, out var obj);
|
||||||
if (!existed || obj is null) // keep: satisfies nullable flow analysis
|
if (!existed || obj is null) // keep: satisfies nullable flow analysis
|
||||||
|
|
@ -495,15 +498,12 @@ public sealed class ClientObjectTable
|
||||||
}
|
}
|
||||||
uint oldContainer = obj.ContainerId;
|
uint oldContainer = obj.ContainerId;
|
||||||
if (containerId != 0)
|
if (containerId != 0)
|
||||||
{
|
|
||||||
obj.ContainerId = containerId;
|
obj.ContainerId = containerId;
|
||||||
obj.CurrentlyEquippedLocation = EquipMask.None;
|
obj.CurrentlyEquippedLocation = equip;
|
||||||
}
|
if (equip != EquipMask.None)
|
||||||
else
|
obj.ContainerSlot = -1;
|
||||||
{
|
|
||||||
obj.CurrentlyEquippedLocation = equip;
|
|
||||||
}
|
|
||||||
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
|
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
|
||||||
|
if (priority is { } p) obj.Priority = p;
|
||||||
Reindex(obj, oldContainer);
|
Reindex(obj, oldContainer);
|
||||||
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
||||||
return obj;
|
return obj;
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,61 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerDescription_IndexesLoginEquipmentForDefaultCombatMode()
|
||||||
|
{
|
||||||
|
const uint playerGuid = 0x50000001u;
|
||||||
|
const uint packItemGuid = 0x50000A01u;
|
||||||
|
const uint crossbowGuid = 0x50000B01u;
|
||||||
|
var dispatcher = new GameEventDispatcher();
|
||||||
|
var items = new ClientObjectTable();
|
||||||
|
GameEventWiring.WireAll(
|
||||||
|
dispatcher,
|
||||||
|
items,
|
||||||
|
new CombatState(),
|
||||||
|
new Spellbook(),
|
||||||
|
new ChatLog(),
|
||||||
|
playerGuid: () => playerGuid);
|
||||||
|
|
||||||
|
var sb = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(sb);
|
||||||
|
w.Write(0u); // propertyFlags
|
||||||
|
w.Write(0x52u); // weenieType
|
||||||
|
w.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
|
||||||
|
w.Write(1u); // has_health
|
||||||
|
w.Write(0u); // attribute_flags
|
||||||
|
w.Write(0u); // enchantment_mask
|
||||||
|
w.Write(0u); // option_flags
|
||||||
|
w.Write(0u); // options1
|
||||||
|
w.Write(0u); // legacy hotbar count
|
||||||
|
w.Write(0u); // spellbook_filters
|
||||||
|
w.Write(1u); // inventory count
|
||||||
|
w.Write(packItemGuid); w.Write(0u);
|
||||||
|
w.Write(1u); // equipped count
|
||||||
|
w.Write(crossbowGuid);
|
||||||
|
w.Write((uint)EquipMask.MissileWeapon);
|
||||||
|
w.Write(7u); // layering priority
|
||||||
|
|
||||||
|
var envelope = GameEventEnvelope.TryParse(
|
||||||
|
WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
||||||
|
dispatcher.Dispatch(envelope!.Value);
|
||||||
|
|
||||||
|
ClientObject crossbow = items.Get(crossbowGuid)!;
|
||||||
|
crossbow.Type = ItemType.MissileWeapon;
|
||||||
|
crossbow.CombatUse = 2;
|
||||||
|
var orderedPlayerContents = items.GetContents(playerGuid)
|
||||||
|
.Select(guid => items.Get(guid)!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { packItemGuid, crossbowGuid }, items.GetContents(playerGuid));
|
||||||
|
Assert.Equal(playerGuid, crossbow.ContainerId);
|
||||||
|
Assert.Equal(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation);
|
||||||
|
Assert.Equal(7u, crossbow.Priority);
|
||||||
|
Assert.Equal(
|
||||||
|
CombatMode.Missile,
|
||||||
|
CombatInputPlanner.GetDefaultCombatMode(orderedPlayerContents));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState()
|
public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,28 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it
|
Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordMembership_OwnedEquip_UsesSameIndexShapeAsLiveWield()
|
||||||
|
{
|
||||||
|
const uint playerGuid = 0x50000001u;
|
||||||
|
const uint weaponGuid = 0x500000B9u;
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
|
||||||
|
table.RecordMembership(
|
||||||
|
weaponGuid,
|
||||||
|
containerId: playerGuid,
|
||||||
|
equip: EquipMask.MissileWeapon,
|
||||||
|
priority: 7u);
|
||||||
|
|
||||||
|
var weapon = table.Get(weaponGuid);
|
||||||
|
Assert.NotNull(weapon);
|
||||||
|
Assert.Equal(playerGuid, weapon!.ContainerId);
|
||||||
|
Assert.Equal(-1, weapon.ContainerSlot);
|
||||||
|
Assert.Equal(EquipMask.MissileWeapon, weapon.CurrentlyEquippedLocation);
|
||||||
|
Assert.Equal(7u, weapon.Priority);
|
||||||
|
Assert.Equal(new[] { weaponGuid }, table.GetContents(playerGuid));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject
|
public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue