Seelies, un jeu de stratégie persistant
#11
La fonction apply ne doit pas être rejetée ou planter : les contrôles doivent être effectués dans la fonction de décision.

Dans le cas où il y a un aggregate par joueur, la commande qui informe de la transaction rentre dans la fonction de décision, qui se charge de toutes les vérifications. Elle émet un event PlayersTradedItems avec des infos comme buyer_id, vendor_id, items, cost… Cet event peut alors alors être géré par un handler qui va envoyer une commande à chaque aggregate.

Si l'eventual consistency pose un problème, tu peux demander qu'elle soit forte (au prix d'une latence puisque les aggregates bloqueront jusqu'à ce que tous les events handlers aient fait leur travail.

Après, méfiance : je ne sais pas si c'est la bonne façon de modéliser. Tu aurais peut-être une réponse plus fiable sur le salon Gitter de Commanded.
Répondre
#12
Merci pour le lien.

Le problème que j'évoque est toujours présent avec ce que tu dis, la fonction de décision n'a pas accès aux state courant de l'autre joueur, si ? Elle a un "vieux" state ... De façon concurrente, l'autre joueur peut vendre ses endives à quelqu'un d'autre. Mais bref, j'arrête de t'embêter avec ça car je ne vais pas l'utiliser sur mes projets actuels 2
Répondre
#13
Ah oui, tu as raison. D'après ce que j'ai pu lire, quand on en vient à se demander comment altérer deux aggregates avec une seule commande, c'est que l'aggregate n'est pas bien défini. Peut-être faut-il un aggregate pour les transactions dans ton cas.
Répondre
#14
Avant ce que j'avais prévu c'est que le joueur state du joueur qui achète immobilise l'argent, puis on envoie une demande de transaction à l'autre state qui la valide ou la rejette (et diminue sa quantité de patates tout en augmentant sa thune), et enfin le premier state reçoit la réponse, auquel cas il démobilise l'argent (transaction refusée) ou le supprime et remplit son frigo.

Mais ça complexifie trop à mon goût, surtout que je veux que le marché affiche les offres de ventes et d'achat, donc il faut synchroniser le tout, etc. c'est beaucoup de boulot et de cas à gérer pour un truc que je veux simple .. Mais bon c'est une solution.

À l'occasion j'aimerais voir un `inspect` du state d'une partie en cours !
Répondre
#15
A mon avis dans cette situation il faut un aggregate Market qui s'occupe du transactionnel entre les joueurs.

Je montrerai effectivement l'évolution du state. Je pense que ça va vite devenir massif ! :p

Cet après-midi je présenterai ma démarche TDD pour implémenter le code qui affecter des unités à l'exploitation de gisements.
Répondre
#16
Je vais implémenter l'envoi d'une unité pour exploiter un gisement de ressources.

J'écris un premier scénario de test.


test "Can't exploit a deposit from a distant area" do
  :ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
  :ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
  {:error, :deposit_is_too_far} = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1", deposit_id: "d5", time: 60 })
end

J'ai déjà testé et implémenté le dispatch des commandes StartGame et DeployStartingUnit donc je ne recommence pas. J'utilise le même plateau de jeu dans la plupart de mes tests.

Pour commencer, je crée un scénario qui doit mener à une erreur. J'essaye ici d'affecter une unité à un gisement présent sur une zone trop éloignée du territoire où se situe l'unité.

A la première exécution des tests, ça explose de partout : la commande UnitStartsExploitingDeposit n'existe pas. Je crée la commande, avec les clés dont j'ai besoin.



defmodule Seelies.UnitStartsExploitingDeposit do
  defstruct [:game_id, :unit_id, :deposit_id, :time]
end

Exécution suivante : l'aggregate game n'a aucune fonction de décision pour cette commande. Je la définis.


defmodule Seelies.Game do
  # ...

  def execute(game, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: time }) do
    []
  end

  # ...
end

Nouvelle exécution des tests, le dispatch retourne :ok au lieu de {:error, :deposit_is_too_far}. Je corrige pour que ça marche.


def execute(game, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: time }) do
  {:error, :deposit_is_too_far}
end

Le test passe ! Je peux passer au scénario suivant. On se rend bien compte que je joue les idiots : mon prochain test va mettre en avant qu'on ne doit pas toujours retourner cette erreur.
Comme ce test va rester comme il est, je vais bien devoir contrôler véritablement si le gisement est trop loin. Le prochain test va me forcer à affiner l'implémentation de ma fonction de décision.
En pratique, on peut tout à fait commencer dès maintenant à écrire les conditions.

Mon cas d'erreur suivante : le gisement n'est carrément pas sur le plateau.


test "Can't exploit a nonexistent deposit" do
  :ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
  :ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
  {:error, :deposit_not_found} = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1", deposit_id: "d1000", time: 60 })
end

Bien sûr, l'exécution de ce test échoue puisque l'erreur retournée n'est pas la bonne. Je peux commencer à écrire une implémentation de bonne foi.


