Add a buncha shit

This commit is contained in:
Kodi Craft 2025-11-09 10:31:50 +01:00
parent 8412a2b954
commit 31e179a74a
Signed by: kodi
GPG Key ID: 69D9EED60B242822
17 changed files with 475 additions and 2284 deletions

View File

@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -e
if [[ ! -d "/home/kodi/src/diamondtail" ]]; then
echo "Cannot find source directory; Did you move it?"
echo "(Looking for "/home/kodi/src/diamondtail")"
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1
fi
# rebuild the cache forcefully
_nix_direnv_force_reload=1 direnv exec "/home/kodi/src/diamondtail" true
# Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building.
touch "/home/kodi/src/diamondtail/.envrc"
# Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date.
touch -r "/home/kodi/src/diamondtail/.envrc" "/home/kodi/src/diamondtail/.direnv"/*.rc

View File

@ -1 +0,0 @@
/nix/store/7nx4wv523ig8hgws8ihl3fchhxw1f9dv-source

View File

@ -1 +0,0 @@
/nix/store/ndig4f4dx8bmrmyr5vfm19g02r9l9ggm-source

View File

@ -1 +0,0 @@
/nix/store/panm5k6dglq8dixggfz8xf6y36ch9q61-source

View File

@ -1 +0,0 @@
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source

View File

@ -1 +0,0 @@
/nix/store/7ihccg0w57fj748mfin3scnb58f1z75x-nix-shell-env

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ diamondtail-*.tar
/tmp/
.elixir_ls
.direnv

37
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,37 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "mix_task",
"name": "mix (Default task)",
"request": "launch",
"projectDir": "${workspaceRoot}"
},
{
"type": "mix_task",
"name": "mix test",
"request": "launch",
"task": "test",
"taskArgs": [
"--trace"
],
"startApps": true,
"projectDir": "${workspaceRoot}",
"requireFiles": [
"test/**/test_helper.exs",
"test/**/*_test.exs"
]
},
{
"type": "mix_task",
"request": "launch",
"name": "mix run",
"task": "run",
"taskArgs": [ "--no-halt" ],
"projectDir": "${workspaceRoot}"
}
]
}

4
config/config.exs Normal file
View File

@ -0,0 +1,4 @@
import Config
config :logger,
level: :debug

View File

@ -2,6 +2,8 @@ defmodule Diamondtail.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
alias Diamondtail.Genometracker
alias Diamondtail.Population
use Application
@ -10,7 +12,10 @@ defmodule Diamondtail.Application do
children = [
# Starts a worker by calling: Diamondtail.Worker.start_link(arg)
# {Diamondtail.Worker, arg}
{Plug.Cowboy, scheme: :http, plug: Diamondtail.Router, options: [port: 4001]}
{Plug.Cowboy, scheme: :http, plug: Diamondtail.Router, options: [port: 4001]},
{Task.Supervisor, name: Diamondtail.TaskSupervisor},
{Population, 1..10 |> Enum.map(fn _ -> Population.Genome.random end) |> Enum.to_list},
Genometracker
]
# See https://hexdocs.pm/elixir/Supervisor.html

221
lib/diamondtail/brain.ex Normal file
View File

