Ukendio / jecs

A fast, portable Entity Component System for Luau
https://ukendio.github.io/jecs/
MIT License
121 stars 20 forks source link

[BUG] Changing an entity during OnRemove silently gets undone when the hook finishes #118

Closed chess123mate closed 6 days ago

chess123mate commented 2 weeks ago

Reproduction

const world = new World()
const a = world.component()
const b = world.component()
const e = world.entity()
world.set(a, OnRemove, (e: Entity) => {
    world.set(e, b, true)
    print("In remove:", world.get(e, a), world.get(e, b))
})
world.set(e, a, true)
world.remove(e, a)
print("After remove:", world.get(e, a), world.get(e, b))

Actual Behavior

The component 'b' is only attached to the entity 'e' during the OnRemove hook:

In remove: true true
After remove: nil nil

Expected Behavior

The component 'b' should still be attached to the entity 'e' after the OnRemove hook is done:

In remove: true true
After remove: nil true

or:

Ukendio commented 1 week ago

This happens because the destination of where the entity should move is calculated after. So the order of the operations should be swapped. It will break existing behaviour where people rely on getting the data on remove.

Ukendio commented 6 days ago

OnRemove hooks needs to be invoked before it is actually removed as to keep a stable reference to the entity's component data at the moment of deletion.

Possible workaround is implementing a command buffer to defer/enqueue operations, so this isn't a bug but we can make better documentation for this in the future.

chess123mate commented 1 day ago

OnRemove hooks needs to be invoked before it is actually removed as to keep a stable reference to the entity's component data at the moment of deletion.

I don't understand, despite looking over world_remove in init.lua (though I don't understand how the archetypes/columns/etc work). I don't see how finishing the remove operation and then calling the OnRemove hook could cause problems? (I observe that OnSet is called after the assignment as well, so it would be consistent if they both worked the same way.)

so this isn't a bug

I imagine you don't mean that silently undoing operations is intended behaviour, right? (i.e. even if the solution is "implement a command buffer", shouldn't this issue still be open?)

Possible workaround is implementing a command buffer to defer/enqueue operations

I've seen this in another ECS implementation to avoid issues when iterating over entities. I don't recall seeing any documentation that explains whether something might break if you remove and/or add components while iterating via world.query? For both that case and these hooks, I imagine performance would be better (and expected behaviour more intuitive) if a command buffer can be avoided.

Ukendio commented 17 hours ago

I don't understand, despite looking over world_remove in init.lua (though I don't understand how the archetypes/columns/etc work). I don't see how finishing the remove operation and then calling the OnRemove hook could cause problems?

When you move the entity, and invoke the hook for the removed component, that means you lose the reference to the entity at the moment of deletion, what happens is that you get the data to it after it has been removed which is useless especially if you are looking to cleanup model references.

I imagine you don't mean that silently undoing operations is intended behaviour, right? Right, I don't mean to say that it should undo any operations, but generally speaking you do not want to make any structural changes inside of hooks. That is not the intended feature to work with that directly. Hooks are a means of listening to constructing/deconstructing the object. If you need to intercept and make side effects to the structure of the entity then you are encouraged to do that from a system. You can use (Previous, state) pairs to keep state for your data.

I don't recall seeing any documentation that explains whether something might break if you remove and/or add components while iterating via world.query There isn't such documentation because generally people are good at ensuring structural changes within a query does not re-match the invariant. E.g. adding components that are guaranteed not to rematch the query by using :without will completely circumvent the issue