diff --git a/scoring/converter.py b/scoring/converter.py index e48060e..5113b31 100644 --- a/scoring/converter.py +++ b/scoring/converter.py @@ -10,12 +10,137 @@ from __future__ import annotations +from sr.comp.match_period import Match from sr.comp.scorer.converter import ( Converter as BaseConverter, + InputForm, + OutputForm, parse_int, render_int, + ZoneId, ) +from sr.comp.types import ScoreArenaZonesData, ScoreData, ScoreTeamData, TLA + +from sr2025 import DISTRICTS, RawDistrict, ZONE_COLOURS + + +class SR2025ScoreTeamData(ScoreTeamData): + left_starting_zone: bool class Converter(BaseConverter): - pass + """ + Base class for converting between representations of a match's score. + """ + + def form_team_to_score(self, form: InputForm, zone_id: ZoneId) -> SR2025ScoreTeamData: + """ + Prepare a team's scoring data for saving in a score dict. + + This is also given a `ZoneId` since form data are all keyed by zone. + """ + return { + **super().form_team_to_score(form, zone_id), + 'left_starting_zone': + form.get(f'left_starting_zone_{zone_id}', None) is not None, + } + + def form_district_to_score(self, form: InputForm, name: str) -> RawDistrict: + """ + Prepare a district's scoring data for saving in a score dict. + """ + return RawDistrict({ + 'highest': form.get(f'district_{name}_highest', ''), + 'pallets': { + x: parse_int(form.get(f'district_{name}_pallets_{x}')) + for x in ZONE_COLOURS + }, + }) + + def form_to_score(self, match: Match, form: InputForm) -> ScoreData: + """ + Prepare a score dict for the given match and form dict. + + This method is used to convert the submitted information for storage as + YAML in the compstate. + """ + zone_ids = range(len(match.teams)) + + teams: dict[TLA, ScoreTeamData] = {} + for zone_id in zone_ids: + tla = form.get(f'tla_{zone_id}', None) + if tla: + teams[TLA(tla)] = self.form_team_to_score(form, zone_id) + + arena = ScoreArenaZonesData({ + 'other': { + 'districts': { + district: self.form_district_to_score(form, district) + for district in DISTRICTS + }, + }, + }) + + return ScoreData({ + 'arena_id': match.arena, + 'match_number': match.num, + 'teams': teams, + 'arena_zones': arena, + }) + + def score_team_to_form(self, tla: TLA, info: ScoreTeamData) -> OutputForm: + zone_id = info['zone'] + return OutputForm({ + **super().score_team_to_form(tla, info), + f'left_starting_zone_{zone_id}': info.get('left_starting_zone', False), + }) + + def score_district_to_form(self, name: str, district: RawDistrict) -> OutputForm: + return OutputForm({ + f'district_{name}_highest': district['highest'].upper(), + **{ + f'district_{name}_pallets_{x}': render_int(district['pallets'].get(x)) + for x in ZONE_COLOURS + }, + }) + + def score_to_form(self, score: ScoreData) -> OutputForm: + """ + Prepare a form dict for the given score dict. + + This method is used when there is an existing score for a match. + """ + form = OutputForm({}) + + for tla, team_info in score['teams'].items(): + form.update(self.score_team_to_form(tla, team_info)) + + districts = score.get('arena_zones', {}).get('other', {}).get('districts', {}) # type: ignore[attr-defined, union-attr, call-overload] # noqa: E501 + + for name, district in districts.items(): + form.update(self.score_district_to_form(name, district)) + + return form + + def match_to_form(self, match: Match) -> OutputForm: + """ + Prepare a fresh form dict for the given match. + + This method is used when there is no existing score for a match. + """ + + form = OutputForm({}) + + for zone_id, tla in enumerate(match.teams): + if tla: + form[f'tla_{zone_id}'] = tla + form[f'disqualified_{zone_id}'] = False + form[f'present_{zone_id}'] = False + form[f'left_starting_zone_{zone_id}'] = False + + for name in DISTRICTS: + form[f'district_{name}_highest'] = '' + for x in ZONE_COLOURS: + form[f'district_{name}_pallets_{x}'] = None + + return form diff --git a/scoring/score.py b/scoring/score.py index 5c3cec5..d2c8eaf 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -4,7 +4,19 @@ Required as part of a compstate. """ -import warnings +from __future__ import annotations + +import collections +from typing import Iterable + +from sr2025 import ( + DISTRICT_SCORE_MAP, + DISTRICTS_NO_HIGH_RISE, + RawDistrict, + ZONE_COLOURS, +) + +TOKENS_PER_ZONE = 6 class InvalidScoresheetException(Exception): @@ -13,21 +25,182 @@ def __init__(self, message: str, *, code: str) -> None: self.code = code +def join_text(strings: Iterable[str], separator: str) -> str: + """ + Construct an english-language comma separated list ending with the given + separator word. This enables constructs like "foo, bar and baz" given a list + of names. + + >>> join_text(["foo", "bar", "baz"], "and") + "foo, bar and baz" + + >>> join_text(["foo", "bar"], "or") + "foo or bar" + + >>> join_text(["foo"], "or") + "foo" + """ + strings = tuple(strings) + + try: + *strings, right = strings + except ValueError: + return "" + + if not strings: + return right + + left = ", ".join(strings[:-1]) + return f"{left} {separator} {strings[-1]}" + + +def join_and(strings: Iterable[str]) -> str: + return join_text(strings, "and") + + +def join_or(strings: Iterable[str]) -> str: + return join_text(strings, "or") + + class Scorer: def __init__(self, teams_data, arena_data): self._teams_data = teams_data - self._arena_data = arena_data + self._districts = arena_data['other']['districts'] + + for district in self._districts.values(): + district['pallets'] = collections.Counter(district['pallets']) + district['highest'] = district['highest'].replace(' ', '') + + def score_district_for_zone(self, name: str, district: RawDistrict, zone: int) -> int: + colour = ZONE_COLOURS[zone] + + num_tokens = district['pallets'][colour] + score = num_tokens * DISTRICT_SCORE_MAP[name] + + if colour in district['highest']: + # Points are doubled for the team owning the highest pallet + score *= 2 + + return score def calculate_scores(self): scores = {} for tla, info in self._teams_data.items(): - scores[tla] = 0 + district_score = sum( + self.score_district_for_zone(name, district, info['zone']) + for name, district in self._districts.items() + ) + movement_score = 1 if info.get('left_starting_zone') else 0 + scores[tla] = district_score + movement_score return scores def validate(self, other_data): - warnings.warn("Scoresheet validation not implemented") + # Check that the right districts are specified. + if self._districts.keys() != DISTRICT_SCORE_MAP.keys(): + missing = DISTRICT_SCORE_MAP.keys() - self._districts.keys() + extra = self._districts.keys() - DISTRICT_SCORE_MAP.keys() + detail = "Wrong districts specified." + if missing: + detail += f" Missing: {join_and(missing)}." + if extra: + detail += f" Extra: {join_and(repr(x) for x in extra)}." + raise InvalidScoresheetException( + detail, + code='invalid_districts', + ) + + # Check that the "highest" pallet is a single, valid colour entry. + bad_highest = {} + for name, district in self._districts.items(): + highest = district['highest'] + if highest and highest not in ZONE_COLOURS: + bad_highest[name] = highest + if bad_highest: + raise InvalidScoresheetException( + f"Invalid pallets specified as the highest in some districts -- " + f"must be a single pallet from " + f"{join_or(repr(x) for x in ZONE_COLOURS)}.\n" + f"{bad_highest!r}", + code='invalid_highest_pallet', + ) + + # Check that the pallets are valid colours. + bad_pallets = {} + for name, district in self._districts.items(): + extra = district['pallets'].keys() - ZONE_COLOURS + if extra: + bad_pallets[name] = extra + if bad_pallets: + raise InvalidScoresheetException( + f"Invalid pallets specified in some districts -- must be from " + f"{join_or(repr(x) for x in ZONE_COLOURS)}.\n" + f"{bad_pallets!r}", + code='invalid_pallets', + ) + + # Check that the "highest" pallet is accompanied by at least one pallet + # of that colour in the district. + bad_highest2 = {} + for name, district in self._districts.items(): + highest = district['highest'] + if highest and highest not in district['pallets']: + bad_highest2[name] = (highest, district['pallets'].keys()) + if bad_highest2: + detail = "\n".join( + ( + ( + f"District {name} has only {join_and(pallets)} so " + f"{highest!r} cannot be the highest." + ) + if pallets + else ( + f"District {name} has no pallets so {highest!r} cannot " + "be the highest." + ) + ) + for name, (highest, pallets) in bad_highest2.items() + ) + raise InvalidScoresheetException( + f"Impossible pallets specified as the highest in some districts " + f"-- must be a pallet which is present in the district.\n" + f"{detail}", + code='impossible_highest_pallet', + ) + + # Check that the "highest" pallet in districts which don't have a + # high-rise has another pallet to be placed on top of (pallets on the + # floor don't qualify for being the highest). + single_pallet_highest = {} + for name in DISTRICTS_NO_HIGH_RISE: + district = self._districts[name] + highest = district['highest'] + num_pallets = sum(district['pallets'].values()) + if num_pallets == 1 and highest: + single_pallet_highest[name] = highest + if single_pallet_highest: + raise InvalidScoresheetException( + "Districts without a high-rise and only a single pallet cannot " + "have a \"highest\" pallet since pallets on the floor cannot " + "count as the highest.\n" + f"{single_pallet_highest!r}", + code='impossible_highest_single_pallet', + ) + + # Check that the total number of pallets of each colour across the whole + # arena are less than the expected number. + totals = collections.Counter() + for district in self._districts.values(): + totals += district['pallets'] + bad_totals = [x for x, y in totals.items() if y > TOKENS_PER_ZONE] + if bad_totals: + raise InvalidScoresheetException( + f"Too many {join_and(repr(x) for x in bad_totals)} pallets " + f"specified, must be no more than {TOKENS_PER_ZONE} of each type.\n" + f"Totals: {totals!r}", + code='too_many_pallets', + ) if __name__ == '__main__': diff --git a/scoring/setup.cfg b/scoring/setup.cfg index 6a19c81..cfa6333 100644 --- a/scoring/setup.cfg +++ b/scoring/setup.cfg @@ -1,3 +1,7 @@ +[flake8] +# try to keep it below 85, but this allows us to push it a bit when needed. +max_line_length = 95 + [isort] atomic = True balanced_wrapping = True diff --git a/scoring/sr2025.py b/scoring/sr2025.py new file mode 100644 index 0000000..30134fb --- /dev/null +++ b/scoring/sr2025.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import TypedDict + + +class RawDistrict(TypedDict): + # Single pallet colour character + highest: str + # Pallet colour -> count + pallets: dict[str, int] + + +DISTRICT_SCORE_MAP = { + 'outer_nw': 1, + 'outer_ne': 1, + 'outer_se': 1, + 'outer_sw': 1, + 'inner_nw': 2, + 'inner_ne': 2, + 'inner_se': 2, + 'inner_sw': 2, + 'central': 3, +} + +DISTRICTS_NO_HIGH_RISE = frozenset([ + 'outer_nw', + 'outer_ne', + 'outer_se', + 'outer_sw', +]) + +DISTRICTS = DISTRICT_SCORE_MAP.keys() + +assert DISTRICTS_NO_HIGH_RISE < DISTRICTS + +ZONE_COLOURS = ( + 'G', # zone 0 = green + 'O', # zone 1 = orange + 'P', # zone 2 = purple + 'Y', # zone 3 = yellow +) diff --git a/scoring/template.yaml b/scoring/template.yaml index 7b8f152..40f48ea 100644 --- a/scoring/template.yaml +++ b/scoring/template.yaml @@ -1,30 +1,54 @@ arena_id: main arena_zones: - 0: - tokens: '' - 1: - tokens: '' - 2: - tokens: '' - 3: - tokens: '' other: - tokens: '' + districts: + outer_nw: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + outer_ne: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + outer_se: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + outer_sw: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + inner_nw: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + inner_ne: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + inner_se: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + inner_sw: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + central: + highest: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} + match_number: 0 teams: TLA0: disqualified: false present: true + left_starting_zone: false zone: 0 TLA1: disqualified: true present: false + left_starting_zone: false zone: 1 TLA2: disqualified: false present: true + left_starting_zone: false zone: 2 TLA3: disqualified: false present: false + left_starting_zone: false zone: 3 diff --git a/scoring/tests/test_scoring.py b/scoring/tests/test_scoring.py index 94b9999..78a5f7d 100644 --- a/scoring/tests/test_scoring.py +++ b/scoring/tests/test_scoring.py @@ -7,6 +7,7 @@ auto detect this and run the tests. """ +import copy import pathlib import random import sys @@ -22,6 +23,10 @@ InvalidScoresheetException, Scorer, ) +from sr2025 import ( # type: ignore[import-not-found] # noqa: E402 + DISTRICTS, + RawDistrict, +) def shuffled(text: str) -> str: @@ -33,24 +38,21 @@ def shuffled(text: str) -> str: class ScorerTests(unittest.TestCase): longMessage = True - def construct_scorer(self, robot_contents, zone_tokens): + def construct_scorer(self, districts): return Scorer( - { - tla: {**info, 'robot_tokens': robot_contents.get(tla, "")} - for tla, info in self.teams_data.items() - }, - {x: {'tokens': y} for x, y in zone_tokens.items()}, + self.teams_data, + {'other': {'districts': districts}}, ) - def assertScores(self, expected_scores, robot_contents, zone_tokens): - scorer = self.construct_scorer(robot_contents, zone_tokens) + def assertScores(self, expected_scores, districts): + scorer = self.construct_scorer(districts) scorer.validate(None) actual_scores = scorer.calculate_scores() self.assertEqual(expected_scores, actual_scores, "Wrong scores") - def assertInvalidScoresheet(self, robot_contents, zone_tokens, *, code): - scorer = self.construct_scorer(robot_contents, zone_tokens) + def assertInvalidScoresheet(self, districts, *, code): + scorer = self.construct_scorer(districts) with self.assertRaises(InvalidScoresheetException) as cm: scorer.validate(None) @@ -61,17 +63,17 @@ def assertInvalidScoresheet(self, robot_contents, zone_tokens, *, code): f"Wrong error code, message was: {cm.exception}", ) - def setUp(self): + def setUp(self) -> None: self.teams_data = { - 'ABC': {'zone': 0, 'present': True, 'left_scoring_zone': False}, - 'DEF': {'zone': 1, 'present': True, 'left_scoring_zone': False}, + 'GGG': {'zone': 0, 'present': True, 'left_starting_zone': False}, + 'OOO': {'zone': 1, 'present': True, 'left_starting_zone': False}, } - tokens_per_zone = 'B' * 5 + 'S' * 3 + 'G' - self.zone_tokens = { - 0: shuffled(tokens_per_zone), - 1: shuffled(tokens_per_zone), - 2: shuffled(tokens_per_zone), - 3: shuffled(tokens_per_zone), + self.districts: dict[str, RawDistrict] = { + name: RawDistrict({ + 'highest': '', + 'pallets': {}, + }) + for name in DISTRICTS } def test_template(self): @@ -96,27 +98,232 @@ def test_template(self): # Scoring logic - ... + def test_left_starting_zone(self) -> None: + self.teams_data['GGG']['left_starting_zone'] = True + self.assertScores( + { + 'GGG': 1, + 'OOO': 0, + }, + self.districts, + ) + + def test_outer_single(self) -> None: + self.districts['outer_nw']['pallets'] = {'G': 1} + self.assertScores( + { + 'GGG': 1, + 'OOO': 0, + }, + self.districts, + ) + + def test_outer_multiple(self) -> None: + self.districts['outer_nw']['pallets'] = {'G': 2, 'O': 1} + self.assertScores( + { + 'GGG': 2, + 'OOO': 1, + }, + self.districts, + ) + + def test_outer_highest(self) -> None: + self.districts['outer_nw']['highest'] = 'G' + self.districts['outer_nw']['pallets'] = {'G': 2, 'O': 1} + self.assertScores( + { + 'GGG': 4, + 'OOO': 1, + }, + self.districts, + ) - # Invalid characters + def test_outer_highest_self(self) -> None: + self.districts['outer_nw']['highest'] = 'G' + self.districts['outer_nw']['pallets'] = {'G': 2} + self.assertScores( + { + 'GGG': 4, + 'OOO': 0, + }, + self.districts, + ) - ... + def test_outer_highest_use_other_team(self) -> None: + self.districts['outer_nw']['highest'] = 'G' + self.districts['outer_nw']['pallets'] = {'G': 1, 'O': 1} + self.assertScores( + { + 'GGG': 2, + 'OOO': 1, + }, + self.districts, + ) - # Missing tokens + def test_inner_single(self) -> None: + self.districts['inner_ne']['pallets'] = {'O': 1} + self.assertScores( + { + 'GGG': 0, + 'OOO': 2, + }, + self.districts, + ) - ... + def test_inner_multiple(self) -> None: + self.districts['inner_ne']['pallets'] = {'G': 1, 'O': 2} + self.assertScores( + { + 'GGG': 2, + 'OOO': 4, + }, + self.districts, + ) - # Extra tokens + def test_inner_highest(self) -> None: + self.districts['inner_ne']['highest'] = 'O' + self.districts['inner_ne']['pallets'] = {'G': 1, 'O': 2} + self.assertScores( + { + 'GGG': 2, + 'OOO': 8, + }, + self.districts, + ) - ... + def test_central_single(self) -> None: + self.districts['central']['pallets'] = {'O': 1} + self.assertScores( + { + 'GGG': 0, + 'OOO': 3, + }, + self.districts, + ) + + def test_central_multiple(self) -> None: + self.districts['central']['pallets'] = {'G': 1, 'O': 2} + self.assertScores( + { + 'GGG': 3, + 'OOO': 6, + }, + self.districts, + ) + + def test_central_highest(self) -> None: + self.districts['central']['highest'] = 'O' + self.districts['central']['pallets'] = {'G': 1, 'O': 2} + self.assertScores( + { + 'GGG': 3, + 'OOO': 12, + }, + self.districts, + ) + + def test_mixed(self) -> None: + self.teams_data['OOO']['left_starting_zone'] = True + self.districts['outer_sw']['pallets'] = {'O': 1} + self.districts['inner_sw']['pallets'] = {'G': 1} + self.districts['central']['pallets'] = {'G': 1, 'O': 1} + self.assertScores( + { + 'GGG': 5, + 'OOO': 5, + }, + self.districts, + ) + + def test_mixed_highest(self) -> None: + self.districts['outer_sw']['pallets'] = {'O': 1} + self.districts['inner_sw']['highest'] = 'G' + self.districts['inner_sw']['pallets'] = {'G': 1} + self.districts['central']['highest'] = 'G' + self.districts['central']['pallets'] = {'G': 1, 'O': 1} + self.assertScores( + { + 'GGG': 10, + 'OOO': 4, + }, + self.districts, + ) + + # Invalid input + + def test_bad_highest_pallet_letter(self) -> None: + self.districts['outer_sw']['highest'] = 'o' + self.assertInvalidScoresheet( + self.districts, + code='invalid_highest_pallet', + ) + + def test_bad_pallet_letter(self) -> None: + self.districts['outer_sw']['pallets'] = {'o': 1} + self.assertInvalidScoresheet( + self.districts, + code='invalid_pallets', + ) + + def test_outer_highest_requires_multiple_tokens_self(self) -> None: + self.districts['outer_sw']['highest'] = 'O' + self.districts['outer_sw']['pallets'] = {'O': 1} + self.assertInvalidScoresheet( + self.districts, + code='impossible_highest_single_pallet', + ) + + def test_missing_district(self) -> None: + del self.districts['outer_sw'] + self.assertInvalidScoresheet( + self.districts, + code='invalid_districts', + ) + + def test_extra_district(self) -> None: + self.districts['bees'] = copy.deepcopy(self.districts['central']) + self.assertInvalidScoresheet( + self.districts, + code='invalid_districts', + ) # Tolerable input deviances - ... + def test_spacey(self) -> None: + self.districts['outer_nw']['highest'] = ' O ' + self.districts['outer_nw']['pallets'] = {'G': 1, 'O': 1, 'Y': 1} + self.assertScores( + { + 'GGG': 1, + 'OOO': 2, + }, + self.districts, + ) # Impossible scenarios - ... + def test_highest_when_no_pallets(self) -> None: + self.districts['outer_sw']['highest'] = 'O' + self.assertInvalidScoresheet( + self.districts, + code='impossible_highest_pallet', + ) + + def test_highest_when_team_not_present(self) -> None: + self.districts['outer_sw']['highest'] = 'Y' + self.districts['outer_sw']['pallets'] = {'O': 1, 'P': 1} + self.assertInvalidScoresheet( + self.districts, + code='impossible_highest_pallet', + ) + + def test_too_many_pallets(self) -> None: + self.districts['outer_sw']['pallets'] = {'G': 7, 'O': 1} + self.assertInvalidScoresheet( + self.districts, + code='too_many_pallets', + ) if __name__ == '__main__': diff --git a/scoring/update.html b/scoring/update.html index 7183792..31bf5f8 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -1,5 +1,85 @@ {% extends "_update.html" %} -{% block valid_token_regex %} -var valid_token_regex = /^[GOPY]*$/; +{% macro input_highest(x, y, name) %} + + + +{% endmacro %} + +{% macro input_pallets(x, y, name, colour_symbol, corner) %} + + + + + + +{% endmacro %} + +{% macro district(name, x, y) %} + + + {{ input_highest(x + 2.5, y + 5, name) }} + {{ input_pallets(x, y + 40, name, 'O', 1) }} + {{ input_pallets(x + 55, y + 40, name, 'P', 2) }} + {{ input_pallets(x + 55, y + 75, name, 'Y', 3) }} + {{ input_pallets(x, y + 75, name, 'G', 0) }} + +{% endmacro %} + +{% macro zone(corner, x, y) %} + + Zone {{ corner }} + {{ input_tla(x, y + 15, corner) }} + {{ input_present(x + 160, y + 15, corner) }} + {{ input_disqualified(x + 120, y + 45, corner) }} + {{ input_checkbox(x + 70, y + 75, 170, corner, "left_starting_zone", "Left Starting Zone") }} + +{% endmacro %} + +{% block form_content %} + + + + + {# Yes, zone 0 deliberately bottom left #} + {{ zone(0, 10, 805) }} + {{ zone(1, 10, 40) }} + {{ zone(2, 320, 40) }} + {{ zone(3, 320, 805) }} + + + + {{ district('outer_nw', 0, 0) }} + {{ district('outer_ne', 440, 0) }} + {{ district('outer_se', 440, 440) }} + {{ district('outer_sw', 0, 440) }} + {{ district('inner_nw', 110, 110) }} + {{ district('inner_ne', 330, 110) }} + {{ district('inner_se', 330, 330) }} + {{ district('inner_sw', 110, 330) }} + {{ district('central', 220, 220) }} + + {% endblock %}