@ -0,0 +1,221 @@
defmodule Diamondtail.Brain do
alias Diamondtail.Population.Genome
require Logger
defmodule GameState do
use TypedStruct
defmodule Snake do
use TypedStruct
typedstruct enforce: true do
field :health, pos_integer()
field :body, [%{x: pos_integer(), y: pos_integer()}]
field :head, %{x: pos_integer(), y: pos_integer()}
field :length, pos_integer()
field :squad, String.t()
end
def from(snake) do
%Snake{
health: snake["health"],
body: snake["body"] |> Enum.map(fn %{"x" => x, "y" => y} -> %{x: x, y: y} end),
head: %{x: snake["head"]["x"], y: snake["head"]["y"]},
length: snake["length"],
squad: snake["squad"]
}
end
end
typedstruct enforce: true do
field :type, :q | :v
field :height, pos_integer()
field :width, pos_integer()
field :food, [%{x: pos_integer(), y: pos_integer()}]
field :hazards, [%{x: pos_integer(), y: pos_integer()}]
field :snakes, %{String.t() => Snake.t()}
field :self, String.t()
end
def from(board, self) do
%GameState{
type: :v,
height: board["height"],
width: board["width"],
food: board["food"] |> Enum.map(fn %{"x" => x, "y" => y} -> %{x: x, y: y} end),
hazards: board["hazards"] |> Enum.map(fn %{"x" => x, "y" => y} -> %{x: x, y: y} end),
snakes: board["snakes"] |> Enum.map(fn %{"id" => id} = snake -> {id, Snake.from(snake)} end) |> Map.new(),
self: self["id"]
}
end
def apply_actions(%GameState{type: :v} = state, move) do
# Apply the move to our snake
Map.put(state, :type, :q)
|> Map.update(:snakes, nil, fn snakes ->
Map.update(snakes, state.self, nil, &apply_action_to_snake(&1, move))
end)
# |> resolve_state()
end
def apply_actions(%GameState{type: :q} = state, moves) do
# Apply every move to the corresponding snake
Map.put(state, :type, :v)
|> Map.update(:snakes, nil, fn snakes ->
Enum.map(snakes, fn {id, s} ->
if Map.has_key?(moves, id) do
{id, apply_action_to_snake(s, moves[id])}
else
{id, s}
end
end)
end)
|> resolve_state()
end
def valid_actions(%GameState{type: :v, snakes: snakes, self: self}) do
if Map.has_key?(snakes, self) do
[:up, :down, :left, :right]
else
[]
end
end
def valid_actions(%GameState{type: :q, snakes: snakes, self: self}) do
non_self_snakes = Map.delete(snakes, self)
for i <- 0..(4 ** map_size(non_self_snakes)) - 1 do
Enum.with_index(non_self_snakes)
|> Enum.map(fn {{id, _}, j} ->
{id, Enum.at([:up, :down, :left, :right], rem(floor(i / (4 ** j)), 4))}
end)
|> Map.new()
end
end
def evaluate_state(genome, state, depth, alpha \\ Float.min_finite, beta \\ Float.max_finite)
def evaluate_state(genome, %GameState{type: :v} = state, 0, _, _) do
# If we're dead, the result is always super duper negative
if Map.has_key?(state.snakes, state.self) do
food = state.food
enemies = Enum.filter(state.snakes, fn {id, _} -> id != state.self end) |> Enum.map(fn {_, s} -> s end)
self = state.snakes[state.self]
Enum.count(enemies) * genome.enemy_alive_weight +
self.health * genome.own_health_weight +
Enum.sum_by(enemies, &(&1.health)) * genome.enemy_health_weight +
(Enum.map(enemies, &(abs(&1.head.x - self.head.x) + abs(&1.head.y - self.head.y))) |> Enum.min(&<=/2, fn -> 0 end)) * genome.enemy_head_distance_weight +
(Enum.map(food, &(abs(&1.x - self.head.x) + abs(&1.y - self.head.y))) |> Enum.min(&<=/2, fn -> 0 end)) * genome.food_distance_weight
else
Float.min_finite()
end
end
# Don't do evaluations on Q states, since they are inherently incomplete
def evaluate_state(genome, %GameState{type: :q} = state, 0, alpha, beta) do
evaluate_state(genome, state, 1, alpha, beta)
end
def evaluate_state(genome, state, depth, alpha, beta) do
# In V states, we are in control, and we will choose the maximum value
# In Q states, opponents are in control, and they will choose the minimum value
op = if state.type == :v, do: &max/2, else: &min/2
init = if state.type == :v, do: Float.min_finite(), else: Float.max_finite()
comp = if state.type == :v, do: &(&1 >= beta), else: &(&1 <= alpha)
valid_actions = valid_actions(state)
if valid_actions == [] do
if Map.has_key?(state.snakes, state.self) do
Float.max_finite()
else
Float.min_finite()
end
else
{score, _alpha, _beta} = valid_actions(state)
|> Enum.reduce_while({init, alpha, beta}, fn action, {score, alpha, beta} ->
score = op.(score, evaluate_state(genome, GameState.apply_actions(state, action), depth - 1, alpha, beta))
if comp.(score) do
{:halt, {score, alpha, beta}}
else
if state.type == :v do
# Maximizing, update alpha
{:cont, {score, max(score, alpha), beta}}
else
# Minimizing, update beta
{:cont, {score, alpha, min(score, beta)}}
end
end
end)
# Apply value discount
score * (1 - (genome.value_discount / 100))
end
end
defp resolve_state(state) do
# Check which snakes have consumed food
Map.update(state, :snakes, nil, fn snakes ->
Enum.map(snakes, fn {id, %{head: head} = snake} ->
if Enum.member?(state.food, head) do
# Snake has gotten food, update it
{id, Map.put(snake, :health, 100)
|> Map.update(:body, nil, fn body -> [List.first(body) | body] end)
|> Map.update(:length, nil, &(&1 + 1))}
else
{id, snake}
end
end) |> Map.new()
end)
# Check which foods have been consumed
|> Map.update(:food, nil, fn food ->
Enum.filter(food, fn pos ->
# Careful, this is the state before being updated above
Enum.any?(state.snakes, fn {_, %{head: head}} -> head != pos end)
end)
end)
# Eliminate snakes
|> Map.update(:snakes, nil, fn snakes ->
Enum.filter(snakes, fn {snake_id, snake} ->
# Die if health is less than or equal to 0
snake.health > 0 &&
# Die if head is out of bounds
snake.head.x >= 0 && snake.head.x < state.width &&
snake.head.y >= 0 && snake.head.y < state.height &&
# Die if head is in a tail
!Enum.any?(state.snakes, fn {_, %{body: body}} -> Enum.member?(body, snake.head) end) &&
# Die if head is on head of a DIFFERENT snake of equal or greater length
!Enum.any?(state.snakes, fn {id, %{head: h, length: l}} -> snake.head == h && snake.length <= l && id != snake_id end)
end) |> Map.new()
end)
end
defp apply_action_to_snake(snake, move) do
delta = case move do
:up -> %{x: 0, y: 1}
:down -> %{x: 0, y: -1}
:left -> %{x: -1, y: 0}
:right -> %{x: 1, y: 0}
end
Map.update(snake, :health, 0, &(&1 - 1))
|> Map.update(:body, nil, fn body -> List.delete_at(body, 0) ++ [snake.head] end)
|> Map.update(:head, nil, fn %{x: x, y: y} -> %{x: x + delta.x, y: y + delta.y} end)
end
end
@spec determine_move(Genome.t(), map(), map()) :: %{move: :up | :down | :left | :right, shout: String.t()}
def determine_move(genome, board, self) do
base_state = GameState.from(board, self)
options = GameState.valid_actions(base_state) |>
Task.async_stream(fn action ->
qstate = GameState.apply_actions(base_state, action)
{action, GameState.evaluate_state(genome, qstate, ceil(genome.search_depth))}
end, ordered: false)
{:ok, {action, score}} = Enum.max_by(options, fn {:ok, {_, s}} -> s end)
%{
move: action,
shout: "#{inspect(options |> Enum.map(fn {:ok, v} -> v end) |> Enum.to_list)} (#{score})"
}
end
end

