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 18 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
127 changes: 126 additions & 1 deletion scoring/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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': {
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
181 changes: 177 additions & 4 deletions scoring/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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]}"
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['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
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")
# 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',
)
PeterJCLaw marked this conversation as resolved.
Show resolved Hide resolved

# 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:
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',
)

# 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-rises has another pallet to be placed on top of (pallets on the
PeterJCLaw marked this conversation as resolved.
Show resolved Hide resolved
# 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__':
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
41 changes: 41 additions & 0 deletions scoring/sr2025.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading