Seelies, un jeu de stratégie persistant
#31
(07-29-2019, 06:18 PM)Meraxes a écrit : Mais du coup par exemple, typiquement pour ce cas de TDD : imaginons qu'à un moment donné tu veuilles que le nb de ressources récupérées ait un petit delta (i.e. qu'il y ait une petite variation avec un random) alors tu devras réécrire ces tests-là ?

Il faudra le réécrire, en s'aidant par exemple de assert_in_delta pour tester qu'un nombre est proche d'un autre dans une certaine limite.
Répondre
#32
OK d'accord.

(oui, donc là, du coup, c'est un exemple concret du fait que c'est le test qui va définir le comportement du jeu ; d'accord).
Répondre
#33
Ces jours-ci j'ai bossé sur le convoyage de ressources.

Citation :DepositsExploitationTest
* test Stopping exploitation bring some resources back to the territory (53.8ms)
* test Unit can't exploit a deposit from a distant area (0.8ms)
* test Unit can't be sent again if already exploiting (0.9ms)
* test Nonexistent unit can't exploit a deposit (0.7ms)
* test Unit starts exploiting the deposit (1.2ms)
* test Unit can't exploit a nonexistent deposit (0.7ms)
* test Exploitation ticks make units bring some resources back to their territory (6.2ms)

ConvoysTest
* test Unit can only leave the convoy its in (4.0ms)
* test Unit can't exploit resources while in a convoy (1.1ms)
* test Unit can't leave a nonexistent convoy (0.8ms)
* test Convoy is prepared on a territory (1.2ms)
* test Convoy can't be prepared on a nonexistent territory (0.7ms)
* test Unit joins the convoy and is no longer available for exploitation (4.0ms)
* test Nonexistent unit can't leave the convoy (0.9ms)
* test Unit can't join a convoy from another territory (1.1ms)
* test Unit can't join a convoy twice (1.0ms)
* test Unit leaves the convoy and becomes available again (4.2ms)
* test Nonexistent unit can't join a convoy (1.2ms)
* test Unit can't join a nonexistent convoy (0.7ms)

UnitsDeploymentTest
* test Unit ID must be unique (0.9ms)
* test Unit can't be deployed on a nonexistent territory (0.6ms)
* test Deploys a starting unit to a territory (0.8ms)

SeeliesTest
* test Game can be started (0.5ms)


Finished in 0.3 seconds
23 tests, 0 failures

Randomized with seed 473868

La suite du programme :
  • ajouter des ressources à un convoi ou en retirer ;
  • faire en sorte que les ressources soient ajouté au territoire quand le convoi arrive ;
  • planifier l'exécution de ce fragment de code à la date prévue d'arrivée ;

Il y a pas mal de cas d'erreur à gérer ici, mais ça ne devrait pas prendre trop de temps. Quand ça sera fait, j'aurai atteint ma première milestone et il sera temps de définir le contenu de la suivante.
Répondre
#34
J'ai terminé le code pour charger/décharger un convoi. Je suis assez content du résultat. Voici les nouveaux cas que je couvre :

Citation :ConvoysTest
  * test Convoy can't be loaded if resources are missing (0.8ms)
  * test Only loaded resources can be unloaded from the convoy (6.1ms)
  * test Loaded resources are moved from the territory to the convoy (1.0ms)
  * test Nonexistent convoy can't be loaded with resources (0.6ms)
  * test Nonexistent convoy can't be unloaded (0.5ms)
  * test Unloaded resources are moved from the convoy to the territory (11.4ms)


Pour représenter les quantités de ressources, je raisonne avec des maps qui sont comme des value objects. J'ai créé des fonctions add et substract pour manipuler ces value objects.

defmodule Seelies.ResourcesQuantity do
  def null do
    %{
      gold: 0,
      silver: 0,
      bronze: 0
    }
  end


  def has_enough?(available_quantity, needed_quantity) do
    Enum.all?(needed_quantity, fn ({resource_type, quantity}) ->
      available_quantity[resource_type] <= quantity
    end)
  end


  def add(base_quantity, added_quantity) do
    Map.merge(base_quantity, added_quantity, fn (_resource_type, count, other_count) ->
      count + other_count
    end)
  end


  def substract(base_quantity, substracted_quantity) do
    Enum.reduce(substracted_quantity, base_quantity, fn ({resource_type, count}, remaining_quantity) ->
      Map.update!(remaining_quantity, resource_type, fn (initial_count) -> initial_count - count end)
    end)
  end
end


Ainsi, je peux m'en servir pour charger/décharger mes convois :

defmodule Seelies.ResourcesLoadedIntoConvoy do
  @derive Jason.Encoder
  defstruct [:game_id, :convoy_id, :resources]

  def apply(game = %Seelies.Game{game_id: game_id, convoys: convoys, territories: territories}, %Seelies.ResourcesLoadedIntoConvoy{game_id: game_id, convoy_id: convoy_id, resources: resources}) do
    %{game |
      convoys: update_in(convoys, [convoy_id, :resources], fn (carried_resources) -> Seelies.ResourcesQuantity.add(carried_resources, resources) end),
      territories: update_in(territories, [convoys[convoy_id].territory_id, :resources], fn (stored_resources) -> Seelies.ResourcesQuantity.substract(stored_resources, resources) end)}
  end
end


defmodule Seelies.LoadResourcesIntoConvoy do
  defstruct [:game_id, :resources, :convoy_id]

  def execute(%Seelies.Game{game_id: game_id, convoys: convoys, territories: territories}, %Seelies.LoadResourcesIntoConvoy{convoy_id: convoy_id, resources: resources}) do
    cond do
      convoys[convoy_id] == nil ->
        {:error, :convoy_not_found}

      Seelies.ResourcesQuantity.has_enough?(territories[convoys[convoy_id].territory_id].resources, resources) ->
        {:error, :not_enough_resources}

      true ->
        %Seelies.ResourcesLoadedIntoConvoy{game_id: game_id, convoy_id: convoy_id, resources: resources}
    end
  end
end

Une fois que la fonction de décision execute de l'action LoadResourcesIntoConvoy a validé que tout était en ordre, elle émet l'événement ResourcesLoadedIntoConvoy. Quand il reçoit cet événement, l'aggregate peut changer d'état grâce à sa fonction de mutation apply.

Il retourne donc le même état game (qui arrive en premier argument), mais en changeant les clés convoys et territories. Dans chacune de ces deux clés on trouve des maps dans lesquelles ont associe respectivement des informations à l'id d'un convoi ou à l'id d'un territoire. On modifie donc uniquement le convoi et le territoire concernés grâce à la fonction update_in qui permet de plonger en profondeur dans des maps imbriqués et on met à jour les quantités de ressources grâce aux fonctions citées plus haut.
Répondre
#35
Code :
def has_enough?(available_quantity, needed_quantity) do
    Enum.all?(needed_quantity, fn ({resource_type, quantity}) ->
      available_quantity[resource_type] <= quantity
    end)
  end

J'arrive pas à piger pourquoi tu utilises <= au lieu de >= ! Mais sinon cool, intéressant. Je trouve que déclencher un évènement qui est garanti d'être persisté c'est aussi un bon emplacement pour envoyer au joueur "il s'est passé ça" ; au lieu de stocker du log dans le state, ou alors dans le process dictionary, ou autres trucs du genre.
Répondre
#36
Parce que je suis un boulet ! 2

J'ai ajouté quelques tests :

test "Resources comparision" do
  assert Seelies.ResourcesQuantity.has_enough?(%{silver: 100, gold: 200}, %{silver: 100, gold: 200})
  assert Seelies.ResourcesQuantity.has_enough?(%{silver: 200, gold: 200}, %{silver: 100, gold: 200})
  refute Seelies.ResourcesQuantity.has_enough?(%{silver: 100, gold: 200}, %{silver: 1000, gold: 200})
end

Et j'en ai profité pour corriger l'implémentation.

def has_enough?(available_quantity, needed_quantity) do
  not Enum.any?(needed_quantity, fn ({resource_type, needed_amount}) ->
    available_quantity[resource_type] < needed_amount
  end)
end

J'utilise any? plutôt que all? pour m'arrêter dès le premier montant insuffisant.
Répondre
#37
J'avais commencé à coder un jeu de stratégie sur Unity (en C#). Pour la gestion des ressources j'avais trouvé très pratique de définir des opérateurs +, -, >=, =<, +=, -= etc.
Ça permettait d'écrire des truc du genre :

public class City {
    public List<Building> Buildings = new List<Building>();
    public resources Resources = Resources.Zero;

    public void Build(Building Building){
        if(this.Resources >= Building.cost){
            this.Resources -= Building.cost;
            this.Buildings.Add(Building);
        }
    }
}
Aucune idée de si ça peut être utile, mais j'avais trouvé que ça rendait le code plus intuitif.
Répondre
#38
Elixir est un langage fonctionnel et non objet mais l'esprit est bien le même. 2
Répondre
#39
(08-02-2019, 10:43 AM)niahoo a écrit : Je trouve que déclencher un évènement qui est garanti d'être persisté c'est aussi un bon emplacement pour envoyer au joueur "il s'est passé ça" ; au lieu de stocker du log dans le state, ou alors dans le process dictionary, ou autres trucs du genre.

C'est l'esprit oui. Seuls les events sont stockés et immutables. C'est pour ça qu'ils sont nommés en conjuguant le temps au passé : ce qui est passé ne peut plus être altéré et il faudra gérer ces événements jusqu'à la fin de vie de l'application.
Répondre
#40
Techiniquement il est possible de définir des opérateurs dans un module Elixir :


defmodule BuildingCost do
  defstruct items: %{}
end

defmodule Resources do
  defstruct items: %{}

  def resources < cost do
    case __MODULE__.>=(resources, cost) do
      :yep -> :nope
      :nope -> :yep
    end
  end

  def resources >= cost do
    contains(resources, cost)
  end

  def resources ~> cost do
    contains(resources, cost)
  end

  def contains(%Resources{} = resources, %BuildingCost{} = cost) do
    :yep
  end

  def inside_module(resources, cost) do
    resources ~> cost
  end
end

defmodule Test do
  def run() do
    [
      # :yep
      Resources.>=(%Resources{}, %BuildingCost{}),
      # :nope
      Resources.<(%Resources{}, %BuildingCost{}),
      # false
      %Resources{} < %BuildingCost{},
      # true
      %Resources{} >= %BuildingCost{},
      # :yep
      Resources.inside_module(%Resources{}, %BuildingCost{})
      # Compile error: undefined function ~>/2
      # %Resources{} ~> %BuildingCost{}
    ]
    |> IO.inspect(pretty: true)
  end

  def run2() do
    import Resources

    [
      # :yep
      %Resources{} ~> %BuildingCost{},
      # :yep
      contains(%Resources{}, %BuildingCost{})
      # Compile error: function >=/2 imported from both Resources and Kernel, call is ambiguous
      # %Resources{} >= %BuildingCost{}
    ]
    |> IO.inspect(pretty: true)
  end
end

Test.run()
Test.run2()


Mais le test run() ci-dessus renvoie la liste suivante : [:yep, :nope, false, true, :yep].
Les deux premiers résultats montrent qu'on peut appeler l'opérateur préfixé de son module, jusque là tout va bien.
Les deux tests suivants montrent que quand utilisés normalement (infixes, sans module), les opérateurs renvoient des booléens. C'est dû au fait qu'ils sont automatiquement importés du module Kernel et le langage ne vérifie pas que l'opérateur soit redéfini dans un module qui définit la struct (ici %Resources{}). Car les strucs sont en fait de simples objets avec une clé __struct__.

Cependant, il existe des opérateurs disponibles qui ne sont pas définis par défaut, et qu'on peut donc utiliser librement : \\, <-, |, ~>>, <<~, ~>, <~, <~>, <|>, <<<, >>>, |||, &&& et 34^. (fuck le smiley, l'opérateur est trois  `^`.

Mais ils sont définis comme des fonctions locales. Le dernier résultat de run() montre qu'appeler Resources.inside_module permet bien d'utiliser l'opérateur ~> au sein du module Resources. Par contre la ligne du dessous dans le test renvoie une erreur de compilation parce que la fonction ~> n'est pas définie.

Dans le test run2(), j'ai importé les fonctions du module Resources (au lieu de l'importer de façon classique au niveau du module Test, ceci afin de ne pas avoir a créer deux modules de tests, et puis ça montre au passage qu'on peut importer des contextes où on veut, puissant mais à utiliser avec parcimonie) ; on peut donc appeler ~> et contains directement.

Par contre, l'opérateur >= déclenche une erreur de compilation : "function >=/2 imported from both Resources and Kernel, call is ambiguous". Kernel est toujours importé automatiquement, mais n'a pas de précédence sur un autre module.

Il serait possible d'importer une sélection : import Resources, only: [{:~>, 2}, {:contains, 2}], mais dans ce cas %Resources{} >= %BuildingCost{} renverrait un booléen et pas :yep.

Allez j'arrête d'étaler ma science ! Mais en conclusion je dirais qu'il vaut mieux utiliser contains dans tous les cas.


Edit: Le fait que %Resources{} < %BuildingCost{} renvoie false est simplement dû à l'ordre alphabétique 2
Répondre




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