defmodule Seelies.Game do
  # ...
  def execute(%Seelies.Game{units: units, board: board}, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: _time }) do
    cond do
      not Seelies.Board.has_deposit?(board, deposit_id) ->
        {:error, :deposit_not_found}

      not Seelies.Board.is_deposit_in_range?(board, deposit_id, units[unit_id].territory_id) ->
        {:error, :deposit_is_too_far}
    end
  end
  # ...
end

defmodule Seelies.Board do
  # ...
  def has_deposit?(board, deposit_id) do
    Enum.any?(board.areas, fn ({_area_id, area}) -> Map.has_key?(area.deposits, deposit_id) end)
  end


  def is_deposit_in_range?(board, deposit_id, territory_id) do
    area_ids = board.territories[territory_id].area_ids
    Enum.any?(area_ids, fn (area_id) ->
      Map.has_key?(board.areas[area_id].deposits, deposit_id)
    end)
  end
end

Comme on peut voir, je commence à extraire des choses (units et board) de l'état de l'aggregate, qui est transmis comme premier argument à la fonction de décision.
En écrivant ça, je me suis dit que ça vaudrait aussi le coup de vérifier que l'unité existe bien. Donc j'écris un scénario de test.


test "Can't send a nonexistant unit to exploit a deposit" do
  :ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
  :ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
  {:error, :unit_not_found} = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1000", deposit_id: "d1", time: 60 })
end

Comme on peut voir, les tests se ressemblent beaucoup, ce n'est donc pas bien fastidieux à écrire.


defmodule Seelies.Game do
  # ...
  def execute(%Seelies.Game{units: units, board: board}, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: _time }) do
    cond do
      units[unit_id] == nil ->
        {:error, :unit_not_found}

      not Seelies.Board.has_deposit?(board, deposit_id) ->
        {:error, :deposit_not_found}

      not Seelies.Board.is_deposit_in_range?(board, deposit_id, units[unit_id].territory_id) ->
        {:error, :deposit_is_too_far}
    end
  end
  # ...
end

C'est mieux. Parfois on sent venir les cas, parfois on se les prend par surprise. A chacun de placer le curseur sur ce qu'il convient de faire entre avoir un code très défensif ou non.
Est-ce que la validité des arguments est contrôlée préalablement au dispatch des messages ? Ou bien est-ce la fonction de décision qui doit faire tout ce travail de contrôle ?
Dans mon cas, puisque la fonction de décision dispose de l'état de l'aggregate, il semble logique qu'elle contrôle tout ce qui est du ressort de l'aggregate.

Je peux donc maintenant passer aux cas de fonctionnement normal de cette commande.


test "Unit starts exploiting the deposit" do
  :ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
  :ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
  :ok = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1", deposit_id: "d1", time: 60 })

  assert_receive_event(Seelies.UnitStartedExploitingDeposit, fn (event) ->
    assert event.game_id == 42
    assert event.unit_id == "u1"
    assert event.deposit_id == "d1"
    assert event.time == 60
  end)
end

Cette fois, ça ne fonctionne pas parce que notre condition n'a pas de "else".


def execute(%Seelies.Game{game_id: game_id, units: units, board: board}, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: time }) do
  cond do
    units[unit_id] == nil ->
      {:error, :unit_not_found}

    not Seelies.Board.has_deposit?(board, deposit_id) ->
      {:error, :deposit_not_found}

    not Seelies.Board.is_deposit_in_range?(board, deposit_id, units[unit_id].territory_id) ->
      {:error, :deposit_is_too_far}

    true ->
      %Seelies.UnitStartedExploitingDeposit{game_id: game_id, unit_id: unit_id, deposit_id: deposit_id, time: time}
  end
end

Ensuite, ça ne marchera pas parce que l'event UnitStartedExploitingDeposit n'existe pas, je le crée :


defmodule Seelies.UnitStartedExploitingDeposit do
  @derive Jason.Encoder
  defstruct [:game_id, :unit_id, :deposit_id, :time]
end

Maintenant ça ne marche pas parce que l'aggregate ne dispose pas d'une fonction de mutation pour cet event.


defmodule Seelies.Game do
  # ...
  def apply(game = %Seelies.Game{}, %Seelies.UnitStartedExploitingDeposit{game_id: game_id, unit_id: unit_id, deposit_id: deposit_id, time: time}) do
    game
  end
  # ...
end

Là je gère l'event mais je n'en fais rien. On note que l'aggregate a autant de fonctions de décision (execute) et de mutation (apply) qu'il y a de commandes et d'events à gérer.
Comme le langage Elixir est compilé, on n'a pas besoin de faire un "switch" à l'intérieur pour savoir à quel type de commande ou d'event on à affaire, ça se fait à l'entrée dans la fonction.
Ça s'appelle du "pattern matching", et ça ressemble un peu à la surcharge qu'on peut trouver en Java ou C++ : on appelle la bonne fonction selon la tronche des arguments qui rentrent.

