From e0b69c4c0cc8ad4e0a0b7534d308fcdd23b4629d Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 17:41:17 +0000 Subject: [PATCH 01/21] Initial scoring implementation This adds the basic data conversion logic for the form, though doesn't add a UI template yet, as well as a first pass at the scoring logic itself, though without any validation. --- scoring/converter.py | 122 ++++++++++++++++++++++++++++++++++++++++-- scoring/score.py | 33 +++++++++++- scoring/setup.cfg | 4 ++ scoring/sr2025.py | 29 ++++++++++ scoring/template.yaml | 42 +++++++++++---- 5 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 scoring/sr2025.py diff --git a/scoring/converter.py b/scoring/converter.py index e48060e..2dbaa73 100644 --- a/scoring/converter.py +++ b/scoring/converter.py @@ -10,12 +10,128 @@ from __future__ import annotations +from sr.comp.match_period import Match from sr.comp.scorer.converter import ( Converter as BaseConverter, - parse_int, - render_int, + InputForm, + OutputForm, + ZoneId, ) +from sr.comp.types import ScoreArenaZonesData, ScoreData, ScoreTeamData, TLA + +from sr2025 import DISTRICTS, RawDistrict + + +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 given a zone as form data is 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': form.get(f'district_{name}_pallets', ''), + }) + + 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 { + **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': district['pallets'].upper(), + }) + + 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'] = '' + form[f'district_{name}_pallets'] = '' + + return form diff --git a/scoring/score.py b/scoring/score.py index 5c3cec5..9c2d30b 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -4,8 +4,17 @@ Required as part of a compstate. """ +from __future__ import annotations + +import collections import warnings +from sr2025 import DISTRICT_SCORE_MAP, RawDistrict, ZONE_COLOURS + + +class District(RawDistrict): + pallet_counts: collections.Counter[str] + class InvalidScoresheetException(Exception): def __init__(self, message: str, *, code: str) -> None: @@ -16,13 +25,33 @@ def __init__(self, message: str, *, code: str) -> None: 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['pallet_counts'] = collections.Counter( + district['pallets'].replace(' ', ''), + ) + + def score_district_for_zone(self, name: str, district: District, zone: int) -> int: + colour = ZONE_COLOURS[zone] + + num_tokens = district['pallet_counts'][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 + scores[tla] = sum( + self.score_district_for_zone(name, district, info['zone']) + for name, district in self._districts.items() + ) return scores 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..cd05a21 --- /dev/null +++ b/scoring/sr2025.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TypedDict + +class RawDistrict(TypedDict): + highest: str + pallets: str + + +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 = DISTRICT_SCORE_MAP.keys() + +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..98f333e 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: '' + outer_ne: + highest: '' + pallets: '' + outer_se: + highest: '' + pallets: '' + outer_sw: + highest: '' + pallets: '' + inner_nw: + highest: '' + pallets: '' + inner_ne: + highest: '' + pallets: '' + inner_se: + highest: '' + pallets: '' + inner_sw: + highest: '' + pallets: '' + central: + highest: '' + pallets: '' + 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 From a5fc2b7c81eb7d194565cdb02fca4d553d977aee Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 18:24:44 +0000 Subject: [PATCH 02/21] Add initial input validation --- scoring/score.py | 118 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/scoring/score.py b/scoring/score.py index 9c2d30b..9d68949 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -7,10 +7,12 @@ from __future__ import annotations import collections -import warnings +from typing import Iterable from sr2025 import DISTRICT_SCORE_MAP, RawDistrict, ZONE_COLOURS +TOKENS_PER_ZONE = 6 + class District(RawDistrict): pallet_counts: collections.Counter[str] @@ -22,6 +24,29 @@ def __init__(self, message: str, *, code: str) -> None: self.code = code +def join_text(strings: Iterable[str], separator: str) -> str: + strings = tuple(strings) + + try: + *strings, right = strings + except TypeError: + 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 @@ -31,6 +56,7 @@ def __init__(self, teams_data, arena_data): district['pallet_counts'] = collections.Counter( district['pallets'].replace(' ', ''), ) + district['highest'] = district['highest'].replace(' ', '') def score_district_for_zone(self, name: str, district: District, zone: int) -> int: colour = ZONE_COLOURS[zone] @@ -56,7 +82,95 @@ def calculate_scores(self): return scores def validate(self, other_data): - warnings.warn("Scoresheet validation not implemented") + if self._districts.keys() != DISTRICT_SCORE_MAP.keys(): + missing = DISTRICT_SCORE_MAP.keys() - self._districts.keys() + extra = self._districts.keys() - DISTRICT_SCORE_MAP.keys() + raise InvalidScoresheetException( + f"Wrong districts specified. Missing: {join_and(missing)}. " + f"Extra: {extra!r}", + code='invalid_districts', + ) + + 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', + ) + + bad_pallets = {} + for name, district in self._districts.items(): + extra = district['pallet_counts'].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', + ) + + bad_highest2 = {} + for name, district in self._districts.items(): + highest = district['highest'] + if highest and highest not in district['pallet_counts']: + bad_highest2[name] = (highest, district['pallet_counts'].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', + ) + + missing_highest = {} + for name, district in self._districts.items(): + highest = district['highest'] + pallet_counts = district['pallet_counts'] + if len(pallet_counts) == 1 and not highest: + pallet, = pallet_counts.keys() + missing_highest[name] = pallet + if missing_highest: + raise InvalidScoresheetException( + f"Some districts with pallets from a single team are missing " + "specification of the highest.\n" + f"{missing_highest!r}", + code='missing_highest_pallet', + ) + + totals = collections.Counter() + for district in self._districts.values(): + totals += district['pallet_counts'] + too_many = {x for x, y in totals.items() if y > TOKENS_PER_ZONE} + if too_many: + raise InvalidScoresheetException( + f"Too many pallets of some kinds specified, must be no more " + f"than {TOKENS_PER_ZONE} of each type.\n" + f"{too_many!r}", + code='too_many_pallets', + ) if __name__ == '__main__': From a64bfd469f8c932eecf4e33c77f556f0461a6ac8 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 19:35:14 +0000 Subject: [PATCH 03/21] Whitespace --- scoring/sr2025.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scoring/sr2025.py b/scoring/sr2025.py index cd05a21..10a609c 100644 --- a/scoring/sr2025.py +++ b/scoring/sr2025.py @@ -2,6 +2,7 @@ from typing import TypedDict + class RawDistrict(TypedDict): highest: str pallets: str From 4e692192a905d28c1b4e93c4b5269dd45dd36ddc Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 19:35:29 +0000 Subject: [PATCH 04/21] Catch the right exception --- scoring/score.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scoring/score.py b/scoring/score.py index 9d68949..8231074 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -29,7 +29,7 @@ def join_text(strings: Iterable[str], separator: str) -> str: try: *strings, right = strings - except TypeError: + except ValueError: return "" if not strings: From 28c1027d46ceb4b9128acd2b05dba5aaae5de5b8 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 19:35:47 +0000 Subject: [PATCH 05/21] Improve these error messages --- scoring/score.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scoring/score.py b/scoring/score.py index 8231074..7dace53 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -85,9 +85,13 @@ def validate(self, other_data): 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( - f"Wrong districts specified. Missing: {join_and(missing)}. " - f"Extra: {extra!r}", + detail, code='invalid_districts', ) @@ -163,12 +167,11 @@ def validate(self, other_data): totals = collections.Counter() for district in self._districts.values(): totals += district['pallet_counts'] - too_many = {x for x, y in totals.items() if y > TOKENS_PER_ZONE} - if too_many: + if any(x > TOKENS_PER_ZONE for x in totals.values()): raise InvalidScoresheetException( f"Too many pallets of some kinds specified, must be no more " f"than {TOKENS_PER_ZONE} of each type.\n" - f"{too_many!r}", + f"{totals!r}", code='too_many_pallets', ) From 75c204f8d93bbfac38f4e7a2a670850c3111a987 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 19:42:37 +0000 Subject: [PATCH 06/21] Add tests and fix a silly bug they highlighted --- scoring/score.py | 2 +- scoring/tests/test_scoring.py | 232 ++++++++++++++++++++++++++++++---- 2 files changed, 206 insertions(+), 28 deletions(-) diff --git a/scoring/score.py b/scoring/score.py index 7dace53..b4e4230 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -66,7 +66,7 @@ def score_district_for_zone(self, name: str, district: District, zone: int) -> i if colour in district['highest']: # Points are doubled for the team owning the highest pallet - score * 2 + score *= 2 return score diff --git a/scoring/tests/test_scoring.py b/scoring/tests/test_scoring.py index 94b9999..055d470 100644 --- a/scoring/tests/test_scoring.py +++ b/scoring/tests/test_scoring.py @@ -22,6 +22,10 @@ InvalidScoresheetException, Scorer, ) +from sr2025 import ( # type: ignore[import-not-found] # noqa: E402 + DISTRICTS, + RawDistrict, +) def shuffled(text: str) -> str: @@ -33,24 +37,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) @@ -63,15 +64,15 @@ def assertInvalidScoresheet(self, robot_contents, zone_tokens, *, code): def setUp(self): 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 = { + name: RawDistrict({ + 'highest': '', + 'pallets': '', + }) + for name in DISTRICTS } def test_template(self): @@ -96,27 +97,204 @@ def test_template(self): # Scoring logic - ... + def test_outer_single(self) -> None: + self.districts['outer_nw']['highest'] = 'G' + self.districts['outer_nw']['pallets'] = 'G' + self.assertScores( + { + 'GGG': 2, + 'OOO': 0, + }, + self.districts, + ) - # Invalid characters + def test_outer_multiple(self) -> None: + self.districts['outer_nw']['pallets'] = 'GGO' + 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'] = 'GGO' + self.assertScores( + { + 'GGG': 4, + 'OOO': 1, + }, + self.districts, + ) + + def test_inner_single(self) -> None: + self.districts['inner_ne']['highest'] = 'O' + self.districts['inner_ne']['pallets'] = 'O' + self.assertScores( + { + 'GGG': 0, + 'OOO': 4, + }, + self.districts, + ) - # Missing tokens + def test_inner_multiple(self) -> None: + self.districts['inner_ne']['pallets'] = 'GOO' + self.assertScores( + { + 'GGG': 2, + 'OOO': 4, + }, + self.districts, + ) - ... + def test_inner_highest(self) -> None: + self.districts['inner_ne']['highest'] = 'O' + self.districts['inner_ne']['pallets'] = 'GOO' + self.assertScores( + { + 'GGG': 2, + 'OOO': 8, + }, + self.districts, + ) - # Extra tokens + def test_central_single(self) -> None: + self.districts['central']['highest'] = 'O' + self.districts['central']['pallets'] = 'O' + self.assertScores( + { + 'GGG': 0, + 'OOO': 6, + }, + self.districts, + ) - ... + def test_central_multiple(self) -> None: + self.districts['central']['pallets'] = 'GOO' + self.assertScores( + { + 'GGG': 3, + 'OOO': 6, + }, + self.districts, + ) + + def test_central_highest(self) -> None: + self.districts['central']['highest'] = 'O' + self.districts['central']['pallets'] = 'GOO' + self.assertScores( + { + 'GGG': 3, + 'OOO': 12, + }, + self.districts, + ) + + def test_mixed(self) -> None: + self.districts['outer_sw']['highest'] = 'O' + self.districts['outer_sw']['pallets'] = 'O' + self.districts['inner_sw']['highest'] = 'G' + self.districts['inner_sw']['pallets'] = 'G' + self.districts['central']['pallets'] = 'GO' + self.assertScores( + { + 'GGG': 7, + 'OOO': 6, + }, + self.districts, + ) + + def test_mixed_highest(self) -> None: + self.districts['outer_sw']['highest'] = 'O' + self.districts['outer_sw']['pallets'] = 'O' + self.districts['inner_sw']['highest'] = 'G' + self.districts['inner_sw']['pallets'] = 'G' + self.districts['central']['highest'] = 'G' + self.districts['central']['pallets'] = 'GO' + self.assertScores( + { + 'GGG': 10, + 'OOO': 5, + }, + 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' + self.assertInvalidScoresheet( + self.districts, + code='invalid_pallets', + ) + + 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'] = dict(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 O Y ' + 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'] = 'OP' + self.assertInvalidScoresheet( + self.districts, + code='impossible_highest_pallet', + ) + + def test_no_highest_when_only_one_team_present(self) -> None: + self.districts['outer_sw']['pallets'] = 'OO' + self.assertInvalidScoresheet( + self.districts, + code='missing_highest_pallet', + ) + + def test_too_many_pallets(self) -> None: + self.districts['outer_sw']['pallets'] = ('G' * 7) + 'O' + self.assertInvalidScoresheet( + self.districts, + code='too_many_pallets', + ) if __name__ == '__main__': From 602564f084d9d9287bdd95d3ebc6cd7fe1c3e1c7 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 19:43:09 +0000 Subject: [PATCH 07/21] Add overlooked movement point handling --- scoring/score.py | 4 +++- scoring/tests/test_scoring.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scoring/score.py b/scoring/score.py index b4e4230..d9f1a92 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -74,10 +74,12 @@ def calculate_scores(self): scores = {} for tla, info in self._teams_data.items(): - scores[tla] = sum( + 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 diff --git a/scoring/tests/test_scoring.py b/scoring/tests/test_scoring.py index 055d470..fc42fa8 100644 --- a/scoring/tests/test_scoring.py +++ b/scoring/tests/test_scoring.py @@ -97,6 +97,16 @@ 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']['highest'] = 'G' self.districts['outer_nw']['pallets'] = 'G' @@ -194,6 +204,7 @@ def test_central_highest(self) -> None: ) def test_mixed(self) -> None: + self.teams_data['OOO']['left_starting_zone'] = True self.districts['outer_sw']['highest'] = 'O' self.districts['outer_sw']['pallets'] = 'O' self.districts['inner_sw']['highest'] = 'G' From df7f082cd6bb46f99ebe332e706e6fb4d70f15b9 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 16 Nov 2024 22:20:26 +0000 Subject: [PATCH 08/21] First pass at a scorer UI Completely untested. --- scoring/update.html | 71 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/scoring/update.html b/scoring/update.html index 7183792..fb9f157 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -1,5 +1,76 @@ {% extends "_update.html" %} +{% macro input_highest(x, y, name) %} + + + +{% endmacro %} + +{% macro input_pallets(x, y, name) %} + + + +{% endmacro %} + +{% macro district(name, x, y) %} + + + {{ input_highest(x, y + 10, name) }} + {{ input_pallets(x, y + 50, name) }} + +{% 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 %} + + + + + {{ zone(0, 10, 40) }} + {{ zone(1, 320, 40) }} + {{ zone(2, 320, 805) }} + {{ zone(3, 10, 805) }} + + + + {{ district('outer_nw', 50, 50) }} + {{ district('outer_ne', 450, 50) }} + {{ district('outer_se', 450, 450) }} + {{ district('outer_sw', 50, 450) }} + {{ district('inner_nw', 150, 150) }} + {{ district('inner_ne', 350, 150) }} + {{ district('inner_se', 350, 350) }} + {{ district('inner_sw', 150, 350) }} + {{ district('central', 250, 250) }} + + +{% endblock %} + {% block valid_token_regex %} var valid_token_regex = /^[GOPY]*$/; {% endblock %} From 48be34a066dee2fb24696c959fe300a22702e6e3 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 12:43:54 +0000 Subject: [PATCH 09/21] Connect up input-type specific validation This relies on v1.6 of the scorer. --- scoring/update.html | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scoring/update.html b/scoring/update.html index fb9f157..ee7b97f 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -3,13 +3,14 @@ {% macro input_highest(x, y, name) %} {% endmacro %} @@ -17,13 +18,14 @@ {% macro input_pallets(x, y, name) %} {% endmacro %} @@ -70,7 +72,3 @@ {% endblock %} - -{% block valid_token_regex %} -var valid_token_regex = /^[GOPY]*$/; -{% endblock %} From 7cf8b0538736ac0be751c911069e172cc739bd26 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 13:08:24 +0000 Subject: [PATCH 10/21] Improve spacing of district inputs This makes them more uniform and (combined with v1.7 of the scorer) helps ensure the input placeholders are visible. --- scoring/update.html | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scoring/update.html b/scoring/update.html index ee7b97f..a0551b2 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -1,7 +1,7 @@ {% extends "_update.html" %} {% macro input_highest(x, y, name) %} - + + - - {{ input_highest(x, y + 10, name) }} - {{ input_pallets(x, y + 50, name) }} + + {{ input_highest(x + 2.5, y + 15, name) }} + {{ input_pallets(x, y + 65, name) }} {% endmacro %} @@ -59,16 +59,16 @@ {{ zone(3, 10, 805) }} - - {{ district('outer_nw', 50, 50) }} - {{ district('outer_ne', 450, 50) }} - {{ district('outer_se', 450, 450) }} - {{ district('outer_sw', 50, 450) }} - {{ district('inner_nw', 150, 150) }} - {{ district('inner_ne', 350, 150) }} - {{ district('inner_se', 350, 350) }} - {{ district('inner_sw', 150, 350) }} - {{ district('central', 250, 250) }} + + {{ 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 %} From a9ff512d0f52d75b442d9ac77299d3be01a4b6e3 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 18:00:34 +0000 Subject: [PATCH 11/21] Rotate the corners to match this year's arrangement --- scoring/update.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scoring/update.html b/scoring/update.html index a0551b2..934be6f 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -53,10 +53,11 @@ - {{ zone(0, 10, 40) }} - {{ zone(1, 320, 40) }} - {{ zone(2, 320, 805) }} - {{ zone(3, 10, 805) }} + {# Yes, zone 0 deliberately bottom left #} + {{ zone(0, 10, 805) }} + {{ zone(1, 10, 40) }} + {{ zone(2, 320, 40) }} + {{ zone(3, 320, 805) }} From ae266736bf2966b80403da01651eb2bd3a0a3a65 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 18:50:36 +0000 Subject: [PATCH 12/21] Explain each of the implemented checks This makes it easier to understand the intent of these code blocks as well as enabling non-Python-fluent readers to consider which checks have been implemented. --- scoring/score.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scoring/score.py b/scoring/score.py index d9f1a92..f8b4573 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -84,6 +84,7 @@ def calculate_scores(self): return scores def validate(self, other_data): + # 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() @@ -97,6 +98,7 @@ def validate(self, other_data): 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'] @@ -111,6 +113,7 @@ def validate(self, other_data): code='invalid_highest_pallet', ) + # Check that the pallets are valid colours. bad_pallets = {} for name, district in self._districts.items(): extra = district['pallet_counts'].keys() - ZONE_COLOURS @@ -124,6 +127,8 @@ def validate(self, other_data): 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'] @@ -151,6 +156,8 @@ def validate(self, other_data): code='impossible_highest_pallet', ) + # Check that the "highest" pallet is assigned in the case where a + # district has pallets from only one team. missing_highest = {} for name, district in self._districts.items(): highest = district['highest'] @@ -166,6 +173,8 @@ def validate(self, other_data): code='missing_highest_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['pallet_counts'] From 4e54921f87361163b62a21843ba1ec6aaacb19c5 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 19:04:39 +0000 Subject: [PATCH 13/21] Document this helper --- scoring/score.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scoring/score.py b/scoring/score.py index f8b4573..23a99d5 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -25,6 +25,20 @@ def __init__(self, message: str, *, code: str) -> None: 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: From 9c0b0b26f97acb9729001618c3d6a9be9f9d8a69 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 19:09:52 +0000 Subject: [PATCH 14/21] Include slightly more detail in this validation message --- scoring/score.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scoring/score.py b/scoring/score.py index 23a99d5..c08886d 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -192,11 +192,12 @@ def validate(self, other_data): totals = collections.Counter() for district in self._districts.values(): totals += district['pallet_counts'] - if any(x > TOKENS_PER_ZONE for x in totals.values()): + bad_totals = [x for x, y in totals.items() if y > TOKENS_PER_ZONE] + if bad_totals: raise InvalidScoresheetException( - f"Too many pallets of some kinds specified, must be no more " - f"than {TOKENS_PER_ZONE} of each type.\n" - f"{totals!r}", + 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', ) From 0cc5b57c2483655ec678f53395fa85d460b7f379 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 20:45:58 +0000 Subject: [PATCH 15/21] Pallets on the floor don't qualify to be the highest --- scoring/score.py | 17 ----------------- scoring/tests/test_scoring.py | 25 ++++++------------------- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/scoring/score.py b/scoring/score.py index c08886d..463e090 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -170,23 +170,6 @@ def validate(self, other_data): code='impossible_highest_pallet', ) - # Check that the "highest" pallet is assigned in the case where a - # district has pallets from only one team. - missing_highest = {} - for name, district in self._districts.items(): - highest = district['highest'] - pallet_counts = district['pallet_counts'] - if len(pallet_counts) == 1 and not highest: - pallet, = pallet_counts.keys() - missing_highest[name] = pallet - if missing_highest: - raise InvalidScoresheetException( - f"Some districts with pallets from a single team are missing " - "specification of the highest.\n" - f"{missing_highest!r}", - code='missing_highest_pallet', - ) - # Check that the total number of pallets of each colour across the whole # arena are less than the expected number. totals = collections.Counter() diff --git a/scoring/tests/test_scoring.py b/scoring/tests/test_scoring.py index fc42fa8..5d9182b 100644 --- a/scoring/tests/test_scoring.py +++ b/scoring/tests/test_scoring.py @@ -108,11 +108,10 @@ def test_left_starting_zone(self) -> None: ) def test_outer_single(self) -> None: - self.districts['outer_nw']['highest'] = 'G' self.districts['outer_nw']['pallets'] = 'G' self.assertScores( { - 'GGG': 2, + 'GGG': 1, 'OOO': 0, }, self.districts, @@ -140,12 +139,11 @@ def test_outer_highest(self) -> None: ) def test_inner_single(self) -> None: - self.districts['inner_ne']['highest'] = 'O' self.districts['inner_ne']['pallets'] = 'O' self.assertScores( { 'GGG': 0, - 'OOO': 4, + 'OOO': 2, }, self.districts, ) @@ -172,12 +170,11 @@ def test_inner_highest(self) -> None: ) def test_central_single(self) -> None: - self.districts['central']['highest'] = 'O' self.districts['central']['pallets'] = 'O' self.assertScores( { 'GGG': 0, - 'OOO': 6, + 'OOO': 3, }, self.districts, ) @@ -205,21 +202,18 @@ def test_central_highest(self) -> None: def test_mixed(self) -> None: self.teams_data['OOO']['left_starting_zone'] = True - self.districts['outer_sw']['highest'] = 'O' self.districts['outer_sw']['pallets'] = 'O' - self.districts['inner_sw']['highest'] = 'G' self.districts['inner_sw']['pallets'] = 'G' self.districts['central']['pallets'] = 'GO' self.assertScores( { - 'GGG': 7, - 'OOO': 6, + 'GGG': 5, + 'OOO': 5, }, self.districts, ) def test_mixed_highest(self) -> None: - self.districts['outer_sw']['highest'] = 'O' self.districts['outer_sw']['pallets'] = 'O' self.districts['inner_sw']['highest'] = 'G' self.districts['inner_sw']['pallets'] = 'G' @@ -228,7 +222,7 @@ def test_mixed_highest(self) -> None: self.assertScores( { 'GGG': 10, - 'OOO': 5, + 'OOO': 4, }, self.districts, ) @@ -293,13 +287,6 @@ def test_highest_when_team_not_present(self) -> None: code='impossible_highest_pallet', ) - def test_no_highest_when_only_one_team_present(self) -> None: - self.districts['outer_sw']['pallets'] = 'OO' - self.assertInvalidScoresheet( - self.districts, - code='missing_highest_pallet', - ) - def test_too_many_pallets(self) -> None: self.districts['outer_sw']['pallets'] = ('G' * 7) + 'O' self.assertInvalidScoresheet( From 4ca07e188f0fe0f9a9149fe5975e2f479eec5a6b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 17 Nov 2024 22:23:27 +0000 Subject: [PATCH 16/21] Add check that pallets which must be on the floor are not the highest Pallets in districts which don't have high-rises must be on the floor if there is only one pallet in the district. This is an imperect check since it cannot validate districts which do have high-rises. It's probably still useful though. --- scoring/score.py | 26 +++++++++++++++++++++++++- scoring/sr2025.py | 9 +++++++++ scoring/tests/test_scoring.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/scoring/score.py b/scoring/score.py index 463e090..d2bbbb9 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -9,7 +9,12 @@ import collections from typing import Iterable -from sr2025 import DISTRICT_SCORE_MAP, RawDistrict, ZONE_COLOURS +from sr2025 import ( + DISTRICT_SCORE_MAP, + DISTRICTS_NO_HIGH_RISE, + RawDistrict, + ZONE_COLOURS, +) TOKENS_PER_ZONE = 6 @@ -170,6 +175,25 @@ def validate(self, other_data): code='impossible_highest_pallet', ) + # Check that the "highest" pallet in districts which don't have a + # high-rises 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['pallet_counts'].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() diff --git a/scoring/sr2025.py b/scoring/sr2025.py index 10a609c..430ee8f 100644 --- a/scoring/sr2025.py +++ b/scoring/sr2025.py @@ -20,8 +20,17 @@ class RawDistrict(TypedDict): '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 diff --git a/scoring/tests/test_scoring.py b/scoring/tests/test_scoring.py index 5d9182b..93d8522 100644 --- a/scoring/tests/test_scoring.py +++ b/scoring/tests/test_scoring.py @@ -138,6 +138,28 @@ def test_outer_highest(self) -> None: self.districts, ) + def test_outer_highest_self(self) -> None: + self.districts['outer_nw']['highest'] = 'G' + self.districts['outer_nw']['pallets'] = 'GG' + 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'] = 'GO' + self.assertScores( + { + 'GGG': 2, + 'OOO': 1, + }, + self.districts, + ) + def test_inner_single(self) -> None: self.districts['inner_ne']['pallets'] = 'O' self.assertScores( @@ -243,6 +265,14 @@ def test_bad_pallet_letter(self) -> None: 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' + self.assertInvalidScoresheet( + self.districts, + code='impossible_highest_single_pallet', + ) + def test_missing_district(self) -> None: del self.districts['outer_sw'] self.assertInvalidScoresheet( From 226e0d43f233695f1687758018230a274bfc22a2 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 24 Nov 2024 20:19:34 +0000 Subject: [PATCH 17/21] =?UTF-8?q?Swap=20to=202=C3=972=20grid=20of=20pallet?= =?UTF-8?q?=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following user testing at yesterday's Tech Day this turns out to be the preferred input format for the paper forms. This commit fully moves over to that style -- UI, converter, storage and scoring logic. --- scoring/converter.py | 17 ++++++++--- scoring/score.py | 22 ++++++--------- scoring/sr2025.py | 4 ++- scoring/template.yaml | 18 ++++++------ scoring/tests/test_scoring.py | 53 ++++++++++++++++++----------------- scoring/update.html | 23 +++++++++------ 6 files changed, 74 insertions(+), 63 deletions(-) diff --git a/scoring/converter.py b/scoring/converter.py index 2dbaa73..95c4ac3 100644 --- a/scoring/converter.py +++ b/scoring/converter.py @@ -15,11 +15,13 @@ Converter as BaseConverter, InputForm, OutputForm, + parse_int, + render_int, ZoneId, ) from sr.comp.types import ScoreArenaZonesData, ScoreData, ScoreTeamData, TLA -from sr2025 import DISTRICTS, RawDistrict +from sr2025 import DISTRICTS, RawDistrict, ZONE_COLOURS class SR2025ScoreTeamData(ScoreTeamData): @@ -49,7 +51,10 @@ def form_district_to_score(self, form: InputForm, name: str) -> RawDistrict: """ return RawDistrict({ 'highest': form.get(f'district_{name}_highest', ''), - 'pallets': form.get(f'district_{name}_pallets', ''), + '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: @@ -93,7 +98,10 @@ def score_team_to_form(self, tla: TLA, info: ScoreTeamData) -> OutputForm: def score_district_to_form(self, name: str, district: RawDistrict) -> OutputForm: return OutputForm({ f'district_{name}_highest': district['highest'].upper(), - f'district_{name}_pallets': district['pallets'].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: @@ -132,6 +140,7 @@ def match_to_form(self, match: Match) -> OutputForm: for name in DISTRICTS: form[f'district_{name}_highest'] = '' - form[f'district_{name}_pallets'] = '' + 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 d2bbbb9..b79ee42 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -19,10 +19,6 @@ TOKENS_PER_ZONE = 6 -class District(RawDistrict): - pallet_counts: collections.Counter[str] - - class InvalidScoresheetException(Exception): def __init__(self, message: str, *, code: str) -> None: super().__init__(message) @@ -72,15 +68,13 @@ def __init__(self, teams_data, arena_data): self._districts = arena_data['other']['districts'] for district in self._districts.values(): - district['pallet_counts'] = collections.Counter( - district['pallets'].replace(' ', ''), - ) + district['pallets'] = collections.Counter(district['pallets']) district['highest'] = district['highest'].replace(' ', '') - def score_district_for_zone(self, name: str, district: District, zone: int) -> int: + def score_district_for_zone(self, name: str, district: RawDistrict, zone: int) -> int: colour = ZONE_COLOURS[zone] - num_tokens = district['pallet_counts'][colour] + num_tokens = district['pallets'][colour] score = num_tokens * DISTRICT_SCORE_MAP[name] if colour in district['highest']: @@ -135,7 +129,7 @@ def validate(self, other_data): # Check that the pallets are valid colours. bad_pallets = {} for name, district in self._districts.items(): - extra = district['pallet_counts'].keys() - ZONE_COLOURS + extra = district['pallets'].keys() - ZONE_COLOURS if extra: bad_pallets[name] = extra if bad_pallets: @@ -151,8 +145,8 @@ def validate(self, other_data): bad_highest2 = {} for name, district in self._districts.items(): highest = district['highest'] - if highest and highest not in district['pallet_counts']: - bad_highest2[name] = (highest, district['pallet_counts'].keys()) + if highest and highest not in district['pallets']: + bad_highest2[name] = (highest, district['pallets'].keys()) if bad_highest2: detail = "\n".join( ( @@ -182,7 +176,7 @@ def validate(self, other_data): for name in DISTRICTS_NO_HIGH_RISE: district = self._districts[name] highest = district['highest'] - num_pallets = sum(district['pallet_counts'].values()) + num_pallets = sum(district['pallets'].values()) if num_pallets == 1 and highest: single_pallet_highest[name] = highest if single_pallet_highest: @@ -198,7 +192,7 @@ def validate(self, other_data): # arena are less than the expected number. totals = collections.Counter() for district in self._districts.values(): - totals += district['pallet_counts'] + totals += district['pallets'] bad_totals = [x for x, y in totals.items() if y > TOKENS_PER_ZONE] if bad_totals: raise InvalidScoresheetException( diff --git a/scoring/sr2025.py b/scoring/sr2025.py index 430ee8f..30134fb 100644 --- a/scoring/sr2025.py +++ b/scoring/sr2025.py @@ -4,8 +4,10 @@ class RawDistrict(TypedDict): + # Single pallet colour character highest: str - pallets: str + # Pallet colour -> count + pallets: dict[str, int] DISTRICT_SCORE_MAP = { diff --git a/scoring/template.yaml b/scoring/template.yaml index 98f333e..40f48ea 100644 --- a/scoring/template.yaml +++ b/scoring/template.yaml @@ -4,31 +4,31 @@ arena_zones: districts: outer_nw: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} outer_ne: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} outer_se: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} outer_sw: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} inner_nw: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} inner_ne: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} inner_se: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} inner_sw: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} central: highest: '' - pallets: '' + pallets: {G: 0, O: 0, P: 0, Y: 0} match_number: 0 teams: diff --git a/scoring/tests/test_scoring.py b/scoring/tests/test_scoring.py index 93d8522..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 @@ -62,15 +63,15 @@ def assertInvalidScoresheet(self, districts, *, code): f"Wrong error code, message was: {cm.exception}", ) - def setUp(self): + def setUp(self) -> None: self.teams_data = { 'GGG': {'zone': 0, 'present': True, 'left_starting_zone': False}, 'OOO': {'zone': 1, 'present': True, 'left_starting_zone': False}, } - self.districts = { + self.districts: dict[str, RawDistrict] = { name: RawDistrict({ 'highest': '', - 'pallets': '', + 'pallets': {}, }) for name in DISTRICTS } @@ -108,7 +109,7 @@ def test_left_starting_zone(self) -> None: ) def test_outer_single(self) -> None: - self.districts['outer_nw']['pallets'] = 'G' + self.districts['outer_nw']['pallets'] = {'G': 1} self.assertScores( { 'GGG': 1, @@ -118,7 +119,7 @@ def test_outer_single(self) -> None: ) def test_outer_multiple(self) -> None: - self.districts['outer_nw']['pallets'] = 'GGO' + self.districts['outer_nw']['pallets'] = {'G': 2, 'O': 1} self.assertScores( { 'GGG': 2, @@ -129,7 +130,7 @@ def test_outer_multiple(self) -> None: def test_outer_highest(self) -> None: self.districts['outer_nw']['highest'] = 'G' - self.districts['outer_nw']['pallets'] = 'GGO' + self.districts['outer_nw']['pallets'] = {'G': 2, 'O': 1} self.assertScores( { 'GGG': 4, @@ -140,7 +141,7 @@ def test_outer_highest(self) -> None: def test_outer_highest_self(self) -> None: self.districts['outer_nw']['highest'] = 'G' - self.districts['outer_nw']['pallets'] = 'GG' + self.districts['outer_nw']['pallets'] = {'G': 2} self.assertScores( { 'GGG': 4, @@ -151,7 +152,7 @@ def test_outer_highest_self(self) -> None: def test_outer_highest_use_other_team(self) -> None: self.districts['outer_nw']['highest'] = 'G' - self.districts['outer_nw']['pallets'] = 'GO' + self.districts['outer_nw']['pallets'] = {'G': 1, 'O': 1} self.assertScores( { 'GGG': 2, @@ -161,7 +162,7 @@ def test_outer_highest_use_other_team(self) -> None: ) def test_inner_single(self) -> None: - self.districts['inner_ne']['pallets'] = 'O' + self.districts['inner_ne']['pallets'] = {'O': 1} self.assertScores( { 'GGG': 0, @@ -171,7 +172,7 @@ def test_inner_single(self) -> None: ) def test_inner_multiple(self) -> None: - self.districts['inner_ne']['pallets'] = 'GOO' + self.districts['inner_ne']['pallets'] = {'G': 1, 'O': 2} self.assertScores( { 'GGG': 2, @@ -182,7 +183,7 @@ def test_inner_multiple(self) -> None: def test_inner_highest(self) -> None: self.districts['inner_ne']['highest'] = 'O' - self.districts['inner_ne']['pallets'] = 'GOO' + self.districts['inner_ne']['pallets'] = {'G': 1, 'O': 2} self.assertScores( { 'GGG': 2, @@ -192,7 +193,7 @@ def test_inner_highest(self) -> None: ) def test_central_single(self) -> None: - self.districts['central']['pallets'] = 'O' + self.districts['central']['pallets'] = {'O': 1} self.assertScores( { 'GGG': 0, @@ -202,7 +203,7 @@ def test_central_single(self) -> None: ) def test_central_multiple(self) -> None: - self.districts['central']['pallets'] = 'GOO' + self.districts['central']['pallets'] = {'G': 1, 'O': 2} self.assertScores( { 'GGG': 3, @@ -213,7 +214,7 @@ def test_central_multiple(self) -> None: def test_central_highest(self) -> None: self.districts['central']['highest'] = 'O' - self.districts['central']['pallets'] = 'GOO' + self.districts['central']['pallets'] = {'G': 1, 'O': 2} self.assertScores( { 'GGG': 3, @@ -224,9 +225,9 @@ def test_central_highest(self) -> None: def test_mixed(self) -> None: self.teams_data['OOO']['left_starting_zone'] = True - self.districts['outer_sw']['pallets'] = 'O' - self.districts['inner_sw']['pallets'] = 'G' - self.districts['central']['pallets'] = 'GO' + 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, @@ -236,11 +237,11 @@ def test_mixed(self) -> None: ) def test_mixed_highest(self) -> None: - self.districts['outer_sw']['pallets'] = 'O' + self.districts['outer_sw']['pallets'] = {'O': 1} self.districts['inner_sw']['highest'] = 'G' - self.districts['inner_sw']['pallets'] = 'G' + self.districts['inner_sw']['pallets'] = {'G': 1} self.districts['central']['highest'] = 'G' - self.districts['central']['pallets'] = 'GO' + self.districts['central']['pallets'] = {'G': 1, 'O': 1} self.assertScores( { 'GGG': 10, @@ -259,7 +260,7 @@ def test_bad_highest_pallet_letter(self) -> None: ) def test_bad_pallet_letter(self) -> None: - self.districts['outer_sw']['pallets'] = 'o' + self.districts['outer_sw']['pallets'] = {'o': 1} self.assertInvalidScoresheet( self.districts, code='invalid_pallets', @@ -267,7 +268,7 @@ def test_bad_pallet_letter(self) -> None: def test_outer_highest_requires_multiple_tokens_self(self) -> None: self.districts['outer_sw']['highest'] = 'O' - self.districts['outer_sw']['pallets'] = 'O' + self.districts['outer_sw']['pallets'] = {'O': 1} self.assertInvalidScoresheet( self.districts, code='impossible_highest_single_pallet', @@ -281,7 +282,7 @@ def test_missing_district(self) -> None: ) def test_extra_district(self) -> None: - self.districts['bees'] = dict(self.districts['central']) + self.districts['bees'] = copy.deepcopy(self.districts['central']) self.assertInvalidScoresheet( self.districts, code='invalid_districts', @@ -291,7 +292,7 @@ def test_extra_district(self) -> None: def test_spacey(self) -> None: self.districts['outer_nw']['highest'] = ' O ' - self.districts['outer_nw']['pallets'] = ' G O Y ' + self.districts['outer_nw']['pallets'] = {'G': 1, 'O': 1, 'Y': 1} self.assertScores( { 'GGG': 1, @@ -311,14 +312,14 @@ def test_highest_when_no_pallets(self) -> None: def test_highest_when_team_not_present(self) -> None: self.districts['outer_sw']['highest'] = 'Y' - self.districts['outer_sw']['pallets'] = 'OP' + 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' + self.districts['outer_sw']['pallets'] = {'G': 7, 'O': 1} self.assertInvalidScoresheet( self.districts, code='too_many_pallets', diff --git a/scoring/update.html b/scoring/update.html index 934be6f..2281ec6 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -15,17 +15,19 @@ {% endmacro %} -{% macro input_pallets(x, y, name) %} - +{% macro input_pallets(x, y, name, colour_symbol) %} + + + + {% endmacro %} @@ -33,8 +35,11 @@ {% macro district(name, x, y) %} - {{ input_highest(x + 2.5, y + 15, name) }} - {{ input_pallets(x, y + 65, name) }} + {{ input_highest(x + 2.5, y + 5, name) }} + {{ input_pallets(x, y + 40, name, 'O') }} + {{ input_pallets(x + 55, y + 40, name, 'P') }} + {{ input_pallets(x + 55, y + 75, name, 'Y') }} + {{ input_pallets(x, y + 75, name, 'G') }} {% endmacro %} From 8db2b89ab7498885a2304abc7d6bd1ccb928dd05 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Mon, 25 Nov 2024 22:05:40 +0000 Subject: [PATCH 18/21] Add missing typing conversion --- scoring/converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scoring/converter.py b/scoring/converter.py index 95c4ac3..70816ea 100644 --- a/scoring/converter.py +++ b/scoring/converter.py @@ -90,10 +90,10 @@ def form_to_score(self, match: Match, form: InputForm) -> ScoreData: def score_team_to_form(self, tla: TLA, info: ScoreTeamData) -> OutputForm: zone_id = info['zone'] - return { + 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({ From efc98982f2dd456953c2e2ef0d38c447310c4a88 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Thu, 5 Dec 2024 21:37:52 +0000 Subject: [PATCH 19/21] Clarify docstring --- scoring/converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scoring/converter.py b/scoring/converter.py index 70816ea..5113b31 100644 --- a/scoring/converter.py +++ b/scoring/converter.py @@ -37,7 +37,7 @@ def form_team_to_score(self, form: InputForm, zone_id: ZoneId) -> SR2025ScoreTea """ Prepare a team's scoring data for saving in a score dict. - This is given a zone as form data is all keyed by zone. + This is also given a `ZoneId` since form data are all keyed by zone. """ return { **super().form_team_to_score(form, zone_id), From 0afd1c3f1deabdc7d40ad995c90cb40d14bb88ac Mon Sep 17 00:00:00 2001 From: Peter Law Date: Thu, 5 Dec 2024 21:56:33 +0000 Subject: [PATCH 20/21] Fix typo Co-authored-by: Andy Barrett-Sprot --- scoring/score.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scoring/score.py b/scoring/score.py index b79ee42..d2c8eaf 100644 --- a/scoring/score.py +++ b/scoring/score.py @@ -170,7 +170,7 @@ def validate(self, other_data): ) # Check that the "highest" pallet in districts which don't have a - # high-rises has another pallet to be placed on top of (pallets on the + # 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: From f473854eff8e228867f7b24a28ab204fa949a064 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 7 Dec 2024 19:38:33 +0000 Subject: [PATCH 21/21] Shadow the pallet count inputs in their colours This aims to help use of the scorer during the virtual competition, where we don't have the instructions on the physical sheet guiding users on which colours are what. --- scoring/update.html | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scoring/update.html b/scoring/update.html index 2281ec6..31bf5f8 100644 --- a/scoring/update.html +++ b/scoring/update.html @@ -15,9 +15,14 @@ {% endmacro %} -{% macro input_pallets(x, y, name, colour_symbol) %} +{% macro input_pallets(x, y, name, colour_symbol, corner) %} - + {{ input_highest(x + 2.5, y + 5, name) }} - {{ input_pallets(x, y + 40, name, 'O') }} - {{ input_pallets(x + 55, y + 40, name, 'P') }} - {{ input_pallets(x + 55, y + 75, name, 'Y') }} - {{ input_pallets(x, y + 75, name, 'G') }} + {{ 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 %}