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())
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.
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.
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.
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.
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).
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.
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.