ctfy.sdk.admin_resources.awd

client.admin.awd — configuring a classic-AWD match.

  1"""``client.admin.awd`` — configuring a classic-AWD match."""
  2
  3from __future__ import annotations
  4
  5from datetime import datetime
  6
  7from ctfy.sdk._helpers import _raise_for_status
  8from ctfy.sdk.base import BaseHttpClient
  9from ctfy.server.models import AdminTaskInfo, AwdMatchInfo, AwdProvisionPlanInfo
 10
 11
 12class AdminAwdResource:
 13    """The organiser's half of a match: cadence, gamebox, budgets.
 14
 15    ⚠️ **The round number is derived from the clock, not stored**, so
 16    ``starts_at`` / ``tick_seconds`` / ``challenge_id`` are frozen once
 17    the match has started — editing one would retroactively renumber
 18    every round already settled and published. :meth:`configure`
 19    answers 409 ``awd_match_locked`` naming the fields, rather than
 20    silently accepting a write that breaks the board.
 21
 22    There is deliberately no way to set ``last_tick_done``: it is the
 23    tick loop's bookmark, and moving it makes the loop re-settle rounds
 24    the board has already published.
 25    """
 26
 27    def __init__(self, http: BaseHttpClient) -> None:
 28        self._http = http
 29
 30    def get(self, competition_id: str) -> AwdMatchInfo:
 31        """This competition's match. 404 if it runs none.
 32
 33        ``current_tick`` and ``started`` are computed per read — asking
 34        again a round later gives a different answer with no write in
 35        between, which is the point.
 36        """
 37        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match")
 38        _raise_for_status(resp)
 39        return AwdMatchInfo.model_validate(resp.json())
 40
 41    def configure(
 42        self,
 43        competition_id: str,
 44        *,
 45        tick_seconds: int,
 46        tick_count: int,
 47        challenge_id: str = "",
 48        starts_at: datetime | None = None,
 49        per_tick_budget: int | None = None,
 50        total_budget: int | None = None,
 51    ) -> AwdMatchInfo:
 52        """Create the match, or edit it. Idempotent on the competition.
 53
 54        ``challenge_id`` may be left empty while the match is being put
 55        together — provisioning is what refuses without a gamebox, and
 56        it refuses at the point an organiser can still act on it.
 57        """
 58        body: dict[str, object] = {
 59            "challenge_id": challenge_id,
 60            "starts_at": starts_at.isoformat() if starts_at else None,
 61            "tick_seconds": tick_seconds,
 62            "tick_count": tick_count,
 63            "per_tick_budget": per_tick_budget,
 64            "total_budget": total_budget,
 65        }
 66        resp = self._http.request(
 67            "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body
 68        )
 69        _raise_for_status(resp)
 70        return AwdMatchInfo.model_validate(resp.json())
 71
 72    def delete(self, competition_id: str) -> None:
 73        """Remove a match that never started and holds no boxes.
 74
 75        Refused once the first round has opened — its scores are
 76        published and keyed by round, so the configuration they belong
 77        to has to stay. Lowering ``tick_count`` is how a running match
 78        is ended.
 79        """
 80        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match")
 81        _raise_for_status(resp)
 82
 83    def plan(self, competition_id: str) -> AwdProvisionPlanInfo:
 84        """What opening the arena would do, without doing it.
 85
 86        409 ``awd_provision_refused`` when the match cannot be
 87        provisioned at all — no gamebox, no teams, or a match that has
 88        already started (§4.5 fixes the box supply at kickoff).
 89        """
 90        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan")
 91        _raise_for_status(resp)
 92        return AwdProvisionPlanInfo.model_validate(resp.json())
 93
 94    def provision(self, competition_id: str) -> AdminTaskInfo:
 95        """Start a gamebox for every team that lacks one.
 96
 97        Returns the **task**, not the boxes: a fleet launch is minutes
 98        of work at event scale. Poll ``client.admin.tasks.get(id)`` (or
 99        ``.logs``, which names every team that failed) for the outcome.
100        Idempotent — re-running skips the teams already holding a box.
101        """
102        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision")
103        _raise_for_status(resp)
104        return AdminTaskInfo.model_validate(resp.json())
105
106    def teardown(self, competition_id: str) -> AdminTaskInfo:
107        """Stop every arena box of this match. Also a task.
108
109        Only this competition's ``lease="match"`` boxes: a player's own
110        on-demand instance is not part of the match, and no node is
111        wiped wholesale — the arena shares hosts with every other event
112        on the platform.
113        """
114        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes")
115        _raise_for_status(resp)
116        return AdminTaskInfo.model_validate(resp.json())
class AdminAwdResource:
 13class AdminAwdResource:
 14    """The organiser's half of a match: cadence, gamebox, budgets.
 15
 16    ⚠️ **The round number is derived from the clock, not stored**, so
 17    ``starts_at`` / ``tick_seconds`` / ``challenge_id`` are frozen once
 18    the match has started — editing one would retroactively renumber
 19    every round already settled and published. :meth:`configure`
 20    answers 409 ``awd_match_locked`` naming the fields, rather than
 21    silently accepting a write that breaks the board.
 22
 23    There is deliberately no way to set ``last_tick_done``: it is the
 24    tick loop's bookmark, and moving it makes the loop re-settle rounds
 25    the board has already published.
 26    """
 27
 28    def __init__(self, http: BaseHttpClient) -> None:
 29        self._http = http
 30
 31    def get(self, competition_id: str) -> AwdMatchInfo:
 32        """This competition's match. 404 if it runs none.
 33
 34        ``current_tick`` and ``started`` are computed per read — asking
 35        again a round later gives a different answer with no write in
 36        between, which is the point.
 37        """
 38        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match")
 39        _raise_for_status(resp)
 40        return AwdMatchInfo.model_validate(resp.json())
 41
 42    def configure(
 43        self,
 44        competition_id: str,
 45        *,
 46        tick_seconds: int,
 47        tick_count: int,
 48        challenge_id: str = "",
 49        starts_at: datetime | None = None,
 50        per_tick_budget: int | None = None,
 51        total_budget: int | None = None,
 52    ) -> AwdMatchInfo:
 53        """Create the match, or edit it. Idempotent on the competition.
 54
 55        ``challenge_id`` may be left empty while the match is being put
 56        together — provisioning is what refuses without a gamebox, and
 57        it refuses at the point an organiser can still act on it.
 58        """
 59        body: dict[str, object] = {
 60            "challenge_id": challenge_id,
 61            "starts_at": starts_at.isoformat() if starts_at else None,
 62            "tick_seconds": tick_seconds,
 63            "tick_count": tick_count,
 64            "per_tick_budget": per_tick_budget,
 65            "total_budget": total_budget,
 66        }
 67        resp = self._http.request(
 68            "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body
 69        )
 70        _raise_for_status(resp)
 71        return AwdMatchInfo.model_validate(resp.json())
 72
 73    def delete(self, competition_id: str) -> None:
 74        """Remove a match that never started and holds no boxes.
 75
 76        Refused once the first round has opened — its scores are
 77        published and keyed by round, so the configuration they belong
 78        to has to stay. Lowering ``tick_count`` is how a running match
 79        is ended.
 80        """
 81        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match")
 82        _raise_for_status(resp)
 83
 84    def plan(self, competition_id: str) -> AwdProvisionPlanInfo:
 85        """What opening the arena would do, without doing it.
 86
 87        409 ``awd_provision_refused`` when the match cannot be
 88        provisioned at all — no gamebox, no teams, or a match that has
 89        already started (§4.5 fixes the box supply at kickoff).
 90        """
 91        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan")
 92        _raise_for_status(resp)
 93        return AwdProvisionPlanInfo.model_validate(resp.json())
 94
 95    def provision(self, competition_id: str) -> AdminTaskInfo:
 96        """Start a gamebox for every team that lacks one.
 97
 98        Returns the **task**, not the boxes: a fleet launch is minutes
 99        of work at event scale. Poll ``client.admin.tasks.get(id)`` (or
100        ``.logs``, which names every team that failed) for the outcome.
101        Idempotent — re-running skips the teams already holding a box.
102        """
103        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision")
104        _raise_for_status(resp)
105        return AdminTaskInfo.model_validate(resp.json())
106
107    def teardown(self, competition_id: str) -> AdminTaskInfo:
108        """Stop every arena box of this match. Also a task.
109
110        Only this competition's ``lease="match"`` boxes: a player's own
111        on-demand instance is not part of the match, and no node is
112        wiped wholesale — the arena shares hosts with every other event
113        on the platform.
114        """
115        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes")
116        _raise_for_status(resp)
117        return AdminTaskInfo.model_validate(resp.json())

The organiser's half of a match: cadence, gamebox, budgets.

⚠️ The round number is derived from the clock, not stored, so starts_at / tick_seconds / challenge_id are frozen once the match has started — editing one would retroactively renumber every round already settled and published. configure() answers 409 awd_match_locked naming the fields, rather than silently accepting a write that breaks the board.

There is deliberately no way to set last_tick_done: it is the tick loop's bookmark, and moving it makes the loop re-settle rounds the board has already published.

AdminAwdResource(http: ctfy.sdk.base.BaseHttpClient)
28    def __init__(self, http: BaseHttpClient) -> None:
29        self._http = http
def get(self, competition_id: str) -> ctfy.server.models.AwdMatchInfo:
31    def get(self, competition_id: str) -> AwdMatchInfo:
32        """This competition's match. 404 if it runs none.
33
34        ``current_tick`` and ``started`` are computed per read — asking
35        again a round later gives a different answer with no write in
36        between, which is the point.
37        """
38        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match")
39        _raise_for_status(resp)
40        return AwdMatchInfo.model_validate(resp.json())

This competition's match. 404 if it runs none.

current_tick and started are computed per read — asking again a round later gives a different answer with no write in between, which is the point.

def configure( self, competition_id: str, *, tick_seconds: int, tick_count: int, challenge_id: str = '', starts_at: datetime.datetime | None = None, per_tick_budget: int | None = None, total_budget: int | None = None) -> ctfy.server.models.AwdMatchInfo:
42    def configure(
43        self,
44        competition_id: str,
45        *,
46        tick_seconds: int,
47        tick_count: int,
48        challenge_id: str = "",
49        starts_at: datetime | None = None,
50        per_tick_budget: int | None = None,
51        total_budget: int | None = None,
52    ) -> AwdMatchInfo:
53        """Create the match, or edit it. Idempotent on the competition.
54
55        ``challenge_id`` may be left empty while the match is being put
56        together — provisioning is what refuses without a gamebox, and
57        it refuses at the point an organiser can still act on it.
58        """
59        body: dict[str, object] = {
60            "challenge_id": challenge_id,
61            "starts_at": starts_at.isoformat() if starts_at else None,
62            "tick_seconds": tick_seconds,
63            "tick_count": tick_count,
64            "per_tick_budget": per_tick_budget,
65            "total_budget": total_budget,
66        }
67        resp = self._http.request(
68            "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body
69        )
70        _raise_for_status(resp)
71        return AwdMatchInfo.model_validate(resp.json())

