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

Implement scoring for SR2025 #1

Merged
merged 21 commits into from
Dec 7, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 119 additions & 3 deletions scoring/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Adimote marked this conversation as resolved.
Show resolved Hide resolved
"""
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:
Copy link
Contributor

Choose a reason for hiding this comment

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

[nit] It doesn't look like this returns a score, it returns the raw district data, maybe we should rename the function for clarity?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hrm, the returned object is not itself a whole score, however it is in the score-flavoured structure (rather than the form-flavoured structure passed in). Open to better ideas if we have any, though worth bearing in mind that this is following existing patterns for naming of these functions.

Copy link
Contributor

Choose a reason for hiding this comment

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

Feel free to submit as-is,

maybe in the future we could rename "score" to "data" but I don't think it matters

"""
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
156 changes: 152 additions & 4 deletions scoring/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@
Required as part of a compstate.
"""

import warnings
from __future__ import annotations

import collections
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]


class InvalidScoresheetException(Exception):
Expand All @@ -13,21 +24,158 @@ 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 ValueError:
return ""

if not strings:
return right

left = ", ".join(strings[:-1])
return f"{left} {separator} {strings[-1]}"
PeterJCLaw marked this conversation as resolved.
Show resolved Hide resolved


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['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]

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
PeterJCLaw marked this conversation as resolved.
Show resolved Hide resolved

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")
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',
)

bad_highest = {}
for name, district in self._districts.items():
highest = district['highest']
if highest and highest not in ZONE_COLOURS:
Adimote marked this conversation as resolved.
Show resolved Hide resolved
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',
)
PeterJCLaw marked this conversation as resolved.
Show resolved Hide resolved

totals = collections.Counter()
for district in self._districts.values():
totals += district['pallet_counts']
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"{totals!r}",
PeterJCLaw marked this conversation as resolved.
Show resolved Hide resolved
code='too_many_pallets',
)


if __name__ == '__main__':
Expand Down
4 changes: 4 additions & 0 deletions scoring/setup.cfg
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 30 additions & 0 deletions scoring/sr2025.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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
)
Loading
Loading