Skip to content

Commit

Permalink
Merge branch 'scoring'
Browse files Browse the repository at this point in the history
  • Loading branch information
PeterJCLaw committed Dec 7, 2024
2 parents c5fb1db + f473854 commit 132a387
Show file tree
Hide file tree
Showing 7 changed files with 698 additions and 44 deletions.
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 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
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]}"


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__':
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

0 comments on commit 132a387

Please sign in to comment.