View File

@ -0,0 +1,38 @@
defmodule Diamondtail.Genometracker do
@moduledoc """
This agent keeps track of which genome is associated with each running game,
it handles taking out a genome from the Population to assign it to a game and reintroducing new genomes after the end of the game.
"""
alias Diamondtail.Population.Genome
alias Diamondtail.Population
use Agent
def start_link(_) do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
@spec game_start({String.t(), String.t()}) :: :ok
def game_start(id) do
genome = Population.take_random()
Agent.update(__MODULE__, &Map.put(&1, id, genome))
end
@spec get_genome({String.t(), String.t()}) :: Genome.t() | nil
def get_genome(id) do
Agent.get(__MODULE__, &Map.get(&1, id))
end
@spec game_end({String.t(), String.t()}, boolean()) :: nil
def game_end(id, victory?) do
genome = Agent.get_and_update(__MODULE__, &Map.pop(&1, id))
# If this genome lost, we will not reintroduce it into the population, effectively "killing" it
if victory? do
# The genome will create three "offspring", amount could be tweaked
for _ <- 0..3 do
Population.add(Genome.mutate(genome))
end
end
end
end

View File

@ -1,5 +1,124 @@
defmodule Diamondtail.Population do
defmodule Genes do
@moduledoc """
This module defines an Agent which maintains a list of every individual in the population, allowing an individual to be taken out or introduced.
"""
use Agent
defmodule Genome do
@moduledoc """
Genomes represents the parameters of an individual, which tweaks the weights in the algorithm and implements the genetic algorithm "learning" functionality
"""
use TypedStruct
typedstruct enforce: true do
field :mutation_chance, float()
field :mutation_max_amplitude, float()
field :enemy_alive_weight, float()
field :own_health_weight, float()
field :enemy_health_weight, float()
field :enemy_head_distance_weight, float()
field :food_distance_weight, float()
field :search_depth, float()
field :value_discount, float()
end
defp random_between(bot, top) do
bot + :rand.uniform() * (top - bot)
end
@spec mutate(Genome.t()) :: Genome.t()
def mutate(genome) do
Map.from_struct(genome)
|> Enum.map(fn {key, value} ->
if :rand.uniform() <= genome.mutation_chance do
{key, value + random_between(-genome.mutation_max_amplitude, genome.mutation_max_amplitude)}
else
{key, value}
end
end)
|> Map.new() |> Map.put(:__struct__, Genome)
end
def average(genomes) do
Enum.reduce(genomes, zero(), fn g, acc ->
Map.from_struct(acc)
|> Enum.map(fn {k, v} ->
{k, v + get_in(g, [Access.key!(k)])}
end)
|> Map.new() |> Map.put(:__struct__, Genome)
end) |> Map.from_struct()
|> Enum.map(fn {k, v} ->
{k, v / length(genomes)}
end) |> Map.new() |> Map.put(:__struct__, Genome)
end
def random() do
%Genome{
mutation_chance: random_between(0.1, 0.2),
mutation_max_amplitude: random_between(0.1, 0.05),
enemy_alive_weight: random_between(-100.0, -200.0),
own_health_weight: random_between(0.7, 1.0),
enemy_health_weight: random_between(-0.8, -0.4),
# both of the following are compared to the nearest object, to avoid overrewarding when there are more enemies/foods
enemy_head_distance_weight: random_between(1, 2), # maximize distance, reward staying away
food_distance_weight: random_between(-2, -1), # minimize distance, reward staying near
search_depth: random_between(3.0, 7.0),
value_discount: random_between(1.0, 10.0) # as percentage
}
end
# Not very computationally efficient but avoids repeating code
def zero() do
Map.from_struct(random())
|> Enum.map(fn {key, _value} ->
{key, 0}
end)
|> Map.new() |> Map.put(:__struct__, Genome)
end
end
def start_link(initial_value) do
Agent.start_link(fn -> initial_value end, name: __MODULE__)
end
@doc """
Takes out and returns a random genome from the population. If the population is empty, a new random genome will be introduced.
"""
@spec take_random() :: Genome.t()
def take_random() do
Agent.get_and_update(__MODULE__, fn list ->
index = :rand.uniform(length(list)) - 1
{got, list} = List.pop_at(list, index)
list = if list == [], do: [Genome.random], else: list
{got, list}
end)
end
@doc """
Adds a genome to the population
"""
@spec add(Genome.t()) :: :ok
def add(genome) do
Agent.update(__MODULE__, &[genome | &1])
end
@doc """
Returns the amount of genomes in the population
"""
@spec size() :: integer()
def size() do
Agent.get(__MODULE__, &length/1)
end
@doc """
Returns the "average" genome, to get a sense of the population's tendencies
"""
@spec avg() :: Genome.t()
def avg() do
Agent.get(__MODULE__, &Genome.average/1)
end
end

