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 %}
+
{% endblock %}