Ici, notre fonction de mutation ne mute rien du tout : la fonction reçoit son ancien état ainsi que l'événement et doit retourner le nouvel état. Ici, je retourne directement l'ancien état, donc l'aggregate reste dans le même état.

Jusque là, j'ai bien testé le comportement de ma fonction de décision, sans trop me soucier de comment elle y arrivait. Généralement, on évite de trop s'intéresser à l'implémentation de la fonctionnalité testée.
Le but des tests, c'est de s'assurer que la fonctionnalité… fonctionne, même après avoir modifié son implémentation (que ce soit pour optimiser le code, le simplifier, chasser des bugs etc.). Le plus souvent, les tests déjà écrits ne bougent pas, ou peu.
Si on commence à tester l'implémentation plutôt que le comportement, on risque de casser des tests et donc de se donner du travail supplémentaire, en plus de risquer une fragilisation de la suite de tests : est-ce que les tests que j'ai dû modifier testent toujours bien les comportements ?

Me voici donc à un moment où il faut choisir : tester l'état de l'aggregate (la structure Seelies.Game) ou bien tester son comportement ?
Pour éviter de tester la structure directement, je vais construire un module dont le but sera de modifier cette structure, et ce module aura sa propre suite de tests. Ça implique que je vais devoir éviter de toucher à la structure "manuellement" (sans passer par ce module).
Ici, mon module devra avoir une fonction qui sert à signaler que telle unité exploite telle ressource. Il me faudra aussi des fonctions pour interroger l'état et savoir si une unité est déjà en train d'exploiter, pour arrêter l'exploitation etc.

Déjà, cet article vous aura permis de voir ma façon d'approcher TDD (ici dans une architecture avec event sourcing, nouvelle pour moi).

Pour résumer :
- je commence par écrire un test, il ne passe pas (ça s'écrit généralement en rouge dans le terminal dans lequel on exécute les tests).
- j'écris le minimum de code nécessaire pour que le test passe (ça s'écrit alors en vert).
- je modifie le code au besoin (optimiser, clarifier, etc.) et en toute confiance : le test doit rester vert.
- je recommence.

Dans le jargon TDD, on parle parfois de cycle "red, green, refactor" pour désigner ça.

Ce n'est pas parfait mais ça permet d'avoir confiance en son code. Ça sert également de documentation : si on laisse de côté le projet un moment, au retour il suffit de lancer et lire les tests pour savoir où on en était.
C'est intéressant de coupler ça un outil de source control comme Git : on commit quand les tests passent au vert et on peut trifouiller le cœur léger pour améliorer le code : si on casse tout, il suffit de revenir à la version précédente.
Répondre
#17
Hey sympa tout ça 2 ça fait quoi DeployStartingUnit en termes de gameplay ? ça spawn ?

Citation :je vais construire un module dont le but sera de modifier cette structure

N'est-ce pas le rôle de Board qui contient déjà les fonctions de lecture du state ?
Répondre
#18
(07-29-2019, 10:18 AM)niahoo a écrit : Hey sympa tout ça 2 ça fait quoi DeployStartingUnit en termes de gameplay ? ça spawn ?

Ça ajoute une unité à la partie.


def apply(game = %Seelies.Game{units: units}, %Seelies.StartingUnitDeployed{territory_id: territory_id, unit_id: unit_id, unit_type: unit_type}) do
unit = %{unit_id: unit_id, unit_type: unit_type, territory_id: territory_id}
%{game | units: Map.put(units, unit_id, unit)}
end


(07-29-2019, 10:18 AM)niahoo a écrit :
Citation :je vais construire un module dont le but sera de modifier cette structure

N'est-ce pas le rôle de Board qui contient déjà les fonctions de lecture du state ?

Tout à fait, mais pour le board : c'est un sous ensemble du state de Game pour stocker la map. C'est donc plutôt statique. Si j'utilisais la même map sur plusieurs parties, je pourrais sans problème le sortir du state de Game.

Puisque c'est la structure liée au module Game que je manipule, je pourrais utiliser des fonctions privée dans ce module, mais avec toutes les définitions de execute et de apply, ça risque de vite devenir très gros.
Répondre
#19
J'me demande si ça serait pas plus clair d'avoir les fonctions execute et apply dans les modules de la commande et de l'event, et elles appelleraient les fonctions de Game/Board uniquement.
Répondre
#20
J'aime beaucoup l'idée de regrouper par bounded contexts (pilotage de la partie, exploitation des ressources, etc.) plutôt que par type technique (commandes, events, etc.).

Par contre ça m'oblige à faire plein de proxy dans le module de mon aggregate (Game) :


defmodule Seelies.Game do
  def execute(game = %Seelies.Game{}, command = %Seelies.StartGame{}) do
    Seelies.StartGame.execute(game, command)
  end


  def apply(game = %Seelies.Game{}, event = %Seelies.GameStarted{}) do
    Seelies.GameStarted.apply(game, event)
  end
end
Répondre




Utilisateur(s) parcourant ce sujet : 1 visiteur(s)