diff --git a/dlgr/griduniverse/csv_gridworlds.py b/dlgr/griduniverse/csv_gridworlds.py new file mode 100644 index 00000000..b5cf66bf --- /dev/null +++ b/dlgr/griduniverse/csv_gridworlds.py @@ -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) diff --git a/dlgr/griduniverse/experiment.py b/dlgr/griduniverse/experiment.py index 9792a113..4d649144 100644 --- a/dlgr/griduniverse/experiment.py +++ b/dlgr/griduniverse/experiment.py @@ -1,6 +1,7 @@ """The Griduniverse.""" import collections +import csv import datetime import itertools import json @@ -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, @@ -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() @@ -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'] @@ -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) @@ -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( @@ -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)) @@ -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(): diff --git a/package.json b/package.json index 935e8859..ac54df54 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/test/conftest.py b/test/conftest.py index bcc17403..dc17169b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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. diff --git a/test/test_griduniverse.py b/test/test_griduniverse.py index 9eb3559d..6af39c79 100755 --- a/test/test_griduniverse.py +++ b/test/test_griduniverse.py @@ -2,9 +2,9 @@ Tests for `dlgr.griduniverse` module. """ import collections +import csv import json import time -import uuid import mock import pytest @@ -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): @@ -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 @@ -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 == {} diff --git a/test/test_gridworld.py b/test/test_gridworld.py index 3630acd0..093d9dc0 100644 --- a/test/test_gridworld.py +++ b/test/test_gridworld.py @@ -157,3 +157,148 @@ def test_instructions(self, gridworld): # Just test something basic html = gridworld.instructions() assert "🫐 Gooseberry (3 points)" in html + + +class TestMatrix2SerializedGridworld(object): + """Tests for converting a list of lists extracted from matrix + representation of initial grid state into the format used in + Gridworld [de]serialization. + """ + + @property + def subject(self): + from dlgr.griduniverse.csv_gridworlds import matrix2gridworld + + return matrix2gridworld + + def test_one_row(self): + csv = [["w", "stone", "", "gooseberry_bush|3", "p1c2"]] + + result = self.subject(csv) + + assert result == { + "columns": 5, + "items": [ + {"id": 1, "item_id": "stone", "position": [0, 1]}, + { + "id": 2, + "item_id": "gooseberry_bush", + "position": [0, 3], + "remaining_uses": 3, + }, + ], + "players": [{"color": "YELLOW", "id": "1", "position": [0, 4]}], + "rows": 1, + "walls": [[0, 0]], + } + + def test_to_demonstrate_orientation(self): + csv = [ + ["top-left", "top-right"], + ["bottom-left", "bottom-right"], + ] + + result = self.subject(csv) + + assert result["items"] == [ + {"id": 1, "item_id": "top-left", "position": [0, 0]}, + {"id": 2, "item_id": "top-right", "position": [0, 1]}, + {"id": 3, "item_id": "bottom-left", "position": [1, 0]}, + {"id": 4, "item_id": "bottom-right", "position": [1, 1]}, + ] + + def test_multirow(self): + csv = [ + ["w", "stone", "", "gooseberry_bush|3", "p1c1"], + ["", "w", "", "", ""], + ["", "p2c1", "w", "", ""], + ["", "", "", "w", ""], + ["", "", "", "p3c2", "w"], + ["", "", "", "", "w"], + ["gooseberry_bush|4", "", "", "", ""], + ["", "big_hard_rock", "", "", ""], + ["", "p4c2", "", "", ""], + ["", "", "p5c3", "", ""], + ] + + result = self.subject(csv) + + assert result == { + "columns": 5, + "items": [ + {"id": 1, "item_id": "stone", "position": [0, 1]}, + { + "id": 2, + "item_id": "gooseberry_bush", + "position": [0, 3], + "remaining_uses": 3, + }, + { + "id": 3, + "item_id": "gooseberry_bush", + "position": [6, 0], + "remaining_uses": 4, + }, + {"id": 4, "item_id": "big_hard_rock", "position": [7, 1]}, + ], + "players": [ + {"color": "BLUE", "id": "1", "position": [0, 4]}, + {"color": "BLUE", "id": "2", "position": [2, 1]}, + {"color": "YELLOW", "id": "3", "position": [4, 3]}, + {"color": "YELLOW", "id": "4", "position": [8, 1]}, + {"color": "ORANGE", "id": "5", "position": [9, 2]}, + ], + "rows": 10, + "walls": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 4]], + } + + def test_supports_empty_matrix(self): + csv = [] + + result = self.subject(csv) + + assert result == {"rows": 0, "columns": 0} + + def test_not_confused_by_whitepace(self): + csv = [["w ", " stone", " ", " gooseberry_bush | 3 ", " p1c2"]] + + result = self.subject(csv) + + assert result == { + "columns": 5, + "items": [ + {"id": 1, "item_id": "stone", "position": [0, 1]}, + { + "id": 2, + "item_id": "gooseberry_bush", + "position": [0, 3], + "remaining_uses": 3, + }, + ], + "players": [{"color": "YELLOW", "id": "1", "position": [0, 4]}], + "rows": 1, + "walls": [[0, 0]], + } + + def test_explains_invalid_player_colors(self): + csv = [["p1c999"]] + + with pytest.raises(ValueError) as exc_info: + self.subject(csv) + + assert exc_info.match("Invalid player color") + + def test_preserves_empty_edge_rows_and_columns(self): + csv = [ + ["", "", "", "", ""], + ["", "", "stone", "", ""], + ["", "", "", "", ""], + ] + + result = self.subject(csv) + + assert result == { + "columns": 5, + "items": [{"id": 1, "item_id": "stone", "position": [1, 2]}], + "rows": 3, + }