Skip to content

Commit

Permalink
Merge pull request #296 from /issues/268-initial-map-csv
Browse files Browse the repository at this point in the history
Issues/268 initial map csv
  • Loading branch information
jessesnyder authored Nov 6, 2024
2 parents 98370ae + 2d67882 commit 283d0d3
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 14 deletions.
84 changes: 84 additions & 0 deletions dlgr/griduniverse/csv_gridworlds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import re
from collections import defaultdict

from dlgr.griduniverse.experiment import Gridworld

player_regex = re.compile(r"(p\d+)(c\d+)?")
color_names = Gridworld.player_color_names


def matrix2gridworld(matrix):
"""Transform a 2D matrix representing an initial grid state
into the serialized format used by Gridworld.
Example:
+---------------+---------+--------------------+
| w | stone | gooseberry_bush|3 |
| p1c1 | w | w |
| | | p3c2 |
| | p4c2 | |
| big_hard_rock | w | p2c1 |
+---------------+---------+--------------------+
Explanation:
- "w": a wall
- "stone": item defined by item_id "stone" in game_config.yml
- "gooseberry_bush|3": similar to the above, with the added detail
that the item has 3 remaining uses
- "p2c1": player ID 2, who is on team (color) 1
- Empty cells: empty space in the grid
"""
result = defaultdict(list)

result["rows"] = len(matrix)
if matrix:
result["columns"] = len(matrix[0])
else:
result["columns"] = 0

for row_num, row in enumerate(matrix):
for col_num, cell in enumerate(row):
# NB: we use [y, x] format in GU!! (╯°□°)╯︵ ┻━┻
position = [row_num, col_num]
cell = cell.strip()
player_match = player_regex.match(cell)
if not cell:
# emtpy
continue
if cell == "w":
result["walls"].append(position)
elif player_match:
id_str, color_str = player_match.groups()
player_id = id_str.replace("p", "")
player_data = {
"id": player_id,
"position": position,
}
if color_str is not None:
player_color_index = int(color_str.replace("c", "")) - 1
try:
player_data["color"] = color_names[player_color_index]
except IndexError:
max_color = len(color_names)
raise ValueError(
f'Invalid player color specified in "{cell}" at postion {position}. '
f"Max color value is {max_color}, "
f"but you specified {player_color_index + 1}."
)

result["players"].append(player_data)
else:
# assume an Item
id_and_maybe_uses = [s.strip() for s in cell.split("|")]
item_data = {
"id": len(result["items"]) + 1,
"item_id": id_and_maybe_uses[0],
"position": position,
}
if len(id_and_maybe_uses) == 2:
item_data["remaining_uses"] = int(id_and_maybe_uses[1])
result["items"].append(item_data)

return dict(result)
40 changes: 29 additions & 11 deletions dlgr/griduniverse/experiment.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""The Griduniverse."""

import collections
import csv
import datetime
import itertools
import json
Expand Down Expand Up @@ -94,6 +95,7 @@
"difi_group_label": unicode,
"difi_group_image": unicode,
"fun_survey": bool,
"map_csv": unicode,
"pre_difi_question": bool,
"pre_difi_group_label": unicode,
"pre_difi_group_image": unicode,
Expand Down Expand Up @@ -507,6 +509,18 @@ def compute_payoffs(self):
player.payoff *= inter_proportions[player.color_idx]
player.payoff *= self.dollars_per_point

def load_map(self, csv_file_path):
with open(csv_file_path) as csv_file:
grid_state = self.csv_to_grid_state(csv_file)
self.deserialize(grid_state)

def csv_to_grid_state(self, csv_file):
from .csv_gridworlds import matrix2gridworld # avoid circular import

reader = csv.reader(csv_file)
grid_state = matrix2gridworld(list(reader))
return grid_state

def build_labyrinth(self):
if self.walls_density and not self.wall_locations:
start = time.time()
Expand Down Expand Up @@ -561,7 +575,7 @@ def deserialize(self, state):
self.columns,
)
)
self.round = state["round"]
self.round = state.get("round", 0)
# @@@ can't set donation_active because it's a property
# self.donation_active = state['donation_active']

Expand Down Expand Up @@ -857,7 +871,7 @@ class Item:
"""

item_config: dict
id: int = field(default_factory=lambda: uuid.uuid4())
id: int = field(default_factory=lambda: uuid.uuid4().int)
creation_timestamp: float = field(default_factory=time.time)
position: tuple = (0, 0)
remaining_uses: int = field(default=None)
Expand Down Expand Up @@ -1353,7 +1367,7 @@ def handle_connect(self, msg):
return

