Current section
Files
Jump to
Current section
Files
lib/ecspanse/command.ex
defmodule Ecspanse.Command do
@moduledoc """
The `Ecspanse.Command` module provides a set of functions for managing entities, components and resources in the `Ecspanse` engine.
Commands are the only way to change the state of components and resources in `Ecspanse`. These commands can only be run from systems, otherwise an error will be thrown.
The `Ecspanse.Command` module includes functions for managing relationships between entities, such as adding and removing children and parents.
All entity and component related commands can run for batches (lists) for performance reasons.
All commands raise an error if the command fails.
#### Entity Relationships
The `Ecspanse.Command` module provides functions for managing relationships between entities.
This is aslo a powerful tool to manage different kind of collections.
> #### Ecspanse entities relationships are **bidirectional associations** {: .info}
> When adding or removing children or parents, they are automatically added or removed from the corresponding parent or children entities.
> The same applies when despawning entities.
"""
require Logger
require Ex2ms
alias __MODULE__
alias Ecspanse.Component
alias Ecspanse.Entity
alias Ecspanse.Event
alias Ecspanse.Query
alias Ecspanse.Resource
alias Ecspanse.Util
defmodule Operation do
@moduledoc false
@type t :: %__MODULE__{
name: name(),
system: module(),
entities_components:
list(%{(entity_id :: binary()) => list(component_module :: module())}),
system_execution: atom(),
locked_components: list()
}
@type name ::
:run
| :spawn_entities
| :despawn_entities
| :despawn_entities_and_descendants
| :add_components
| :remove_components
| :update_components
| :add_children
| :remove_children
| :add_parents
| :remove_parents
| :insert_resource
| :update_resource
| :delete_resource
defstruct name: nil,
system: nil,
entities_components: %{},
system_execution: nil,
locked_components: []
end
defmodule Error do
@moduledoc false
defexception [:message]
@impl true
def exception({%Ecspanse.Command.Operation{} = operation, message}) do
msg = """
System: #{inspect(operation.system)}
Operation: #{inspect(operation.name)}
Message: #{message}
"""
%Error{message: msg}
end
end
@typedoc false
@type t :: %Command{
return_result: any(),
insert_components: list(component :: struct()),
update_components: list(component :: struct()),
delete_components: list(component :: struct())
}
defstruct return_result: nil,
insert_components: [],
update_components: [],
delete_components: []
@doc """
Spawns a new entity with the given components and relations provided by the Ecspanse.Entity.entity_spec() type.
When creating a new entity, at least one of the `components:`, `children:` or `parents:`
must be provided in the entity spec, otherwise the entity cannot be persisted.
Due to the potentially large number of components that may be affected by this operation,
it is recommended to run this function in a synchronous system (such as a `frame_start` or `frame_end` system)
to avoid the need to lock all involved components.
## Examples
```elixir
%Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(
{
Ecspanse.Entity,
id: "my_custom_id",
components: [Demo.Components.Hero, {Demo.Components.Position, [x: 5, y: 3], [:hero, :map]}],
children: [potion_entity, sword_entity],
parents: [map_entity]
}
)
```
"""
@doc group: :entities
@spec spawn_entity!(Entity.entity_spec()) :: Entity.t()
def spawn_entity!(spec) do
[entity] = spawn_entities!([spec])
entity
end
@doc """
The same as `spawn_entity!/1` but spawns multiple entities at once.
It takes a list of entity specs as argument and returns a list of Ecspanse.Entity structs.
See `spawn_entity!/1` for more details.
"""
@doc group: :entities
@spec spawn_entities!(list(Entity.entity_spec())) :: list(Entity.t())
def spawn_entities!([]), do: []
def spawn_entities!(list) do
operation = build_operation(:spawn_entities)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Despawns the specified entity and removes all of its components.
It also removes the despawned entity from its parent and child entities, if any.
Due to the potentially large number of components that may be affected by this operation,
it is recommended to run this function in a synchronous system (such as a `frame_start` or `frame_end` system)
to avoid the need to lock all involved components.
## Examples
```elixir
:ok = Ecspanse.Command.despawn_entity!(hero_entity)
```
"""
@doc group: :entities
@spec despawn_entity!(Entity.t()) :: :ok
def despawn_entity!(entity) do
despawn_entities!([entity])
end
@doc """
The same as `despawn_entity!/1` but despawns multiple entities at once.
It takes a list of entities as argument and returns `:ok`. See `despawn_entity!/1` for more details.
"""
@doc group: :entities
@spec despawn_entities!(list(Entity.t())) :: :ok
def despawn_entities!([]), do: :ok
def despawn_entities!(list) do
operation = build_operation(:despawn_entities)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
The same as `despawn_entity!/1` but recursively despawns also all descendant tree of the entity.
This means that it will despawn the children of the entity, and their children, and so on.
It is an efficient way to remove an entire entity tree with just one operation.
Extra attention required for entities with shared children.
See `despawn_entity!/1` for more details.
"""
@doc group: :entities
@spec despawn_entity_and_descendants!(Entity.t()) :: :ok
def despawn_entity_and_descendants!(entity) do
despawn_entities_and_descendants!([entity])
end
@doc """
The same as `despawn_entity_and_descendants!/1` but despawns multiple entities and their descendants at once.
It takes a list of entities as argument and returns `:ok`.
"""
@doc group: :entities
@spec despawn_entities_and_descendants!(list(Entity.t())) :: :ok
def despawn_entities_and_descendants!([]), do: :ok
def despawn_entities_and_descendants!(entities_list) do
descendants_list = entities_descendants(entities_list)
(entities_list ++ descendants_list)
|> List.flatten()
|> Enum.uniq()
|> despawn_entities!()
end
@doc """
Adds a new component to the specified entity.
> #### Info {: .info}
> An entity cannot have multiple components of the same type.
> If an attempt is made to insert a component that already exists for the entity, an error will be raised.
## Examples
```elixir
:ok = Ecspanse.Command.add_component!(hero_entity, Demo.Components.Gold)
:ok = Ecspanse.Command.add_component!(hero_entity, {Demo.Components.Gold, [amount: 5], [:resource, :available]})
```
"""
@doc group: :components
@spec add_component!(Entity.t(), Component.component_spec()) :: :ok
def add_component!(entity, component_spec) do
add_components!([{entity, [component_spec]}])
end
@doc """
The same as `add_component!/2` but adds multiple components to moultiple entities at once.
It takes a list of two element tuples as argument, where the first element of the tuple is the entity
and the second element is a list of component specs.
## Examples
```elixir
:ok = Ecspanse.Command.add_components!([
{inventory_item_entity, [Demo.Components.Sword]},
{hero_entity, [Demo.Components.Position, Demo.Components.Hero]}
])
```
"""
@doc group: :components
@spec add_components!(list({Entity.t(), list(Component.component_spec())})) :: :ok
def add_components!([]), do: :ok
def add_components!(list) do
operation = build_operation(:add_components)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Updates the state of an existing component.
The function takes two arguments: the component struct to update and a keyword list of changes to apply.
## Examples
```elixir
:ok = Ecspanse.Command.update_component!(position_component, x: :12)
```
"""
@doc group: :components
@spec update_component!(current_component :: struct(), state_changes :: keyword()) :: :ok
def update_component!(component, changes_keyword) do
update_components!([{component, changes_keyword}])
end
@doc """
The same as `update_component!/2` but updates multiple components at once.
It takes a list of two element tuples as argument, where the first element of the tuple is the component struct
and the second element is a keyword list of changes to apply.
## Examples
```elixir
:ok = Ecspanse.Command.update_components!([
{position_component, x: 7, y: 9},
{gold_component, amount: 12}
])
```
"""
@doc group: :components
@spec update_components!(list({current_component :: struct(), state_changes :: keyword()})) ::
:ok
def update_components!([]), do: :ok
def update_components!(list) do
operation = build_operation(:update_components)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Removes an existing component from its entity. The components is destroyed.
## Examples
```elixir
:ok = Ecspanse.Command.remove_component!(invisibility_component)
```
"""
@doc group: :components
@spec remove_component!(component :: struct()) :: :ok
def remove_component!(component) do
remove_components!([component])
end
@doc """
The same as `remove_component!/1` but removes multiple components at once.
"""
@doc group: :components
@spec remove_components!(list(component :: struct())) :: :ok
def remove_components!([]), do: :ok
def remove_components!(components) do
operation = build_operation(:remove_components)
command = apply_operation(operation, %Command{}, components)
commit(command)
command.return_result
end
@doc """
Adds an entity as child to a parent entity.
## Examples
```elixir
:ok = Ecspanse.Command.add_child!(hero_entity, sword_entity)
```
"""
@doc group: :relationships
@spec add_child!(Entity.t(), child :: Entity.t()) :: :ok
def add_child!(entity, child) do
add_children!([{entity, [child]}])
end
@doc """
The same as `add_child!/2` but can perform multiple operations at once.
For example, adding multiple children to multiple parents.
It takes a list of two element tuples as argument, where the first element of the tuple is the parent entity
and the second element is a list of children entities.
## Examples
```elixir
:ok = Ecspanse.Command.add_children!([
{hero_entity, [sword_entity]},
{market_entity, [map_entity, potion_entity]}
])
```
"""
@doc group: :relationships
@spec add_children!(list({Entity.t(), children :: list(Entity.t())})) :: :ok
def add_children!([]), do: :ok
def add_children!(list) do
operation = build_operation(:add_children)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Adds a parent entity to a child entity.
## Examples
```elixir
:ok = Ecspanse.Command.add_parent!(sowrd_entity, hero_entity)
```
"""
@doc group: :relationships
@spec add_parent!(Entity.t(), parent :: Entity.t()) :: :ok
def add_parent!(entity, parent) do
add_parents!([{entity, [parent]}])
end
@doc """
The same as `add_parent!/2` but can perform multiple operations at once.
For example, adding multiple parents to multiple children.
It takes a list of two element tuples as argument, where the first element of the tuple is the child entity
and the second element is a list of parent entities.
## Examples
```elixir
:ok = Ecspanse.Command.add_parents!([
{sword_entity, [hero_entity]},
{map_entity, [market_entity, vendor_entity]}
])
```
"""
@doc group: :relationships
@spec add_parents!(list({Entity.t(), parents :: list(Entity.t())})) :: :ok
def add_parents!([]), do: :ok
def add_parents!(list) do
operation = build_operation(:add_parents)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Removes a child entity from a parent entity.
## Examples
```elixir
:ok = Ecspanse.Command.remove_child!(hero_entity, sword_entity)
```
"""
@doc group: :relationships
@spec remove_child!(Entity.t(), child :: Entity.t()) :: :ok
def remove_child!(entity, child) do
remove_children!([{entity, [child]}])
end
@doc """
The same as `remove_child!/2` but can perform multiple operations at once.
For example, removing multiple children from multiple parents.
It takes a list of two element tuples as argument, where the first element of the tuple is the parent entity
and the second element is a list of children entities.
## Examples
```elixir
:ok = Ecspanse.Command.remove_children!([
{hero_entity, [sword_entity]},
{market_entity, [map_entity, potion_entity]}
])
```
"""
@doc group: :relationships
@spec remove_children!(list({Entity.t(), children :: list(Entity.t())})) :: :ok
def remove_children!([]), do: :ok
def remove_children!(list) do
operation = build_operation(:remove_children)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Removes a parent entity from a child entity.
## Examples
```elixir
:ok = Ecspanse.Command.remove_parent!(sword_entity, hero_entity)
```
"""
@doc group: :relationships
@spec remove_parent!(Entity.t(), parent :: Entity.t()) :: :ok
def remove_parent!(entity, parent) do
remove_parents!([{entity, [parent]}])
end
@doc """
The same as `remove_parent!/2` but can perform multiple operations at once.
For example, removing multiple parents from multiple children.
It takes a list of two element tuples as argument, where the first element of the tuple is the child entity
and the second element is a list of parent entities.
## Examples
```elixir
:ok = Ecspanse.Command.remove_parents!([
{sword_entity, [hero_entity]},
{map_entity, [market_entity, vendor_entity]}
])
```
"""
@doc group: :relationships
@spec remove_parents!(list({Entity.t(), parents :: list(Entity.t())})) :: :ok
def remove_parents!([]), do: :ok
def remove_parents!(list) do
operation = build_operation(:remove_parents)
command = apply_operation(operation, %Command{}, list)
commit(command)
command.return_result
end
@doc """
Inserts a new global resource.
> #### Info {: .info}
> An Ecspanse instance can only hold one resource of each type at a time.
> If an attempt is made to insert a resource that already exists, an error will be raised.
## Examples
```elixir
:ok = Ecspanse.Command.insert_resource!({Demo.Resources.Lobby, player_count: 0})
```
"""
@doc group: :resources
@spec insert_resource!(resource_spec :: Resource.resource_spec()) :: resource :: struct()
def insert_resource!(resource_spec) do
operation = build_operation(:insert_resource)
:ok = validate_payload(operation, resource_spec)
command = apply_operation(operation, %Command{}, resource_spec)
command.return_result
end
@doc """
Updates an existing global resource.
## Examples
```elixir
:ok = Ecspanse.Command.update_resource!(lobby_resource, player_count: 1)
```
"""
@doc group: :resources
@spec update_resource!(resource :: struct(), state_changes :: keyword()) ::
updated_resource :: struct()
def update_resource!(resource, state_changes) do
operation = build_operation(:update_resource)
:ok = validate_payload(operation, {resource, state_changes})
command = apply_operation(operation, %Command{}, {resource, state_changes})
command.return_result
end
@doc """
Deletes an existing global resource.
## Examples
```elixir
:ok = Ecspanse.Command.delete_resource!(lobby_resource)
```
"""
@doc group: :resources
@spec delete_resource!(resource :: struct()) :: deleted_resource :: struct()
def delete_resource!(resource) do
operation = build_operation(:delete_resource)
:ok = validate_payload(operation, resource)
command = apply_operation(operation, %Command{}, resource)
command.return_result
end
########
defp build_operation(operation_name) do
unless Process.get(:ecs_process_type) == :system do
raise "Commands can only be executed from a System."
end
# Find the entities_components only once per Command and store it in the Operation
entities_components = Util.list_entities_components()
%Operation{
name: operation_name,
system: Process.get(:system_module),
entities_components: entities_components,
system_execution: Process.get(:system_execution),
locked_components: Process.get(:locked_components)
}
end
# resource payload validation
defp validate_payload(%Operation{name: :insert_resource}, resource_module)
when is_atom(resource_module),
do: :ok
defp validate_payload(%Operation{name: :insert_resource}, {resource_module, state})
when is_atom(resource_module) and is_list(state),
do: :ok
defp validate_payload(%Operation{name: :insert_resource} = operation, value),
do:
raise(
Error,
{operation,
"Expected type `Ecspanse.Resource.resource_spec()` , got: `#{inspect(value)}`"}
)
defp validate_payload(%Operation{name: :update_resource}, {resource, state_changes})
when is_struct(resource) and is_list(state_changes),
do: :ok
defp validate_payload(%Operation{name: :update_resource} = operation, value),
do:
raise(
Error,
{operation,
"Expected a resource state `struct()` and `keyword()` type args, got: `#{inspect(value)}`"}
)
defp validate_payload(%Operation{name: :delete_resource}, resource)
when is_struct(resource),
do: :ok
# Create, Update, Delete components
# recieves [{Entity, opts}]
defp apply_operation(
%Operation{name: :spawn_entities} = operation,
command,
entity_spec_list
) do
entity_spec_list =
Enum.map(entity_spec_list, fn {_, opts} ->
entity_id = Keyword.get(opts, :id, UUID.uuid4())
component_specs = Keyword.get(opts, :components, [])
component_modules =
Enum.map(component_specs, fn
module when is_atom(module) -> module
{module, _} when is_atom(module) -> module
{module, _, _} when is_atom(module) -> module
end)
children_entities = Keyword.get(opts, :children, [])
parents_entities = Keyword.get(opts, :parents, [])
:ok =
validate_required_opts(operation, component_specs, children_entities, parents_entities)
%{
entity: Util.build_entity(entity_id),
component_specs: component_specs,
component_modules: component_modules ++ [Component.Children, Component.Parents],
children_entities: children_entities,
parents_entities: parents_entities
}
end)
v1 =
Task.async(fn ->
entity_ids = Enum.map(entity_spec_list, fn %{entity: entity} -> entity.id end)
:ok = validate_binary_entity_names(operation, entity_ids)
:ok = validate_unique_entity_names(operation, entity_ids)
end)
v2 =
Task.async(fn ->
component_specs =
Enum.map(entity_spec_list, fn %{component_specs: component_specs} -> component_specs end)
|> List.flatten()
:ok = validate_no_relation(operation, component_specs)
end)
v3 =
Task.async(fn ->
relation_entities =
Enum.map(entity_spec_list, fn %{children_entities: children, parents_entities: parents} ->
children ++ parents
end)
|> List.flatten()
:ok = validate_entities(operation, relation_entities)
:ok = validate_entities_exist(operation, relation_entities)
end)
custom_components =
Task.async(fn ->
for %{
entity: entity,
component_specs: component_specs
} <- entity_spec_list do
# Force the creation of the children and parents components on entity creation
component_specs = component_specs ++ [Component.Children, Component.Parents]
upsert_components(operation, entity, component_specs, [])
end
end)
children_and_parents_components =
Task.async(fn ->
for %{
entity: entity,
children_entities: children_entities
} <-
entity_spec_list do
create_children_and_parents(
operation,
entity,
children_entities
)
end
end)
parents_and_children_components =
Task.async(fn ->
for %{
entity: entity,
parents_entities: parents_entities
} <-
entity_spec_list do
create_parents_and_children(
operation,
entity,
parents_entities
)
end
end)
Task.await_many([v1, v2, v3])
components = Task.await(custom_components)
ungrouped_relations =
List.flatten(
Task.await(children_and_parents_components) ++
Task.await(parents_and_children_components)
)
relations = group_added_relations(operation, ungrouped_relations)
# Relation components are always updated, not inserted
%{
command
| return_result: Enum.map(entity_spec_list, fn %{entity: entity} -> entity end),
insert_components: List.flatten(components),
update_components: relations
}
end
defp apply_operation(
%Operation{name: :despawn_entities} = operation,
command,
entities
) do
# Entity relations (children, parents) need to be handled before removing the entity's components
:ok = validate_entities(operation, entities)
table = Util.components_state_ets_table()
children_and_parents_components =
Task.async(fn ->
for entity <- entities do
# it is possible that the children component was already removed
case :ets.lookup(table, {entity.id, Component.Children}) do
[{{_id, Component.Children}, _, %Component.Children{entities: children_entities}}] ->
remove_children_and_parents(operation, entity, children_entities)
_ ->
[]
end
end
end)
parents_and_children_components =
Task.async(fn ->
for entity <- entities do
case :ets.lookup(table, {entity.id, Component.Parents}) do
[{{_id, Component.Parents}, _, %Component.Parents{entities: parents_entities}}] ->
remove_parents_and_children(operation, entity, parents_entities)
_ ->
[]
end
end
end)
deleted_components =
for %Entity{id: id} <- entities do
f =
Ex2ms.fun do
{{entity_id, _component_module}, _component_tags, component_state}
when entity_id == ^id ->
component_state
end
deleted_components_state = :ets.select(table, f)
delete_components(operation, deleted_components_state)
end
# It is possible that more relations for the same entity are updated in the same command.
# If there are more, they need to be grouped, leaving only the relations that are not deleted
ungrouped_relations =
List.flatten(
Task.await(children_and_parents_components) ++ Task.await(parents_and_children_components)
)
relations = group_removed_relations(operation, ungrouped_relations)
%{
command
| return_result: :ok,
update_components: relations,
delete_components: List.flatten(deleted_components)
}
end
# receives a list of {%Entity{} = entity, component_specs}
defp apply_operation(
%Operation{name: :add_components} = operation,
command,
list
) do
entities = Enum.map(list, fn {entity, _component_specs} -> entity end)
v1 =
Task.async(fn ->
:ok = validate_entities(operation, entities)
:ok = validate_entities_exist(operation, entities)
end)
v2 =
Task.async(fn ->
Enum.each(list, fn {entity, component_specs} ->
:ok = validate_components_do_not_exist(operation, entity, component_specs)
end)
end)
v3 =
Task.async(fn ->
component_specs =
Enum.map(list, fn {_entity, component_specs} -> component_specs end)
|> List.flatten()
:ok = validate_no_relation(operation, component_specs)
end)
components =
for {entity, component_specs} <- list do
upsert_components(operation, entity, component_specs, [])
end
Task.await_many([v1, v2, v3])
%{
command
| return_result: :ok,
insert_components: List.flatten(components)
}
end
# Receives a list of updates: [ {%Component{}, state_changes :: keyword()}]
defp apply_operation(
%Operation{name: :update_components} = operation,
command,
updates
) do
v1 =
Task.async(fn ->
component_modules =
Enum.map(updates, fn {component, _state_changes} -> component.__meta__.module end)
:ok = validate_no_relation(operation, component_modules)
end)
v2 =
Task.async(fn ->
Enum.each(updates, fn {component, _state_changes} ->
:ok = validate_is_component(operation, component.__meta__.module)
:ok = validate_component_exists(operation, component)
end)
end)
v3 =
Task.async(fn ->
Enum.each(updates, fn {component, _state_changes} ->
:ok =
validate_locked_component(
operation,
operation.system_execution,
component.__meta__.module
)
end)
end)
components =
for {component, state_changes} <- updates do
state_changes = Keyword.delete(state_changes, :__meta__)
new_component_state = struct(component, state_changes)
:ok = validate_component_state(operation, new_component_state)
{
{component.__meta__.entity.id, component.__meta__.module},
component.__meta__.tags,
new_component_state
}
end
Task.await_many([v1, v2, v3])
%{
command
| return_result: :ok,
update_components: components
}
end
defp apply_operation(
%Operation{name: :remove_components} = operation,
command,
components_state
) do
:ok = validate_no_relation(operation, Enum.map(components_state, & &1.__meta__.module))
deleted_components = delete_components(operation, components_state)
%{
command
| return_result: :ok,
delete_components: deleted_components
}
end
# receives a list of [{%Entity{}, [%ChildrenEntity{}]}]
defp apply_operation(
%Operation{name: :add_children} = operation,
command,
list
) do
v1 =
Task.async(fn ->
entities =
Enum.map(list, fn {entity, children_entities} -> [entity | children_entities] end)
|> List.flatten()
:ok = validate_entities(operation, entities)
:ok = validate_entities_exist(operation, entities)
end)
children_and_parents_components =
for {entity, children_entities} <- list do
create_children_and_parents(
operation,
entity,
children_entities
)
end
Task.await(v1)
relations = group_added_relations(operation, List.flatten(children_and_parents_components))
%{
command
| return_result: :ok,
update_components: relations
}
end
# receives a list of [{%Entity{}, [%ParentEntity{}]}]
defp apply_operation(
%Operation{name: :add_parents} = operation,
command,
list
) do
v1 =
Task.async(fn ->
entities =
Enum.map(list, fn {entity, parents_entities} -> [entity | parents_entities] end)
|> List.flatten()
:ok = validate_entities(operation, entities)
:ok = validate_entities_exist(operation, entities)
end)
parents_and_children_components =
for {entity, parents_entities} <- list do
create_parents_and_children(
operation,
entity,
parents_entities
)
end
Task.await(v1)
relations = group_added_relations(operation, List.flatten(parents_and_children_components))
%{
command
| return_result: :ok,
update_components: relations
}
end
# receives a list of [{%Entity{}, [%ChildrenEntity{}]}]
defp apply_operation(
%Operation{name: :remove_children} = operation,
command,
list
) do
v1 =
Task.async(fn ->
entities =
Enum.map(list, fn {entity, children_entities} -> [entity | children_entities] end)
|> List.flatten()
:ok = validate_entities(operation, entities)
end)
v2 =
Task.async(fn ->
entities =
Enum.map(list, fn {entity, _children_entities} -> entity end)
|> List.flatten()
# not checking if the children entities exist because they might have been deleted
:ok = validate_entities_exist(operation, entities)
end)
children_and_parents_components =
for {entity, children_entities} <- list do
remove_children_and_parents(
operation,
entity,
children_entities
)
end
Task.await_many([v1, v2])
relations = group_removed_relations(operation, List.flatten(children_and_parents_components))
%{
command
| return_result: :ok,
update_components: relations
}
end
# receives a list of [{%Entity{}, [%ParentEntity{}]}]
defp apply_operation(
%Operation{name: :remove_parents} = operation,
command,
list
) do
v1 =
Task.async(fn ->
entities =
Enum.map(list, fn {entity, parents_entities} -> [entity | parents_entities] end)
|> List.flatten()
:ok = validate_entities(operation, entities)
end)
v2 =
Task.async(fn ->
entities =
Enum.map(list, fn {entity, _parents_entities} -> entity end)
|> List.flatten()
# not checking if the parents entities exist because they might have been deleted
:ok = validate_entities_exist(operation, entities)
end)
parents_and_children_components =
for {entity, parents_entities} <- list do
remove_parents_and_children(
operation,
entity,
parents_entities
)
end
Task.await_many([v1, v2])
relations = group_removed_relations(operation, List.flatten(parents_and_children_components))
%{
command
| return_result: :ok,
update_components: relations
}
end
defp apply_operation(%Operation{name: :insert_resource} = operation, command, resource_spec) do
:ok = validate_resource_does_not_exist(operation, resource_spec)
resource_state = upsert_resource(operation, resource_spec)
resource_created_event(resource_state)
%{
command
| return_result: resource_state
}
end
defp apply_operation(
%Operation{name: :update_resource} = operation,
command,
{resource_state, state_changes}
) do
resource_module = resource_state.__meta__.module
:ok = validate_resource_exists(operation, resource_state)
state_changes = Keyword.delete(state_changes, :__meta__)
state =
resource_state
|> Map.from_struct()
|> Map.to_list()
|> Keyword.merge(state_changes)
resource_state = upsert_resource(operation, {resource_module, state})
resource_updated_event(resource_state)
%{
command
| return_result: resource_state
}
end
defp apply_operation(%Operation{name: :delete_resource} = operation, command, resource_state) do
resource_module = resource_state.__meta__.module
:ok =
validate_locked_resource(
operation,
operation.system_execution,
resource_module
)
table = Util.resources_state_ets_table()
:ets.delete(table, resource_module)
resource_deleted_event(resource_state)
%{
command
| return_result: resource_state
}
end
defp upsert_resource(operation, resource_module) when is_atom(resource_module) do
upsert_resource(operation, {resource_module, []})
end
# composes the resource and inserts it into the ets table
# the flow is different than the components, as the resources are managed one at a time
defp upsert_resource(operation, {resource_module, state})
when is_atom(resource_module) and is_list(state) do
:ok = validate_is_resource(operation, resource_module)
:ok =
validate_locked_resource(
operation,
operation.system_execution,
resource_module
)
resource_meta =
struct!(Resource.Meta, %{
module: resource_module
})
resource_state = struct!(resource_module, Keyword.put(state, :__meta__, resource_meta))
:ok = validate_resource_state(operation, resource_state)
# this is stored in the ETS table
resource_with_key = {resource_module, resource_state}
table = Util.resources_state_ets_table()
:ets.insert(table, resource_with_key)
resource_state
end
defp upsert_components(_operation, _entity, [], components), do: components
defp upsert_components(
operation,
entity,
[component_spec | component_specs],
components
) do
component = upsert_component(operation, entity, component_spec)
upsert_components(operation, entity, component_specs, [component | components])
end
# Used also for children and parents. Validating children and parents should be done before this
defp upsert_component(operation, entity, component_module)
when is_atom(component_module) do
upsert_component(operation, entity, {component_module, [], []})
end
defp upsert_component(operation, entity, {component_module, state}) do
upsert_component(operation, entity, {component_module, state, []})
end
defp upsert_component(operation, entity, {component_module, state, tags})
when is_atom(component_module) and is_list(state) do
:ok = validate_is_component(operation, component_module)
:ok = validate_tags(operation, tags)
# VALIDATE THE COMPOENENT IS LOCKED FOR CREATION
:ok =
validate_locked_component(
operation,
operation.system_execution,
component_module
)
# merging tags from compile time with tags from component spec
tags = Enum.uniq(component_module.__component_tags__() ++ tags)
component_meta =
struct!(Component.Meta, %{
entity: entity,
module: component_module,
tags: tags
})
component_state = struct!(component_module, Keyword.put(state, :__meta__, component_meta))
:ok = validate_component_state(operation, component_state)
# this is stored in the ETS table
{{entity.id, component_module}, tags, component_state}
end
# there is no guarantee that the components belong to the same entity
# returns a list of {{entity_id, component_module, tags}, component_state}
defp delete_components(operation, components_state) do
Enum.map(components_state, fn component_state ->
entity = component_state.__meta__.entity
component_module = component_state.__meta__.module
component_tags = component_state.__meta__.tags
:ok =
validate_locked_component(
operation,
operation.system_execution,
component_module
)
{{entity.id, component_module}, component_tags, component_state}
end)
end
# Adds to, or creates the Entity's children and adds to or create the children's parents components
defp create_children_and_parents(operation, entity, []) do
# Create empty children component for entity
empty_entity_children = upsert_children_for(operation, entity, [])
[empty_entity_children]
end
defp create_children_and_parents(operation, entity, children)
when is_list(children) do
entity_children = upsert_children_for(operation, entity, children)
entities_parents =
Enum.map(children, fn child_entity ->
upsert_parents_for(operation, child_entity, [entity])
end)
[entity_children | entities_parents]
end
# Adds to, or creates the Entity's parents and adds to or create the parent's children components
defp create_parents_and_children(operation, entity, []) do
# Create empty parents component for entity
empty_entity_parents = upsert_parents_for(operation, entity, [])
[empty_entity_parents]
end
defp create_parents_and_children(operation, entity, parents)
when is_list(parents) do
entity_parents = upsert_parents_for(operation, entity, parents)
entities_children =
Enum.map(parents, fn parent_entity ->
upsert_children_for(operation, parent_entity, [
entity
])
end)
[entity_parents | entities_children]
end
# Returns Children component with its key
# {{entity_id, Component.Children, []}, %Component.Children{entities: [entity_3, entity_2, entity_1]}}
# it is calling upsert_component which will validate the component is locked for creation
defp upsert_children_for(operation, entity, children) do
table = Util.components_state_ets_table()
case :ets.lookup(table, {entity.id, Component.Children}) do
[{_key, _tags, %Component.Children{entities: existing_children}}] ->
children = Enum.concat(existing_children, children) |> Enum.uniq()
upsert_component(
operation,
entity,
{Component.Children, [entities: children]}
)
[] ->
upsert_component(
operation,
entity,
{Component.Children, [entities: children]}
)
end
end
# Returns a Parents component with its key
# {{entity_id, Component.Parents, []}, %Component.Parents{entities: [entity_3, entity_2, entity_1]}}
# it is calling upsert_component which will validate the component is locked for creation
defp upsert_parents_for(operation, entity, parents) do
table = Util.components_state_ets_table()
case :ets.lookup(table, {entity.id, Component.Parents}) do
[{_key, _tags, %Component.Parents{entities: existing_parents}}] ->
parents = Enum.concat(existing_parents, parents) |> Enum.uniq()
upsert_component(operation, entity, {Component.Parents, [entities: parents]})
[] ->
upsert_component(operation, entity, {Component.Parents, [entities: parents]})
end
end
# Mark for update: Remove children entities from target Entity
# and parent entity from their parents
defp remove_children_and_parents(_operation, _entity, []), do: []
defp remove_children_and_parents(operation, entity, children) when is_list(children) do
table = Util.components_state_ets_table()
entity_children =
case :ets.lookup(table, {entity.id, Component.Children}) do
[{_key, _tags, %Component.Children{entities: existing_children}}] ->
upsert_component(
operation,
entity,
{Component.Children, [entities: existing_children -- children]}
)
[] ->
nil
end
entities_parents =
Enum.map(children, fn child_entity ->
case :ets.lookup(table, {child_entity.id, Component.Parents}) do
[{_key, _tags, %Component.Parents{entities: existing_parents}}] ->
upsert_component(
operation,
child_entity,
{Component.Parents, [entities: existing_parents -- [entity]]}
)
[] ->
nil
end
end)
[entity_children | entities_parents] |> Enum.reject(&is_nil/1)
end
# Mark for update: Remove parent entities from target Entity
# and children entity from their children
defp remove_parents_and_children(_operation, _entity, []), do: []
defp remove_parents_and_children(operation, entity, parents) do
table = Util.components_state_ets_table()
entity_parents =
case :ets.lookup(table, {entity.id, Component.Parents}) do
[{_key, _tags, %Component.Parents{entities: existing_parents}}] ->
upsert_component(
operation,
entity,
{Component.Parents, [entities: existing_parents -- parents]}
)
[] ->
nil
end
entities_children =
Enum.map(parents, fn parent_entity ->
case :ets.lookup(table, {parent_entity.id, Component.Children}) do
[{_key, _tags, %Component.Children{entities: existing_children}}] ->
upsert_component(
operation,
parent_entity,
{Component.Children, [entities: existing_children -- [entity]]}
)
[] ->
nil
end
end)
[entity_parents | entities_children] |> Enum.reject(&is_nil/1)
end
# Grouping relations
# It is possible that more relations for the same entity are updated in the same command.
# If there are more, they need to be grouped
defp group_added_relations(operation, relations) do
relations
|> Enum.group_by(fn {k, _tags, _v} -> k end, fn {_k, _tags, v} -> v end)
|> Enum.map(fn
{k, [v]} ->
{k, [], v}
{{entity_id, module}, values} ->
list = Enum.map(values, fn value -> value.entities end) |> List.flatten() |> Enum.uniq()
entity = Util.build_entity(entity_id)
upsert_component(operation, entity, {module, entities: list})
end)
end
defp group_removed_relations(operation, relations) do
relations
|> Enum.group_by(fn {k, _tags, _v} -> k end, fn {_k, _tags, v} -> v end)
|> Enum.map(fn
{k, [v]} ->
{k, [], v}
{{entity_id, module}, values} ->
list =
Enum.map(values, fn value -> value.entities end)
|> List.flatten()
|> Enum.uniq()
|> select_entities_present_in_all_relations(values)
entity = Util.build_entity(entity_id)
upsert_component(operation, entity, {module, entities: list})
end)
end
defp select_entities_present_in_all_relations(entity_list, relations) do
Enum.filter(
entity_list,
fn entity ->
Enum.all?(
relations,
fn r -> entity in r.entities end
)
end
)
end
# Component CRUD Validations
defp validate_required_opts(operation, [], [], []) do
raise Error,
{operation,
"Expected at least one of the following options in the entity_spec when creating an entity: `components`, `children`, `parents`"}
end
defp validate_required_opts(_operation, _, _, _), do: :ok
defp validate_entities(operation, entities) do
non_enitites = Enum.reject(entities, &match?(%Entity{}, &1))
case non_enitites do
[] ->
:ok
_ ->
raise Error,
{operation,
"Expected a list of `Ecspanse.Entity.t()` types, got: `#{Kernel.inspect(non_enitites)}`"}
end
end
defp validate_binary_entity_names(operation, entity_ids) do
Enum.each(entity_ids, fn entity_id ->
case is_binary(entity_id) do
true ->
:ok
false ->
raise Error,
{operation,
"Entity id `#{entity_id}` must be a binary. Entity ids must be unique."}
end
end)
:ok
end
defp validate_unique_entity_names(operation, entity_ids) do
table = Util.components_state_ets_table()
Enum.each(entity_ids, fn entity_id ->
f =
Ex2ms.fun do
{{^entity_id, _component_module}, _component_tags, _component_state} -> ^entity_id
end
result = :ets.select(table, f, 1)
case result do
{[], _} ->
:ok
{nil, _} ->
:ok
:"$end_of_table" ->
:ok
_ ->
raise Error,
{operation, "Entity id `#{entity_id}` already exists. Entity ids must be unique."}
end
end)
:ok
end
defp validate_entities_exist(_operation, []), do: :ok
defp validate_entities_exist(operation, entities) do
table = Util.components_state_ets_table()
entity_ids = Enum.map(entities, & &1.id)
# All this, just because `when entity_id in ^entity_ids` doesn't work :(
result =
entity_ids
|> Stream.map(fn target_entity_id ->
f =
Ex2ms.fun do
{{entity_id, _component_module}, _tags, _component_state}
when entity_id == ^target_entity_id ->
entity_id
end
# limit to 1. We don't care about the result, just if it exists
:ets.select(table, f, 1)
end)
|> Stream.map(fn
{id_list, _} -> id_list
:"$end_of_table" -> []
end)
|> Enum.concat()
case entity_ids -- result do
[] ->
:ok
{nil, _} ->
:ok
:"$end_of_table" ->
:ok
missing_entity_ids ->
raise Error, {operation, "Entity ids `#{inspect(missing_entity_ids)}` do not exist."}
end
end
defp validate_is_component(operation, component_module) do
Util.validate_ecs_type(
component_module,
:component,
Error,
{operation, "#{inspect(component_module)} is not a Component"}
)
end
defp validate_no_relation(operation, component_specs) do
component_modules =
Enum.map(component_specs, fn
{component_module, _, _} when is_atom(component_module) -> component_module
{component_module, _} when is_atom(component_module) -> component_module
component_module when is_atom(component_module) -> component_module
end)
if component_modules -- [Component.Children, Component.Parent] == component_modules do
:ok
else
raise Error,
{operation,
"Children or Parent relation not expected. Use the the dedicated `children` and `parents` options when creating a new entity. Or use the dedicated `add_child/3` and `add_parent/3` commands."}
end
end
defp validate_component_state(operation, component_state_struct) do
if function_exported?(component_state_struct.__meta__.module, :validate, 1) do
case component_state_struct.__meta__.module.validate(component_state_struct) do
:ok ->
:ok
{:error, error} ->
raise Error,
{operation,
"#{inspect(component_state_struct)} state is invalid. Error: #{inspect(error)}"}
end
else
:ok
end
end
# There is no lock validation for sync systems
defp validate_locked_component(operation, :sync, component_module) do
unless Enum.empty?(operation.locked_components) do
Logger.warning(
"#{inspect(operation)}. Component: #{inspect(component_module)}. There is no need to lock components in Systems that execute synchronously. The values are ignored"
)
end
:ok
end
defp validate_locked_component(operation, :async, component_module) do
locked_components = operation.locked_components
if component_module in locked_components do
:ok
else
raise Error,
{operation,
"#{inspect(component_module)} is not locked. It can not be created or updated in an async System"}
end
end
defp validate_components_do_not_exist(operation, %Entity{id: entity_id}, component_specs) do
entities_components = operation.entities_components
case entities_components[entity_id] do
nil ->
:ok
[] ->
:ok
existing_components when is_list(existing_components) ->
component_modules =
Enum.map(component_specs, fn
{component_module, _, _} when is_atom(component_module) -> component_module
{component_module, _} when is_atom(component_module) -> component_module
component_module when is_atom(component_module) -> component_module
end)
if component_modules -- existing_components == component_modules do
:ok
else
duplicates = component_modules -- component_modules -- existing_components
raise Error,
{operation,
"Components #{inspect(duplicates)} already exist for the entity #{inspect(entity_id)}"}
end
end
end
defp validate_component_exists(operation, component) do
table = Util.components_state_ets_table()
case :ets.lookup(
table,
{component.__meta__.entity.id, component.__meta__.module}
) do
[{_key, _tags, _val}] -> :ok
_ -> raise Error, {operation, "#{inspect(component)} does not exist"}
end
end
# Resources CRUD validations
defp validate_is_resource(operation, {resource_module, _state}) do
validate_is_resource(operation, resource_module)
end
defp validate_is_resource(operation, resource_module) do
Util.validate_ecs_type(
resource_module,
:resource,
Error,
{operation, "#{inspect(resource_module)} is not a Resource"}
)
end
defp validate_resource_state(operation, resource_state_struct) do
if function_exported?(resource_state_struct.__meta__.module, :validate, 1) do
case resource_state_struct.__meta__.module.validate(resource_state_struct) do
:ok ->
:ok
{:error, error} ->
raise Error,
{operation,
"#{inspect(resource_state_struct)} state is invalid. Error: #{inspect(error)}"}
end
else
:ok
end
end
# for now Resources CRUD are supported only in sync Systems!
defp validate_locked_resource(_operation, :sync, _resource_module) do
:ok
end
defp validate_locked_resource(operation, :async, resource_module) do
raise Error,
{operation,
"Resource commands are supported only in sync Systems (startup_systems, frame_start_system, frame_end_system). Resource: #{inspect(resource_module)} "}
end
defp validate_resource_does_not_exist(operation, resource_spec) do
resource_module =
case resource_spec do
{resource_module, _} when is_atom(resource_module) -> resource_module
resource_module when is_atom(resource_module) -> resource_module
end
table = Util.resources_state_ets_table()
case :ets.lookup(table, resource_module) do
[] ->
:ok
_ ->
raise Error,
{operation, "Resource #{inspect(resource_module)} already exists"}
end
end
defp validate_resource_exists(operation, resource) do
table = Util.resources_state_ets_table()
case :ets.lookup(table, resource.__meta__.module) do
[{_key, _val}] -> :ok
_ -> raise Error, {operation, "#{inspect(resource)} does not exist"}
end
end
defp validate_tags(operation, tags) do
unless is_list(tags) do
raise Error, {operation, "Expected tags to be a list of atoms, got: #{inspect(tags)}"}
end
non_tags = Enum.reject(tags, &is_atom/1)
case non_tags do
[] ->
:ok
_ ->
raise Error,
{operation, "Expected tags to be a list of atoms, got: #{inspect(non_tags)}"}
end
end
# Commits
# CRUD operations are handled as a bundle for each command
defp commit(%Command{} = command) do
:ok = commit_inserts(command.insert_components)
:ok = commit_updates(command.update_components)
:ok = commit_deletes(command.delete_components)
end
defp commit_inserts([]), do: :ok
defp commit_inserts(components) do
table = Util.components_state_ets_table()
# do not allow multiple operations for the same component
unique_components = Enum.uniq_by(components, fn {key, _tags, _val} -> key end)
duplicates = components -- unique_components
case duplicates do
[] ->
:ets.insert(table, components)
invalidate =
Task.async(fn ->
# invalidate the cache when inserting components
Util.invalidate_cache()
end)
component_created_events(components)
Task.await(invalidate)
:ok
_ ->
raise "Error inserting components. Duplicate components insert is not allowed in the same Command: #{inspect(duplicates)}"
end
end
defp commit_updates([]), do: :ok
defp commit_updates(components) do
table = Util.components_state_ets_table()
# do not allow multiple operations for the same component
unique_components = Enum.uniq_by(components, fn {key, _tags, _val} -> key end)
duplicates = components -- unique_components
case duplicates do
[] ->
:ets.insert(table, components)
invalidate =
Task.async(fn ->
maybe_invalidate_cache_on_relation_update(components)
end)
component_updated_events(components)
Task.await(invalidate)
:ok
_ ->
raise "Error updating components. Duplicate components update is not allowed in the same Command: #{inspect(duplicates)}"
end
end
defp commit_deletes([]), do: :ok
defp commit_deletes(components) do
table = Util.components_state_ets_table()
Enum.each(components, fn {key, _tags, _val} ->
:ets.delete(table, key)
end)
invalidate =
Task.async(fn ->
# invalidate the cache when deleting components
Util.invalidate_cache()
end)
component_deleted_events(components)
Task.await(invalidate)
:ok
end
defp maybe_invalidate_cache_on_relation_update(components) do
relation_updates =
Enum.any?(components, fn {{_entity_id, module}, _tags, _component} ->
module in [Ecspanse.Component.Children, Ecspanse.Component.Parents]
end)
if relation_updates do
# invalidate the cache when updating Children or Parents
Util.invalidate_query_cache()
end
end
# helper query functions
defp entities_descendants(entities) do
Query.select({Ecspanse.Entity}, for_descendants_of: entities)
|> Query.stream()
|> Stream.map(fn {entity} -> entity end)
|> Enum.to_list()
end
### Special Events
defp component_created_events(components) do
components
|> Enum.map(fn {_key, _tags, component} ->
{{Event.ComponentCreated, component.__meta__.entity.id},
struct!(Event.ComponentCreated, %{
component: component,
inserted_at: System.os_time()
})}
end)
|> add_events()
end
defp component_updated_events(components) do
components
|> Enum.map(fn {_key, _tags, component} ->
{{Event.ComponentUpdated, component.__meta__.entity.id},
struct!(Event.ComponentUpdated, %{
component: component,
inserted_at: System.os_time()
})}
end)
|> add_events()
end
defp component_deleted_events(components) do
components
|> Enum.map(fn {_key, _tags, component} ->
{{Event.ComponentDeleted, component.__meta__.entity.id},
struct!(Event.ComponentDeleted, %{
component: component,
inserted_at: System.os_time()
})}
end)
|> add_events()
end
defp resource_created_event(resource) do
event =
{{Event.ResourceCreated, resource.__meta__.module},
struct!(Event.ResourceCreated, %{
resource: resource,
inserted_at: System.os_time()
})}
add_events([event])
end
defp resource_updated_event(resource) do
event =
{{Event.ResourceUpdated, resource.__meta__.module},
struct!(Event.ResourceUpdated, %{
resource: resource,
inserted_at: System.os_time()
})}
add_events([event])
end
defp resource_deleted_event(resource) do
event =
{{Event.ResourceDeleted, resource.__meta__.module},
struct!(Event.ResourceDeleted, %{
resource: resource,
inserted_at: System.os_time()
})}
add_events([event])
end
defp add_events(events) when is_list(events) do
table = Util.events_ets_table()
:ets.insert(table, events)
end
end