View File

@ -1,16 +1,21 @@
defmodule Diamondtail.Router do
require Logger
alias Diamondtail.Population
alias Diamondtail.Brain
alias Diamondtail.Genometracker
use Plug.Router
plug Plug.Parsers,
parsers: [:json],
json_decoder: Jason
plug Plug.Logger
plug :match
plug :dispatch
plug Plug.Parsers,
parsers: [:json],
json_decoder: JSON
get "/" do
send_resp(conn, 200, JSON.encode!(%{
put_resp_content_type(conn, "application/json")
|> send_resp(200, JSON.encode!(%{
apiversion: "1",
author: "kodicraft",
color: "#8cd9ff",
@ -19,4 +24,39 @@ defmodule Diamondtail.Router do
version: Mix.Project.config()[:version]
}))
end
post "/start" do
# Assign a genome to this game
game_id = conn.params["game"]["id"]
player_id = conn.params["you"]["id"]
Genometracker.game_start({game_id, player_id})
Logger.info("Starting new game '#{game_id}' as '#{player_id}'")
put_resp_content_type(conn, "application/json")
|> send_resp(200, JSON.encode!(%{status: "success"}))
end
post "/move" do
game_id = conn.params["game"]["id"]
player_id = conn.params["you"]["id"]
genome = Genometracker.get_genome({game_id, player_id})
board = conn.params["board"]
self = conn.params["you"]
Logger.debug("Processing move #{game_id} as #{player_id} turn #{conn.params["turn"]}")
move = Brain.determine_move(genome, board, self)
Logger.debug("Our move: #{inspect(move)}")
put_resp_content_type(conn, "application/json")
|> send_resp(200, JSON.encode!(move))
end
post "/end" do
game_id = conn.params["game"]["id"]
player_id = conn.params["you"]["id"]
victory? = conn.params["board"]["snakes"] |> Enum.any?(fn %{"id" => snake_id} -> snake_id == player_id end)
Logger.info("Ending game '#{game_id}' as '#{player_id}' after #{conn.params["turn"]} turns. #{if victory?, do: "We won!", else: "We lost."}")
Genometracker.game_end({game_id, player_id}, victory?)
Logger.info("#{Population.size} genomes remain in the population.")
Logger.debug("#{inspect(Population.avg)}")
put_resp_content_type(conn, "application/json")
|> send_resp(200, JSON.encode!(%{thank: "you"}))
end
end

View File

@ -26,6 +26,7 @@ defmodule Diamondtail.MixProject do
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
{:credo, "~> 1.7"},
{:plug_cowboy, "~> 2.0"},
{:typed_struct, "~> 0.3.0"}
]
end
end

View File

@ -12,4 +12,5 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
}