logger.info("Client {} has connected.".format(player_id))
client_count = len(self.grid.players)
client_count = len(self.node_by_player_id)
logger.info("Grid num players: {}".format(self.grid.num_players))
if client_count < self.grid.num_players:
participant = self.session.query(dallinger.models.Participant).get(
Expand All @@ -1370,13 +1384,14 @@ def handle_connect(self, msg):
# We use the current node id modulo the number of colours
# to pick the user's colour. This ensures that players are
# allocated to colours uniformly.
self.grid.spawn_player(
id=player_id,
color_name=self.grid.limited_player_color_names[
node.id % self.grid.num_colors
],
recruiter_id=participant.recruiter_id,
)
if player_id not in self.grid.players:
self.grid.spawn_player(
id=player_id,
color_name=self.grid.limited_player_color_names[
node.id % self.grid.num_colors
],
recruiter_id=participant.recruiter_id,
)
else:
logger.info("No free network found for player {}".format(player_id))

Expand Down Expand Up @@ -1721,7 +1736,10 @@ def send_state_thread(self):
def game_loop(self):
"""Update the world state."""
gevent.sleep(0.1)
if not self.config.get("replay", False):
map_csv_path = self.config.get("map_csv", None)
if map_csv_path is not None:
self.grid.load_map(map_csv_path)
elif not self.config.get("replay", False):
self.grid.build_labyrinth()
logger.info("Spawning items")
for item_type in self.item_config.values():
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@
"bugs": {
"url": "https://github.com/Dallinger/Griduniverse/issues"
},
"homepage": "https://github.com/Dallinger/Griduniverse#readme"
"homepage": "https://github.com/Dallinger/Griduniverse#readme",
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
5 changes: 5 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,14 @@ def stub_config():
}
from dallinger.config import Configuration, default_keys

from dlgr.griduniverse.experiment import GU_PARAMS

config = Configuration()
for key in default_keys:
config.register(*key)
for key in GU_PARAMS.items():
config.register(*key)

config.extend(defaults.copy())
# Patch load() so we don't update any key/value pairs from actual files:
# (Note: this is blindly cargo-culted in from dallinger's equivalent fixture.
Expand Down
73 changes: 71 additions & 2 deletions test/test_griduniverse.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
Tests for `dlgr.griduniverse` module.
"""
import collections
import csv
import json
import time
import uuid

import mock
import pytest
Expand Down Expand Up @@ -48,7 +48,7 @@ def test_initialized_with_some_default_values(self, item_config):
item = self.subject(item_config)

assert isinstance(item.creation_timestamp, float)
assert isinstance(item.id, uuid.UUID)
assert isinstance(item.id, int)
assert item.position == (0, 0)

def test_instance_specific_values_can_be_specified(self, item_config):
Expand Down Expand Up @@ -220,6 +220,65 @@ def test_loop_spawns_items(self, loop_exp_3x):
[i["item_count"] for i in exp.item_config.values()]
)

def test_builds_grid_from_csv_if_specified(self, tmpdir, loop_exp_3x):
exp = loop_exp_3x
grid_config = [["w", "stone", "", "gooseberry_bush|3", "p1c2"]]
# Grid size must match incoming data, so update the gridworlds's existing
# settings:
exp.grid.rows = len(grid_config)
exp.grid.columns = len(grid_config[0])

csv_file = tmpdir.join("test_grid.csv")

with csv_file.open(mode="w") as file:
writer = csv.writer(file)
writer.writerows(grid_config)

# active_config.extend({"map_csv": csv_file.strpath}, strict=True)
exp.config.extend({"map_csv": csv_file.strpath}, strict=True)

exp.game_loop()

state = exp.grid.serialize()

def relevant_keys(dictionary):
relevant = {"id", "item_id", "position", "remaining_uses", "color"}
return {k: v for k, v in dictionary.items() if k in relevant}

# Ignore keys added by experiment execution we don't care about and/or
# which are non-deterministic (like player names):
state["items"] = [relevant_keys(item) for item in state["items"]]
state["players"] = [relevant_keys(player) for player in state["players"]]

assert state == {
"columns": 5,
"donation_active": False,
"items": [
{
"id": 1,
"item_id": "stone",
"position": [0, 1],
"remaining_uses": 1,
},
{
"id": 2,
"item_id": "gooseberry_bush",
"position": [0, 3],
"remaining_uses": 3,
},
],
"players": [
{
"color": "YELLOW",
"id": "1",
"position": [0, 4],
}
],
"round": 0,
"rows": 1,
"walls": [[0, 0]],
}

def test_loop_serialized_and_saves(self, loop_exp_3x):
# Grid serialized and added to DB session once per loop
exp = loop_exp_3x
Expand Down Expand Up @@ -285,6 +344,16 @@ def test_handle_connect_adds_player_to_grid(self, exp, a):
exp.handle_connect({"player_id": participant.id})
assert participant.id in exp.grid.players

def test_handle_connect_uses_existing_player_on_grid(self, exp, a):
participant = a.participant()
exp.grid.players[participant.id] = Player(
id=participant.id, color=[0.50, 0.86, 1.00], location=[10, 10]
)
exp.handle_connect({"player_id": participant.id})
assert participant.id in exp.node_by_player_id
assert len(exp.grid.players) == 1
assert len(exp.node_by_player_id) == 1

def test_handle_connect_is_noop_for_spectators(self, exp):
exp.handle_connect({"player_id": "spectator"})
assert exp.node_by_player_id == {}
Expand Down
Loading

0 comments on commit 283d0d3

Please sign in to comment.