Create the match, or edit it. Idempotent on the competition.

challenge_id may be left empty while the match is being put together — provisioning is what refuses without a gamebox, and it refuses at the point an organiser can still act on it.

def delete(self, competition_id: str) -> None:
73    def delete(self, competition_id: str) -> None:
74        """Remove a match that never started and holds no boxes.
75
76        Refused once the first round has opened — its scores are
77        published and keyed by round, so the configuration they belong
78        to has to stay. Lowering ``tick_count`` is how a running match
79        is ended.
80        """
81        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match")
82        _raise_for_status(resp)

Remove a match that never started and holds no boxes.

Refused once the first round has opened — its scores are published and keyed by round, so the configuration they belong to has to stay. Lowering tick_count is how a running match is ended.

def plan(self, competition_id: str) -> ctfy.server.models.AwdProvisionPlanInfo:
84    def plan(self, competition_id: str) -> AwdProvisionPlanInfo:
85        """What opening the arena would do, without doing it.
86
87        409 ``awd_provision_refused`` when the match cannot be
88        provisioned at all — no gamebox, no teams, or a match that has
89        already started (§4.5 fixes the box supply at kickoff).
90        """
91        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan")
92        _raise_for_status(resp)
93        return AwdProvisionPlanInfo.model_validate(resp.json())

What opening the arena would do, without doing it.

409 awd_provision_refused when the match cannot be provisioned at all — no gamebox, no teams, or a match that has already started (§4.5 fixes the box supply at kickoff).

def provision(self, competition_id: str) -> ctfy.server.models.AdminTaskInfo:
 95    def provision(self, competition_id: str) -> AdminTaskInfo:
 96        """Start a gamebox for every team that lacks one.
 97
 98        Returns the **task**, not the boxes: a fleet launch is minutes
 99        of work at event scale. Poll ``client.admin.tasks.get(id)`` (or
100        ``.logs``, which names every team that failed) for the outcome.
101        Idempotent — re-running skips the teams already holding a box.
102        """
103        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision")
104        _raise_for_status(resp)
105        return AdminTaskInfo.model_validate(resp.json())

Start a gamebox for every team that lacks one.

Returns the task, not the boxes: a fleet launch is minutes of work at event scale. Poll client.admin.tasks.get(id) (or .logs, which names every team that failed) for the outcome. Idempotent — re-running skips the teams already holding a box.

def teardown(self, competition_id: str) -> ctfy.server.models.AdminTaskInfo:
107    def teardown(self, competition_id: str) -> AdminTaskInfo:
108        """Stop every arena box of this match. Also a task.
109
110        Only this competition's ``lease="match"`` boxes: a player's own
111        on-demand instance is not part of the match, and no node is
112        wiped wholesale — the arena shares hosts with every other event
113        on the platform.
114        """
115        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes")
116        _raise_for_status(resp)
117        return AdminTaskInfo.model_validate(resp.json())

Stop every arena box of this match. Also a task.

Only this competition's lease="match" boxes: a player's own on-demand instance is not part of the match, and no node is wiped wholesale — the arena shares hosts with every other event on the platform.