Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issues/268 initial map csv #296

Merged
merged 8 commits into from
Nov 6, 2024
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result["columns"] = len(matrix[0]) if matrix else 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what the suggestion is here?

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm.... result is already a defaultdict. Can it be returned without converting it to dict?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think a dict is a clearer and simpler interface (since this is the key return value) than a defaultdict, so I probably would have made this choice anyway. To me, returning a defaultdict suggests I will be modifying the 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return matrix2gridworld(list(reader))


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": "yarn@1.22.22+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
Loading