ctfy.sdk.admin_resources.competitions

client.admin.competitions — competition CRUD + roster (admin).

  1"""``client.admin.competitions`` — competition CRUD + roster (admin)."""
  2
  3from __future__ import annotations
  4
  5import builtins
  6from datetime import datetime
  7from typing import Any, Literal
  8
  9from ctfy.core.challenge_source import ChallengeScope
 10from ctfy.core.enums import (
 11    DEFAULT_SCORING_RULE_VERSION,
 12    InternetAccess,
 13    InternetPolicy,
 14    ScoringRuleName,
 15    ScoringRuleVersion,
 16    SessionEndMode,
 17    SolveEnvMode,
 18    TcpAccess,
 19)
 20from ctfy.core.localized import LocalizedText
 21from ctfy.sdk._helpers import _raise_for_status
 22from ctfy.sdk.base import BaseHttpClient
 23from ctfy.server.models import (
 24    AdminScoringChangeInfo,
 25    AdminScoringChangesResponse,
 26    AuthoringChecklistInfo,
 27    AuthoringScaleInfo,
 28    AuthoringSmokeLedgerInfo,
 29    AwdMatchConfig,
 30    CompetitionChallengeChangeInfo,
 31    CompetitionChallengeOrder,
 32    CompetitionChallengeSource,
 33    CompetitionDriftInfo,
 34    CompetitionFormatPreset,
 35    CompetitionInfo,
 36    CompetitionInternetApplied,
 37    CompetitionInternetApplyRequest,
 38    CompetitionInternetInfo,
 39    CompetitionPinnedChallenges,
 40    CompetitionPoolInfo,
 41    CompetitionPreviewChallenges,
 42    CompetitionRecords,
 43    CompetitionRegistrationInfo,
 44    CompetitionTcpAccessInfo,
 45    RepriceRequest,
 46    ScoringManifestInfo,
 47    ScoringPreview,
 48    ScoringPreviewChallenge,
 49    ScoringPreviewRequest,
 50)
 51
 52
 53class AdminCompetitionsResource:
 54    """Create / update / delete competitions and inspect their roster."""
 55
 56    def __init__(self, http: BaseHttpClient) -> None:
 57        self._http = http
 58
 59    def formats(self) -> builtins.list[CompetitionFormatPreset]:
 60        """The wizard's named starting points for a new competition.
 61
 62        Each entry is a set of axis values, not a type: apply one to
 63        ``create(...)`` and then override whatever the event needs. The
 64        platform stores no record of which one was used.
 65        """
 66        resp = self._http.request("GET", "/admin/competition-formats")
 67        _raise_for_status(resp)
 68        return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]
 69
 70    def internet(self, competition_id: str) -> CompetitionInternetInfo:
 71        """Every challenge here: the author's internet declaration and its verdict (#1969).
 72
 73        ``rows`` sorts the conflicts first — a challenge whose author says
 74        it needs the internet and that this competition denies it — and
 75        ``conflicts`` counts them. Change the policy or a per-challenge
 76        override with :meth:`update` (``internet_policy`` /
 77        ``challenge_internet``, the latter replaced whole).
 78        """
 79        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/internet")
 80        _raise_for_status(resp)
 81        return CompetitionInternetInfo.model_validate(resp.json())
 82
 83    def apply_internet(self, competition_id: str, *, reason: str) -> CompetitionInternetApplied:
 84        """Apply today's internet policy to the boxes already running (#2067).
 85
 86        The policy is read at start, so a change leaves running boxes as they
 87        were (:meth:`internet`'s ``running_differs``). This moves each of them
 88        to the verdict the policy gives today, through its node, and audits
 89        each move with *reason*. Every box tried is in ``results`` with its
 90        outcome; a failure keeps the box as it was.
 91        """
 92        resp = self._http.request(
 93            "POST",
 94            f"/admin/competitions/{competition_id}/internet/apply",
 95            json=CompetitionInternetApplyRequest(reason=reason).model_dump(mode="json"),
 96        )
 97        _raise_for_status(resp)
 98        return CompetitionInternetApplied.model_validate(resp.json())
 99
100    def tcp_access(self, competition_id: str) -> CompetitionTcpAccessInfo:
101        """How players reach this competition's TCP challenges (#2082).
102
103        The default (``tls_sni`` / ``public_port`` / ``vpn``), every TCP
104        challenge's own way in, and how many live nodes can publish a port.
105        Change either with :meth:`update` (``tcp_access`` /
106        ``challenge_tcp_access``, the latter replaced whole).
107        """
108        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/tcp-access")
109        _raise_for_status(resp)
110        return CompetitionTcpAccessInfo.model_validate(resp.json())
111
112    def pool(self, competition_id: str) -> CompetitionPoolInfo:
113        """Where this event's work runs: its dedicated node pool (#1965 E).
114
115        ``state`` is ``shared`` (no pool declared), ``ready`` or
116        ``no_live_node`` — the last meaning every start is refused and every
117        smoke waits, since nothing falls back to the shared pools. ``nodes``
118        is every machine labelled ``competition=<id>``, live or not. Switch
119        the declaration with :meth:`update` (``dedicated_pool``).
120        """
121        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pool")
122        _raise_for_status(resp)
123        return CompetitionPoolInfo.model_validate(resp.json())
124
125    def authoring_checklist(self, competition_id: str) -> AuthoringChecklistInfo:
126        """What must be right before this event opens automatic review (#1965 C3).
127
128        ``rows`` — the dedicated pool, the authoring windows, the contributor
129        agreement, the smoke quota, the internet policy — each ``ok`` or not,
130        with a ``reason`` code and the setting's ``value``; ``ready`` is all of
131        them. Advice: nothing refuses on it. Fix a row with :meth:`update`.
132        """
133        resp = self._http.request(
134            "GET", f"/admin/competitions/{competition_id}/authoring-checklist"
135        )
136        _raise_for_status(resp)
137        return AuthoringChecklistInfo.model_validate(resp.json())
138
139    def smoke_ledger(self, competition_id: str) -> AuthoringSmokeLedgerInfo:
140        """Every automatic smoke run charged in this competition, oldest first
141        (#1979 E3).
142
143        Each row says whose allowance it counted against (``purpose``:
144        ``quota`` a team's window, ``retry`` nobody's, ``budget`` a person's
145        day) and whether the platform refunded it (``refunded_at``, with the
146        reason) because the run reached no verdict. The whole set, unpaged.
147        """
148        resp = self._http.request(
149            "GET", f"/admin/competitions/{competition_id}/authoring/smoke-runs"
150        )
151        _raise_for_status(resp)
152        return AuthoringSmokeLedgerInfo.model_validate(resp.json())
153
154    def scoring_changes(self, competition_id: str) -> builtins.list[AdminScoringChangeInfo]:
155        """Every recorded change to this competition's scoring made while it
156        was live (#1965 H), oldest first — with who made it and the board
157        each one replaced. The public copy is
158        ``client.competitions.scoring_changes``."""
159        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-changes")
160        _raise_for_status(resp)
161        return AdminScoringChangesResponse.model_validate(resp.json()).changes
162
163    def scoring_manifest(self, competition_id: str) -> ScoringManifestInfo:
164        """The frozen scoring manifest (#2731 B): per frozen challenge, the
165        price inputs it was frozen with beside what the catalog says now, and
166        every re-price so far. ``frozen`` is ``False`` before the start."""
167        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-manifest")
168        _raise_for_status(resp)
169        return ScoringManifestInfo.model_validate(resp.json())
170
171    def reprice_challenge(
172        self, competition_id: str, challenge_id: str, *, reason: str
173    ) -> ScoringManifestInfo:
174        """Re-price one frozen challenge from the catalog, with a recorded
175        reason (#2731 B) — the one door that moves a frozen price. Returns the
176        manifest as it now stands."""
177        resp = self._http.request(
178            "POST",
179            f"/admin/competitions/{competition_id}/scoring-manifest/{challenge_id}/reprice",
180            json=RepriceRequest(reason=reason).model_dump(mode="json"),
181        )
182        _raise_for_status(resp)
183        return ScoringManifestInfo.model_validate(resp.json())
184
185    def scoring_preview(
186        self,
187        scoring_rule: ScoringRuleName | Literal[""],
188        scoring_params: dict[str, Any],
189        challenges: builtins.list[ScoringPreviewChallenge],
190        *,
191        reference_id: str = "",
192        max_solvers: int = 100,
193        table_solvers: int = 10,
194        compare_curves: builtins.list[str] | None = None,
195    ) -> ScoringPreview:
196        """Price a **draft** scoring config with the board's own arithmetic.
197
198        What the scoring form's live curve calls: per-challenge values, the
199        value curve for ``reference_id`` (and any ``compare_curves``) and the
200        first-blood ladder. Nothing is stored, and a draft the board could
201        not run is refused as the write path refuses it.
202        """
203        body = ScoringPreviewRequest(
204            scoring_rule=scoring_rule,
205            scoring_params=scoring_params,
206            challenges=challenges,
207            reference_id=reference_id,
208            max_solvers=max_solvers,
209            table_solvers=table_solvers,
210            compare_curves=compare_curves or [],
211        )
212        resp = self._http.request("POST", "/admin/scoring/preview", json=body.model_dump())
213        _raise_for_status(resp)
214        return ScoringPreview.model_validate(resp.json())
215
216    def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo:
217        """What this board's ``authoring_weight`` is worth, in challenges.
218
219        A ``peer_authoring`` weight is a fixed multiplier on a curve
220        output, so on its own it says nothing: 200 is generous on a board
221        of 100-point flags and a rounding error on one of 5000. This
222        prices it against the competition's own challenge set.
223
224        Read-only, and computed from the **stored** rule and challenge
225        set — a weight you have not saved is not what this answers about.
226        """
227        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale")
228        _raise_for_status(resp)
229        return AuthoringScaleInfo.model_validate(resp.json())
230
231    def records(self, competition_id: str) -> CompetitionRecords:
232        """Every record of one competition, for scoring outside the platform.
233
234        Teams (with each agent's owner and the owner's team), solves as the
235        board ranks them, submissions without their text, agent runs, and
236        uploads with their authors — complete, never a page.
237        """
238        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records")
239        _raise_for_status(resp)
240        return CompetitionRecords.model_validate(resp.json())
241
242    def list(
243        self, offset: int = 0, limit: int = 50, kind: str = ""
244    ) -> builtins.list[CompetitionInfo]:
245        """Every competition — past, current, scheduled. ``kind`` ∈ ``"" |
246        "event" | "suite"`` narrows it ("" = both)."""
247        params: dict[str, Any] = {"offset": offset, "limit": limit}
248        if kind:
249            params["kind"] = kind
250        resp = self._http.request("GET", "/admin/competitions", params=params)
251        _raise_for_status(resp)
252        return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]
253
254    def create(
255        self,
256        *,
257        title: str | LocalizedText,
258        description: str | LocalizedText = "",
259        handbook: str | LocalizedText = "",
260        starts_at: datetime | None = None,
261        ends_at: datetime | None = None,
262        challenge_ids: builtins.list[str] | None = None,
263        challenge_scope: ChallengeScope = "set",
264        status: str = "draft",
265        access: str = "public",
266        kind: str = "event",
267        eval_standard: bool = False,
268        corpus_sha: str = "",
269        scoring_rule: str = "",
270        scoring_params: dict[str, Any] | None = None,
271        scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION,
272        sessions: builtins.list[tuple[datetime, datetime]] | None = None,
273        authoring_windows: builtins.list[tuple[datetime | None, datetime | None]] | None = None,
274        awd_match: AwdMatchConfig | None = None,
275        entrants_are_agents: bool = False,
276        internet_policy: InternetPolicy = InternetPolicy.ALLOW,
277        challenge_internet: dict[str, InternetAccess] | None = None,
278        tcp_access: TcpAccess = TcpAccess.TLS_SNI,
279        challenge_tcp_access: dict[str, TcpAccess] | None = None,
280        session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP,
281        session_end_grace_s: int = 900,
282        solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE,
283        solve_grace_s: int = 900,
284        freeze_minutes: int = 0,
285        freeze_auto_unfreeze: bool = False,
286    ) -> CompetitionInfo:
287        """Create a new competition. ``title`` is a bare string — filed under
288        ``zh-CN`` when it holds a CJK ideograph, else ``en`` — or a
289        ``{locale: text}`` map naming both (#2230). ``None`` time fields = no bound
290        (sent as JSON ``null``). Unknown ``challenge_ids`` → 422.
291        ``challenge_scope`` is what entrants may play (#2553): ``"set"`` (the
292        default) — ``challenge_ids`` and nothing else, so an empty set starts
293        nothing — or ``"catalog"``, where any catalog challenge may start.
294        ``status`` defaults to ``draft`` (hidden from players); pass
295        ``"published"`` to make it immediately visible. ``access``
296        defaults to ``public``; pass ``"private_listed"`` /
297        ``"private_hidden"`` for an invite-only competition. ``kind`` is
298        ``"event"`` (a hosted contest, the default) or ``"suite"`` (a public
299        benchmark set open at any time: no window, no registration, listed on
300        the 测评集 page instead of the contest list; #2097).
301        ``eval_standard=True`` marks it for the platform's hosted evaluation
302        (only such a competition may back an official EvalCampaign);
303        ``corpus_sha`` pins the corpus snapshot it was certified against.
304        ``scoring_rule`` selects the board's ranking algorithm
305        (``""`` = flag count, ``static_points``, ``dynamic_points``) with
306        ``scoring_params`` carrying its tuning; set it here where you can,
307        because scoring freezes once the competition goes live.
308        ``scoring_rule_version`` is what a multi-flag challenge is worth
309        (#2731): ``challenge_total`` (the default) shares one total among
310        its scoring questions by weight; ``legacy`` pays every flag the
311        full value. It can be changed with :meth:`update` only before the
312        competition goes live (``409 scoring_rule_version_locked``).
313        ``sessions`` declares the playable slices of the window as
314        ``(start, end)`` pairs — a three-day event running 08:00-16:00
315        daily is three of them. Supplying it also *sets* ``starts_at`` /
316        ``ends_at``, which the server derives from the envelope, and
317        opts the competition into the play gate: outside a session,
318        submissions and instance starts are refused and running
319        instances are torn down. Omit it for a single continuous
320        window, which is how every competition behaves by default.
321        ``authoring_windows`` is the same idea for uploads: ``(start,
322        end)`` pairs, either side ``None`` for unbounded, outside which an
323        authored-challenge upload is refused — four mornings is four
324        pairs. Omit it to take uploads for as long as the event runs.
325        ``handbook`` is the organiser's long-form rules page, markdown,
326        capped server-side — ``description`` stays the one-line blurb the
327        listing and the page header truncate. Both are localised: pass a
328        ``{locale: text}`` map to write one translation, or a bare string,
329        which the server reads as ``en``. ⚠️ **A bare string is kept in the
330        signature rather than deprecated** — every caller that predates
331        localisation sends one, and a 422 for all of them would be a
332        breaking change dressed up as a feature; the coercion is the
333        server's (``coerce_localized``), so the two cannot disagree.
334        ``entrants_are_agents=True`` marks it an agent exhibition: human
335        registration is refused server-side and the browser shows a
336        spectate affordance rather than a sign-up button.
337        ``internet_policy`` decides whether instances started here may
338        reach the internet — ``allow`` (the default, today's behaviour),
339        ``deny``, or ``per_challenge`` (each challenge as its author
340        declared) — and ``challenge_internet`` overrides it per challenge
341        id (#1969). ``tcp_access`` is how players reach a TCP challenge —
342        ``tls_sni`` (the default), ``public_port`` or ``vpn`` — and
343        ``challenge_tcp_access`` overrides it per challenge id (#2082).
344        ``session_end_mode`` decides what a play session's end does
345        to running boxes -- ``pause`` / ``stop_keep`` (default) / ``grace``
346        (``session_end_grace_s``) / ``delete`` -- and the last session always
347        deletes (#2128). ``solve_env_mode`` decides what a full solve does to
348        the solved box -- ``destroy`` / ``grace`` (default,
349        ``solve_grace_s``) / ``keep_until_ttl`` (#2105). ``freeze_minutes`` holds the
350        players' scoreboard at ``ends_at - freeze_minutes`` until
351        :meth:`unfreeze` (or, with ``freeze_auto_unfreeze``, until the event
352        ends); ``0`` never freezes (#2133)."""
353        resp = self._http.request(
354            "POST",
355            "/admin/competitions",
356            json={
357                **({"title_i18n": title} if isinstance(title, dict) else {"title": title}),
358                "description": description,
359                "handbook": handbook,
360                "starts_at": starts_at.isoformat() if starts_at else None,
361                "ends_at": ends_at.isoformat() if ends_at else None,
362                "challenge_ids": challenge_ids or [],
363                "challenge_scope": challenge_scope,
364                "status": status,
365                "access": access,
366                "kind": kind,
367                "eval_standard": eval_standard,
368                "corpus_sha": corpus_sha,
369                "scoring_rule": scoring_rule,
370                "scoring_params": scoring_params or {},
371                "scoring_rule_version": scoring_rule_version,
372                # An agent exhibition: entrants are dispatched agents, so
373                # human registration is refused and the browser offers a
374                # spectate affordance. See ``CompetitionCreate``.
375                "entrants_are_agents": entrants_are_agents,
376                # #1969: the internet axis and its per-challenge overrides.
377                "internet_policy": str(internet_policy),
378                "challenge_internet": {k: str(v) for k, v in (challenge_internet or {}).items()},
379                # #2082: the way in for TCP challenges, and its overrides.
380                "tcp_access": str(tcp_access),
381                "challenge_tcp_access": {
382                    k: str(v) for k, v in (challenge_tcp_access or {}).items()
383                },
384                "session_end_mode": str(session_end_mode),
385                "session_end_grace_s": session_end_grace_s,
386                "solve_env_mode": str(solve_env_mode),
387                "solve_grace_s": solve_grace_s,
388                "freeze_minutes": freeze_minutes,
389                "freeze_auto_unfreeze": freeze_auto_unfreeze,
390                "sessions": [
391                    {"starts_at": s.isoformat(), "ends_at": e.isoformat()}
392                    for s, e in (sessions or [])
393                ],
394                "authoring_windows": [
395                    {
396                        "starts_at": s.isoformat() if s else None,
397                        "ends_at": e.isoformat() if e else None,
398                    }
399                    for s, e in (authoring_windows or [])
400                ],
401                # ⚠️ **Absent, never `{}`.** The server's model is
402                # `extra="forbid"` with two required cadence fields, so
403                # an empty object is a 422 on every competition that
404                # runs no rounds. `None` here means "not configured in
405                # this request", which the upsert route can still
406                # satisfy tomorrow — never "this competition has no
407                # match".
408                **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}),
409            },
410        )
411        _raise_for_status(resp)
412        return CompetitionInfo.model_validate(resp.json())
413
414    def update(self, competition_id: str, **fields: Any) -> CompetitionInfo:
415        """PATCH any subset of ``title_i18n`` (the whole ``{locale: text}``
416        title map, replaced; #2230) / ``title`` (a bare string, merged into the
417        locale it is written in) / ``description`` / ``starts_at`` /
418        ``ends_at`` / ``challenge_ids`` / ``challenge_scope`` (``set`` |
419        ``catalog``, #2553 — a change to what may be played, so a locked event
420        takes it only like a change to the set) / ``status`` / ``access`` /
421        ``kind`` / ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` /
422        ``scoring_params`` / ``scoring_rule_version`` (before the start only) /
423        ``sessions`` / ``anonymous_entry`` /
424        ``anonymous_entry_kind`` / ``authoring_review`` (``human`` |
425        ``automatic``: under ``automatic`` a passing smoke on the event's
426        dedicated pool approves, publishes and curates an upload) /
427        ``authoring_smoke_quota`` (smoke runs per team per authoring window
428        under ``automatic``) / ``authoring_rate_limit_count`` +
429        ``authoring_rate_limit_window_s`` (uploads one team may make per
430        rolling window, #2079; ``0`` is off) / ``authoring_uploads_per_window``
431        (new uploads one team may start per authoring window, a re-upload of its
432        own upload never counted; ``0`` is **unlimited**) /
433        ``pinned`` (pin / unpin the event for everybody, #2070 — **platform
434        admins only**, ``403 pin_requires_platform_admin`` for an organiser) /
435        ``organiser_contact_email`` (where entrants reach the organisers, shown
436        on the competition page and in the registration mail, #2283; ``""``
437        removes it) /
438        ``authoring_overrides`` (#2885: a ``{setting: int}`` object of the
439        platform's own authoring setting names, e.g.
440        ``{"authored_challenge_memory_mb": 8192}``; only the keys sent move,
441        ``0`` defers to the platform; the runtime budget, build timeout,
442        auto-smoke budget and re-check interval are **platform admins only**,
443        ``403 authoring_override_requires_platform_admin``) /
444        ``authoring_cooldown`` (``default`` |
445        ``platform`` | ``competition`` | ``off``: the pause after repeated
446        rejections, with ``authoring_cooldown_threshold`` / ``_window_s`` /
447        ``_pause_s`` read under ``competition``) / ``report_disclosure``
448        (``default`` | ``after_lock`` | ``own`` | ``own_and_board``: what
449        players are shown about how they are doing, #2030) / ``freeze_minutes`` +
450        ``freeze_auto_unfreeze`` (hold the players' scoreboard for the last N
451        minutes, #2133; ``0`` is off). ⚠️ This is a **passthrough** — the list
452        is what the wire accepts today, so it is a pointer to
453        ``CompetitionUpdate`` rather than a gate, and :meth:`create` is
454        the narrow one (it builds its body by hand and cannot carry a
455        field it does not name). ⚠️ ``anonymous_entry`` may be *widened*
456        freely and is refused on a narrowing once entrants have relied
457        on it, ``required`` → ``optional`` included: that transition
458        un-anonymises the whole field at once. Omit a field to leave it; pass
459        ``None`` to clear. ``sessions`` is the one field where ``[]`` is
460        meaningful rather than empty: it clears the schedule and returns
461        the competition to a single continuous window; ``authoring_windows``
462        is the other, and ``[]`` there takes uploads for as long as the
463        event runs. Changing the scoring fields on a live competition needs
464        ``scoring_change_reason`` (``422 scoring_change_needs_reason``
465        without one): the change is recorded and shown on the board — see
466        ``competitions.scoring_live``. ``lock_challenge_versions`` holds an
467        event's uploaded challenges still while it is in play (#2041); a
468        change to ``challenge_ids`` of such an event then needs
469        ``challenge_change_confirmed=True`` and a ``challenge_change_reason``
470        (``409 challenge_set_locked`` otherwise), recorded per challenge —
471        see :meth:`challenge_changes`."""
472        resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields)
473        _raise_for_status(resp)
474        return CompetitionInfo.model_validate(resp.json())
475
476    def challenge_source(self, competition_id: str) -> CompetitionChallengeSource:
477        """Where a competition's challenge set comes from (#2234): the
478        competition it follows, whether that source passes its set on now
479        (only while it is published), and the competitions that follow it."""
480        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-source")
481        _raise_for_status(resp)
482        return CompetitionChallengeSource.model_validate(resp.json())
483
484    def set_challenge_source(self, competition_id: str, source_id: str) -> CompetitionInfo:
485        """Make a competition's challenge set follow ``source_id``'s, or stop
486        following with ``""`` (#2234).
487
488        The follower then serves the source's set, read live, minus player
489        uploads; its own list is cleared and not editable while it follows
490        (``409 challenge_set_followed``). Stopping keeps the set it serves at
491        that moment as its own list. One level only: refused with
492        ``challenge_source_chained`` / ``challenge_source_followed``; the
493        caller must organise the source (``403 challenge_source_forbidden``).
494        Solves, instances and boards stay each competition's own."""
495        return self.update(competition_id, challenge_source_competition_id=source_id)
496
497    def reorder_challenges(
498        self, competition_id: str, challenge_ids: builtins.list[str]
499    ) -> CompetitionChallengeOrder:
500        """Put a competition's challenges in the order its entrants see them
501        (#2037). ``challenge_ids`` must be exactly the set it holds now,
502        reordered — ``competition_challenge_order_stale`` when another
503        organiser changed it meanwhile."""
504        resp = self._http.request(
505            "PUT",
506            f"/admin/competitions/{competition_id}/challenge-order",
507            json={"challenge_ids": challenge_ids},
508        )
509        _raise_for_status(resp)
510        return CompetitionChallengeOrder.model_validate(resp.json())
511
512    def pinned_challenges(self, competition_id: str) -> CompetitionPinnedChallenges:
513        """The challenges pinned to the head of a competition's list (#2070),
514        in the order they are shown."""
515        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pinned-challenges")
516        _raise_for_status(resp)
517        return CompetitionPinnedChallenges.model_validate(resp.json())
518
519    def set_pinned_challenges(
520        self, competition_id: str, challenge_ids: builtins.list[str]
521    ) -> CompetitionPinnedChallenges:
522        """Pin challenges to the head of a competition's list, in the order
523        given; the whole list is replaced (``[]`` clears it). Every id must be
524        one the competition serves now (``422 unknown_pinned_challenge``). A
525        pin is ordering and a marker, never visibility."""
526        resp = self._http.request(
527            "PUT",
528            f"/admin/competitions/{competition_id}/pinned-challenges",
529            json={"challenge_ids": challenge_ids},
530        )
531        _raise_for_status(resp)
532        return CompetitionPinnedChallenges.model_validate(resp.json())
533
534    def preview_challenges(self, competition_id: str) -> CompetitionPreviewChallenges:
535        """An invitation-only suite's free sample challenges (#2306), in the
536        order its page shows them."""
537        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/preview-challenges")
538        _raise_for_status(resp)
539        return CompetitionPreviewChallenges.model_validate(resp.json())
540
541    def set_preview_challenges(
542        self, competition_id: str, challenge_ids: builtins.list[str]
543    ) -> CompetitionPreviewChallenges:
544        """Name an invitation-only suite's free sample challenges, replacing
545        the list (``[]`` offers none). Every id must be in the suite's set
546        (``422 unknown_preview_challenge``). Samples are open to everyone and
547        rank on no board of the suite."""
548        resp = self._http.request(
549            "PUT",
550            f"/admin/competitions/{competition_id}/preview-challenges",
551            json={"challenge_ids": challenge_ids},
552        )
553        _raise_for_status(resp)
554        return CompetitionPreviewChallenges.model_validate(resp.json())
555
556    def clone(
557        self, competition_id: str, *, as_test: bool = True, starts_at: datetime | None = None
558    ) -> CompetitionInfo:
559        """Clone a competition as its **test** (#2041) and return the test.
560
561        Every rule is copied, rules files included (their bytes); only the
562        allow-list differs — ``private_hidden``, the " · 测试" title suffix,
563        ``test_of`` naming the source, and every window cleared or, with
564        ``starts_at``, moved by one offset so the sessions keep their shape.
565        No team, registration, solve, submission or instance is copied.
566        Refused (422) for a replay (``clone_of_virtual``), for a test
567        (``clone_of_test``), and for ``starts_at`` on a source with no start
568        (``clone_shift_needs_start``).
569        """
570        resp = self._http.request(
571            "POST",
572            f"/admin/competitions/{competition_id}/clone",
573            json={
574                "as_test": as_test,
575                "starts_at": starts_at.isoformat() if starts_at else None,
576            },
577        )
578        _raise_for_status(resp)
579        return CompetitionInfo.model_validate(resp.json())
580
581    def unfreeze(self, competition_id: str) -> CompetitionInfo:
582        """公布最终结果: publish a frozen event's final standings (#2133).
583
584        Ends the hold on the players' boards, mails the entrants their final
585        result and nudges every open board. Idempotent. ``409
586        board_not_frozen`` when the event has no freeze; ``409
587        event_not_over`` before ``ends_at`` (to lift a freeze during play,
588        ``update(id, freeze_minutes=0)``).
589        """
590        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/unfreeze")
591        _raise_for_status(resp)
592        return CompetitionInfo.model_validate(resp.json())
593
594    def drift(self, competition_id: str) -> builtins.list[CompetitionDriftInfo]:
595        """How a test differs from its official competition, field by field (#2041).
596
597        One entry for a test; one per test of it for an official competition;
598        none for a competition with no test. ``warnings`` counts the rules the
599        two do not share, ``intentional`` the allow-listed differences.
600        """
601        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/drift")
602        _raise_for_status(resp)
603        return [CompetitionDriftInfo.model_validate(d) for d in resp.json()]
604
605    def sync_rules(self, competition_id: str) -> CompetitionDriftInfo:
606        """Apply the clone rules to a test competition again, in place (#2041),
607        and return the drift that is left."""
608        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/sync-rules")
609        _raise_for_status(resp)
610        return CompetitionDriftInfo.model_validate(resp.json())
611
612    def challenge_changes(
613        self, competition_id: str
614    ) -> builtins.list[CompetitionChallengeChangeInfo]:
615        """Every challenge added to or taken out of a competition while it
616        locked its challenges in play (#2041), oldest first, with the reason."""
617        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-changes")
618        _raise_for_status(resp)
619        return [CompetitionChallengeChangeInfo.model_validate(r) for r in resp.json()]
620
621    def extend_session(self, competition_id: str, minutes: int) -> CompetitionInfo:
622        """Add ``minutes`` to the session running right now (#2128).
623
624        Refused (409) when no session is running (``session_not_running``)
625        or when the new end would run into the next session
626        (``session_extend_overlap``, carrying ``next_session_starts_at``).
627        Every registered team is told over SSE and by mail, and the
628        teardown job reads the new end on its next tick.
629        """
630        resp = self._http.request(
631            "POST",
632            f"/admin/competitions/{competition_id}/sessions/extend",
633            json={"minutes": minutes},
634        )
635        _raise_for_status(resp)
636        return CompetitionInfo.model_validate(resp.json())
637
638    def end_session(self, competition_id: str, *, confirm: bool = False) -> CompetitionInfo:
639        """Close the session running right now, at this instant.
640
641        ``confirm=True`` is required: every environment is stopped within
642        one teardown tick and there is no way back.
643        """
644        resp = self._http.request(
645            "POST",
646            f"/admin/competitions/{competition_id}/sessions/end",
647            json={"confirm": confirm},
648        )
649        _raise_for_status(resp)
650        return CompetitionInfo.model_validate(resp.json())
651
652    def delete(self, competition_id: str) -> None:
653        """Hard-delete a competition. Cascades against per-comp teams,
654        memberships, invites, solve / submission rows."""
655        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}")
656        _raise_for_status(resp)
657
658    def registrations(
659        self,
660        competition_id: str,
661        offset: int = 0,
662        limit: int = 50,
663    ) -> builtins.list[CompetitionRegistrationInfo]:
664        """Roster for ``competition_id``. Per the per-comp refactor, a
665        team's existence with the comp scope IS the registration."""
666        resp = self._http.request(
667            "GET",
668            f"/admin/competitions/{competition_id}/registrations",
669            params={"offset": offset, "limit": limit},
670        )
671        _raise_for_status(resp)
672        return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]
class AdminCompetitionsResource:
 54class AdminCompetitionsResource:
 55    """Create / update / delete competitions and inspect their roster."""
 56
 57    def __init__(self, http: BaseHttpClient) -> None:
 58        self._http = http
 59
 60    def formats(self) -> builtins.list[CompetitionFormatPreset]:
 61        """The wizard's named starting points for a new competition.
 62
 63        Each entry is a set of axis values, not a type: apply one to
 64        ``create(...)`` and then override whatever the event needs. The
 65        platform stores no record of which one was used.
 66        """
 67        resp = self._http.request("GET", "/admin/competition-formats")
 68        _raise_for_status(resp)
 69        return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]
 70
 71    def internet(self, competition_id: str) -> CompetitionInternetInfo:
 72        """Every challenge here: the author's internet declaration and its verdict (#1969).
 73
 74        ``rows`` sorts the conflicts first — a challenge whose author says
 75        it needs the internet and that this competition denies it — and
 76        ``conflicts`` counts them. Change the policy or a per-challenge
 77        override with :meth:`update` (``internet_policy`` /
 78        ``challenge_internet``, the latter replaced whole).
 79        """
 80        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/internet")
 81        _raise_for_status(resp)
 82        return CompetitionInternetInfo.model_validate(resp.json())
 83
 84    def apply_internet(self, competition_id: str, *, reason: str) -> CompetitionInternetApplied:
 85        """Apply today's internet policy to the boxes already running (#2067).
 86
 87        The policy is read at start, so a change leaves running boxes as they
 88        were (:meth:`internet`'s ``running_differs``). This moves each of them
 89        to the verdict the policy gives today, through its node, and audits
 90        each move with *reason*. Every box tried is in ``results`` with its
 91        outcome; a failure keeps the box as it was.
 92        """
 93        resp = self._http.request(
 94            "POST",
 95            f"/admin/competitions/{competition_id}/internet/apply",
 96            json=CompetitionInternetApplyRequest(reason=reason).model_dump(mode="json"),
 97        )
 98        _raise_for_status(resp)
 99        return CompetitionInternetApplied.model_validate(resp.json())
100
101    def tcp_access(self, competition_id: str) -> CompetitionTcpAccessInfo:
102        """How players reach this competition's TCP challenges (#2082).
103
104        The default (``tls_sni`` / ``public_port`` / ``vpn``), every TCP
105        challenge's own way in, and how many live nodes can publish a port.
106        Change either with :meth:`update` (``tcp_access`` /
107        ``challenge_tcp_access``, the latter replaced whole).
108        """
109        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/tcp-access")
110        _raise_for_status(resp)
111        return CompetitionTcpAccessInfo.model_validate(resp.json())
112
113    def pool(self, competition_id: str) -> CompetitionPoolInfo:
114        """Where this event's work runs: its dedicated node pool (#1965 E).
115
116        ``state`` is ``shared`` (no pool declared), ``ready`` or
117        ``no_live_node`` — the last meaning every start is refused and every
118        smoke waits, since nothing falls back to the shared pools. ``nodes``
119        is every machine labelled ``competition=<id>``, live or not. Switch
120        the declaration with :meth:`update` (``dedicated_pool``).
121        """
122        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pool")
123        _raise_for_status(resp)
124        return CompetitionPoolInfo.model_validate(resp.json())
125
126    def authoring_checklist(self, competition_id: str) -> AuthoringChecklistInfo:
127        """What must be right before this event opens automatic review (#1965 C3).
128
129        ``rows`` — the dedicated pool, the authoring windows, the contributor
130        agreement, the smoke quota, the internet policy — each ``ok`` or not,
131        with a ``reason`` code and the setting's ``value``; ``ready`` is all of
132        them. Advice: nothing refuses on it. Fix a row with :meth:`update`.
133        """
134        resp = self._http.request(
135            "GET", f"/admin/competitions/{competition_id}/authoring-checklist"
136        )
137        _raise_for_status(resp)
138        return AuthoringChecklistInfo.model_validate(resp.json())
139
140    def smoke_ledger(self, competition_id: str) -> AuthoringSmokeLedgerInfo:
141        """Every automatic smoke run charged in this competition, oldest first
142        (#1979 E3).
143
144        Each row says whose allowance it counted against (``purpose``:
145        ``quota`` a team's window, ``retry`` nobody's, ``budget`` a person's
146        day) and whether the platform refunded it (``refunded_at``, with the
147        reason) because the run reached no verdict. The whole set, unpaged.
148        """
149        resp = self._http.request(
150            "GET", f"/admin/competitions/{competition_id}/authoring/smoke-runs"
151        )
152        _raise_for_status(resp)
153        return AuthoringSmokeLedgerInfo.model_validate(resp.json())
154
155    def scoring_changes(self, competition_id: str) -> builtins.list[AdminScoringChangeInfo]:
156        """Every recorded change to this competition's scoring made while it
157        was live (#1965 H), oldest first — with who made it and the board
158        each one replaced. The public copy is
159        ``client.competitions.scoring_changes``."""
160        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-changes")
161        _raise_for_status(resp)
162        return AdminScoringChangesResponse.model_validate(resp.json()).changes
163
164    def scoring_manifest(self, competition_id: str) -> ScoringManifestInfo:
165        """The frozen scoring manifest (#2731 B): per frozen challenge, the
166        price inputs it was frozen with beside what the catalog says now, and
167        every re-price so far. ``frozen`` is ``False`` before the start."""
168        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-manifest")
169        _raise_for_status(resp)
170        return ScoringManifestInfo.model_validate(resp.json())
171
172    def reprice_challenge(
173        self, competition_id: str, challenge_id: str, *, reason: str
174    ) -> ScoringManifestInfo:
175        """Re-price one frozen challenge from the catalog, with a recorded
176        reason (#2731 B) — the one door that moves a frozen price. Returns the
177        manifest as it now stands."""
178        resp = self._http.request(
179            "POST",
180            f"/admin/competitions/{competition_id}/scoring-manifest/{challenge_id}/reprice",
181            json=RepriceRequest(reason=reason).model_dump(mode="json"),
182        )
183        _raise_for_status(resp)
184        return ScoringManifestInfo.model_validate(resp.json())
185
186    def scoring_preview(
187        self,
188        scoring_rule: ScoringRuleName | Literal[""],
189        scoring_params: dict[str, Any],
190        challenges: builtins.list[ScoringPreviewChallenge],
191        *,
192        reference_id: str = "",
193        max_solvers: int = 100,
194        table_solvers: int = 10,
195        compare_curves: builtins.list[str] | None = None,
196    ) -> ScoringPreview:
197        """Price a **draft** scoring config with the board's own arithmetic.
198
199        What the scoring form's live curve calls: per-challenge values, the
200        value curve for ``reference_id`` (and any ``compare_curves``) and the
201        first-blood ladder. Nothing is stored, and a draft the board could
202        not run is refused as the write path refuses it.
203        """
204        body = ScoringPreviewRequest(
205            scoring_rule=scoring_rule,
206            scoring_params=scoring_params,
207            challenges=challenges,
208            reference_id=reference_id,
209            max_solvers=max_solvers,
210            table_solvers=table_solvers,
211            compare_curves=compare_curves or [],
212        )
213        resp = self._http.request("POST", "/admin/scoring/preview", json=body.model_dump())
214        _raise_for_status(resp)
215        return ScoringPreview.model_validate(resp.json())
216
217    def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo:
218        """What this board's ``authoring_weight`` is worth, in challenges.
219
220        A ``peer_authoring`` weight is a fixed multiplier on a curve
221        output, so on its own it says nothing: 200 is generous on a board
222        of 100-point flags and a rounding error on one of 5000. This
223        prices it against the competition's own challenge set.
224
225        Read-only, and computed from the **stored** rule and challenge
226        set — a weight you have not saved is not what this answers about.
227        """
228        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale")
229        _raise_for_status(resp)
230        return AuthoringScaleInfo.model_validate(resp.json())
231
232    def records(self, competition_id: str) -> CompetitionRecords:
233        """Every record of one competition, for scoring outside the platform.
234
235        Teams (with each agent's owner and the owner's team), solves as the
236        board ranks them, submissions without their text, agent runs, and
237        uploads with their authors — complete, never a page.
238        """
239        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records")
240        _raise_for_status(resp)
241        return CompetitionRecords.model_validate(resp.json())
242
243    def list(
244        self, offset: int = 0, limit: int = 50, kind: str = ""
245    ) -> builtins.list[CompetitionInfo]:
246        """Every competition — past, current, scheduled. ``kind`` ∈ ``"" |
247        "event" | "suite"`` narrows it ("" = both)."""
248        params: dict[str, Any] = {"offset": offset, "limit": limit}
249        if kind:
250            params["kind"] = kind
251        resp = self._http.request("GET", "/admin/competitions", params=params)
252        _raise_for_status(resp)
253        return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]
254
255    def create(
256        self,
257        *,
258        title: str | LocalizedText,
259        description: str | LocalizedText = "",
260        handbook: str | LocalizedText = "",
261        starts_at: datetime | None = None,
262        ends_at: datetime | None = None,
263        challenge_ids: builtins.list[str] | None = None,
264        challenge_scope: ChallengeScope = "set",
265        status: str = "draft",
266        access: str = "public",
267        kind: str = "event",
268        eval_standard: bool = False,
269        corpus_sha: str = "",
270        scoring_rule: str = "",
271        scoring_params: dict[str, Any] | None = None,
272        scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION,
273        sessions: builtins.list[tuple[datetime, datetime]] | None = None,
274        authoring_windows: builtins.list[tuple[datetime | None, datetime | None]] | None = None,
275        awd_match: AwdMatchConfig | None = None,
276        entrants_are_agents: bool = False,
277        internet_policy: InternetPolicy = InternetPolicy.ALLOW,
278        challenge_internet: dict[str, InternetAccess] | None = None,
279        tcp_access: TcpAccess = TcpAccess.TLS_SNI,
280        challenge_tcp_access: dict[str, TcpAccess] | None = None,
281        session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP,
282        session_end_grace_s: int = 900,
283        solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE,
284        solve_grace_s: int = 900,
285        freeze_minutes: int = 0,
286        freeze_auto_unfreeze: bool = False,
287    ) -> CompetitionInfo:
288        """Create a new competition. ``title`` is a bare string — filed under
289        ``zh-CN`` when it holds a CJK ideograph, else ``en`` — or a
290        ``{locale: text}`` map naming both (#2230). ``None`` time fields = no bound
291        (sent as JSON ``null``). Unknown ``challenge_ids`` → 422.
292        ``challenge_scope`` is what entrants may play (#2553): ``"set"`` (the
293        default) — ``challenge_ids`` and nothing else, so an empty set starts
294        nothing — or ``"catalog"``, where any catalog challenge may start.
295        ``status`` defaults to ``draft`` (hidden from players); pass
296        ``"published"`` to make it immediately visible. ``access``
297        defaults to ``public``; pass ``"private_listed"`` /
298        ``"private_hidden"`` for an invite-only competition. ``kind`` is
299        ``"event"`` (a hosted contest, the default) or ``"suite"`` (a public
300        benchmark set open at any time: no window, no registration, listed on
301        the 测评集 page instead of the contest list; #2097).
302        ``eval_standard=True`` marks it for the platform's hosted evaluation
303        (only such a competition may back an official EvalCampaign);
304        ``corpus_sha`` pins the corpus snapshot it was certified against.
305        ``scoring_rule`` selects the board's ranking algorithm
306        (``""`` = flag count, ``static_points``, ``dynamic_points``) with
307        ``scoring_params`` carrying its tuning; set it here where you can,
308        because scoring freezes once the competition goes live.
309        ``scoring_rule_version`` is what a multi-flag challenge is worth
310        (#2731): ``challenge_total`` (the default) shares one total among
311        its scoring questions by weight; ``legacy`` pays every flag the
312        full value. It can be changed with :meth:`update` only before the
313        competition goes live (``409 scoring_rule_version_locked``).
314        ``sessions`` declares the playable slices of the window as
315        ``(start, end)`` pairs — a three-day event running 08:00-16:00
316        daily is three of them. Supplying it also *sets* ``starts_at`` /
317        ``ends_at``, which the server derives from the envelope, and
318        opts the competition into the play gate: outside a session,
319        submissions and instance starts are refused and running
320        instances are torn down. Omit it for a single continuous
321        window, which is how every competition behaves by default.
322        ``authoring_windows`` is the same idea for uploads: ``(start,
323        end)`` pairs, either side ``None`` for unbounded, outside which an
324        authored-challenge upload is refused — four mornings is four
325        pairs. Omit it to take uploads for as long as the event runs.
326        ``handbook`` is the organiser's long-form rules page, markdown,
327        capped server-side — ``description`` stays the one-line blurb the
328        listing and the page header truncate. Both are localised: pass a
329        ``{locale: text}`` map to write one translation, or a bare string,
330        which the server reads as ``en``. ⚠️ **A bare string is kept in the
331        signature rather than deprecated** — every caller that predates
332        localisation sends one, and a 422 for all of them would be a
333        breaking change dressed up as a feature; the coercion is the
334        server's (``coerce_localized``), so the two cannot disagree.
335        ``entrants_are_agents=True`` marks it an agent exhibition: human
336        registration is refused server-side and the browser shows a
337        spectate affordance rather than a sign-up button.
338        ``internet_policy`` decides whether instances started here may
339        reach the internet — ``allow`` (the default, today's behaviour),
340        ``deny``, or ``per_challenge`` (each challenge as its author
341        declared) — and ``challenge_internet`` overrides it per challenge
342        id (#1969). ``tcp_access`` is how players reach a TCP challenge —
343        ``tls_sni`` (the default), ``public_port`` or ``vpn`` — and
344        ``challenge_tcp_access`` overrides it per challenge id (#2082).
345        ``session_end_mode`` decides what a play session's end does
346        to running boxes -- ``pause`` / ``stop_keep`` (default) / ``grace``
347        (``session_end_grace_s``) / ``delete`` -- and the last session always
348        deletes (#2128). ``solve_env_mode`` decides what a full solve does to
349        the solved box -- ``destroy`` / ``grace`` (default,
350        ``solve_grace_s``) / ``keep_until_ttl`` (#2105). ``freeze_minutes`` holds the
351        players' scoreboard at ``ends_at - freeze_minutes`` until
352        :meth:`unfreeze` (or, with ``freeze_auto_unfreeze``, until the event
353        ends); ``0`` never freezes (#2133)."""
354        resp = self._http.request(
355            "POST",
356            "/admin/competitions",
357            json={
358                **({"title_i18n": title} if isinstance(title, dict) else {"title": title}),
359                "description": description,
360                "handbook": handbook,
361                "starts_at": starts_at.isoformat() if starts_at else None,
362                "ends_at": ends_at.isoformat() if ends_at else None,
363                "challenge_ids": challenge_ids or [],
364                "challenge_scope": challenge_scope,
365                "status": status,
366                "access": access,
367                "kind": kind,
368                "eval_standard": eval_standard,
369                "corpus_sha": corpus_sha,
370                "scoring_rule": scoring_rule,
371                "scoring_params": scoring_params or {},
372                "scoring_rule_version": scoring_rule_version,
373                # An agent exhibition: entrants are dispatched agents, so
374                # human registration is refused and the browser offers a
375                # spectate affordance. See ``CompetitionCreate``.
376                "entrants_are_agents": entrants_are_agents,
377                # #1969: the internet axis and its per-challenge overrides.
378                "internet_policy": str(internet_policy),
379                "challenge_internet": {k: str(v) for k, v in (challenge_internet or {}).items()},
380                # #2082: the way in for TCP challenges, and its overrides.
381                "tcp_access": str(tcp_access),
382                "challenge_tcp_access": {
383                    k: str(v) for k, v in (challenge_tcp_access or {}).items()
384                },
385                "session_end_mode": str(session_end_mode),
386                "session_end_grace_s": session_end_grace_s,
387                "solve_env_mode": str(solve_env_mode),
388                "solve_grace_s": solve_grace_s,
389                "freeze_minutes": freeze_minutes,
390                "freeze_auto_unfreeze": freeze_auto_unfreeze,
391                "sessions": [
392                    {"starts_at": s.isoformat(), "ends_at": e.isoformat()}
393                    for s, e in (sessions or [])
394                ],
395                "authoring_windows": [
396                    {
397                        "starts_at": s.isoformat() if s else None,
398                        "ends_at": e.isoformat() if e else None,
399                    }
400                    for s, e in (authoring_windows or [])
401                ],
402                # ⚠️ **Absent, never `{}`.** The server's model is
403                # `extra="forbid"` with two required cadence fields, so
404                # an empty object is a 422 on every competition that
405                # runs no rounds. `None` here means "not configured in
406                # this request", which the upsert route can still
407                # satisfy tomorrow — never "this competition has no
408                # match".
409                **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}),
410            },
411        )
412        _raise_for_status(resp)
413        return CompetitionInfo.model_validate(resp.json())
414
415    def update(self, competition_id: str, **fields: Any) -> CompetitionInfo:
416        """PATCH any subset of ``title_i18n`` (the whole ``{locale: text}``
417        title map, replaced; #2230) / ``title`` (a bare string, merged into the
418        locale it is written in) / ``description`` / ``starts_at`` /
419        ``ends_at`` / ``challenge_ids`` / ``challenge_scope`` (``set`` |
420        ``catalog``, #2553 — a change to what may be played, so a locked event
421        takes it only like a change to the set) / ``status`` / ``access`` /
422        ``kind`` / ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` /
423        ``scoring_params`` / ``scoring_rule_version`` (before the start only) /
424        ``sessions`` / ``anonymous_entry`` /
425        ``anonymous_entry_kind`` / ``authoring_review`` (``human`` |
426        ``automatic``: under ``automatic`` a passing smoke on the event's
427        dedicated pool approves, publishes and curates an upload) /
428        ``authoring_smoke_quota`` (smoke runs per team per authoring window
429        under ``automatic``) / ``authoring_rate_limit_count`` +
430        ``authoring_rate_limit_window_s`` (uploads one team may make per
431        rolling window, #2079; ``0`` is off) / ``authoring_uploads_per_window``
432        (new uploads one team may start per authoring window, a re-upload of its
433        own upload never counted; ``0`` is **unlimited**) /
434        ``pinned`` (pin / unpin the event for everybody, #2070 — **platform
435        admins only**, ``403 pin_requires_platform_admin`` for an organiser) /
436        ``organiser_contact_email`` (where entrants reach the organisers, shown
437        on the competition page and in the registration mail, #2283; ``""``
438        removes it) /
439        ``authoring_overrides`` (#2885: a ``{setting: int}`` object of the
440        platform's own authoring setting names, e.g.
441        ``{"authored_challenge_memory_mb": 8192}``; only the keys sent move,
442        ``0`` defers to the platform; the runtime budget, build timeout,
443        auto-smoke budget and re-check interval are **platform admins only**,
444        ``403 authoring_override_requires_platform_admin``) /
445        ``authoring_cooldown`` (``default`` |
446        ``platform`` | ``competition`` | ``off``: the pause after repeated
447        rejections, with ``authoring_cooldown_threshold`` / ``_window_s`` /
448        ``_pause_s`` read under ``competition``) / ``report_disclosure``
449        (``default`` | ``after_lock`` | ``own`` | ``own_and_board``: what
450        players are shown about how they are doing, #2030) / ``freeze_minutes`` +
451        ``freeze_auto_unfreeze`` (hold the players' scoreboard for the last N
452        minutes, #2133; ``0`` is off). ⚠️ This is a **passthrough** — the list
453        is what the wire accepts today, so it is a pointer to
454        ``CompetitionUpdate`` rather than a gate, and :meth:`create` is
455        the narrow one (it builds its body by hand and cannot carry a
456        field it does not name). ⚠️ ``anonymous_entry`` may be *widened*
457        freely and is refused on a narrowing once entrants have relied
458        on it, ``required`` → ``optional`` included: that transition
459        un-anonymises the whole field at once. Omit a field to leave it; pass
460        ``None`` to clear. ``sessions`` is the one field where ``[]`` is
461        meaningful rather than empty: it clears the schedule and returns
462        the competition to a single continuous window; ``authoring_windows``
463        is the other, and ``[]`` there takes uploads for as long as the
464        event runs. Changing the scoring fields on a live competition needs
465        ``scoring_change_reason`` (``422 scoring_change_needs_reason``
466        without one): the change is recorded and shown on the board — see
467        ``competitions.scoring_live``. ``lock_challenge_versions`` holds an
468        event's uploaded challenges still while it is in play (#2041); a
469        change to ``challenge_ids`` of such an event then needs
470        ``challenge_change_confirmed=True`` and a ``challenge_change_reason``
471        (``409 challenge_set_locked`` otherwise), recorded per challenge —
472        see :meth:`challenge_changes`."""
473        resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields)
474        _raise_for_status(resp)
475        return CompetitionInfo.model_validate(resp.json())
476
477    def challenge_source(self, competition_id: str) -> CompetitionChallengeSource:
478        """Where a competition's challenge set comes from (#2234): the
479        competition it follows, whether that source passes its set on now
480        (only while it is published), and the competitions that follow it."""
481        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-source")
482        _raise_for_status(resp)
483        return CompetitionChallengeSource.model_validate(resp.json())
484
485    def set_challenge_source(self, competition_id: str, source_id: str) -> CompetitionInfo:
486        """Make a competition's challenge set follow ``source_id``'s, or stop
487        following with ``""`` (#2234).
488
489        The follower then serves the source's set, read live, minus player
490        uploads; its own list is cleared and not editable while it follows
491        (``409 challenge_set_followed``). Stopping keeps the set it serves at
492        that moment as its own list. One level only: refused with
493        ``challenge_source_chained`` / ``challenge_source_followed``; the
494        caller must organise the source (``403 challenge_source_forbidden``).
495        Solves, instances and boards stay each competition's own."""
496        return self.update(competition_id, challenge_source_competition_id=source_id)
497
498    def reorder_challenges(
499        self, competition_id: str, challenge_ids: builtins.list[str]
500    ) -> CompetitionChallengeOrder:
501        """Put a competition's challenges in the order its entrants see them
502        (#2037). ``challenge_ids`` must be exactly the set it holds now,
503        reordered — ``competition_challenge_order_stale`` when another
504        organiser changed it meanwhile."""
505        resp = self._http.request(
506            "PUT",
507            f"/admin/competitions/{competition_id}/challenge-order",
508            json={"challenge_ids": challenge_ids},
509        )
510        _raise_for_status(resp)
511        return CompetitionChallengeOrder.model_validate(resp.json())
512
513    def pinned_challenges(self, competition_id: str) -> CompetitionPinnedChallenges:
514        """The challenges pinned to the head of a competition's list (#2070),
515        in the order they are shown."""
516        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pinned-challenges")
517        _raise_for_status(resp)
518        return CompetitionPinnedChallenges.model_validate(resp.json())
519
520    def set_pinned_challenges(
521        self, competition_id: str, challenge_ids: builtins.list[str]
522    ) -> CompetitionPinnedChallenges:
523        """Pin challenges to the head of a competition's list, in the order
524        given; the whole list is replaced (``[]`` clears it). Every id must be
525        one the competition serves now (``422 unknown_pinned_challenge``). A
526        pin is ordering and a marker, never visibility."""
527        resp = self._http.request(
528            "PUT",
529            f"/admin/competitions/{competition_id}/pinned-challenges",
530            json={"challenge_ids": challenge_ids},
531        )
532        _raise_for_status(resp)
533        return CompetitionPinnedChallenges.model_validate(resp.json())
534
535    def preview_challenges(self, competition_id: str) -> CompetitionPreviewChallenges:
536        """An invitation-only suite's free sample challenges (#2306), in the
537        order its page shows them."""
538        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/preview-challenges")
539        _raise_for_status(resp)
540        return CompetitionPreviewChallenges.model_validate(resp.json())
541
542    def set_preview_challenges(
543        self, competition_id: str, challenge_ids: builtins.list[str]
544    ) -> CompetitionPreviewChallenges:
545        """Name an invitation-only suite's free sample challenges, replacing
546        the list (``[]`` offers none). Every id must be in the suite's set
547        (``422 unknown_preview_challenge``). Samples are open to everyone and
548        rank on no board of the suite."""
549        resp = self._http.request(
550            "PUT",
551            f"/admin/competitions/{competition_id}/preview-challenges",
552            json={"challenge_ids": challenge_ids},
553        )
554        _raise_for_status(resp)
555        return CompetitionPreviewChallenges.model_validate(resp.json())
556
557    def clone(
558        self, competition_id: str, *, as_test: bool = True, starts_at: datetime | None = None
559    ) -> CompetitionInfo:
560        """Clone a competition as its **test** (#2041) and return the test.
561
562        Every rule is copied, rules files included (their bytes); only the
563        allow-list differs — ``private_hidden``, the " · 测试" title suffix,
564        ``test_of`` naming the source, and every window cleared or, with
565        ``starts_at``, moved by one offset so the sessions keep their shape.
566        No team, registration, solve, submission or instance is copied.
567        Refused (422) for a replay (``clone_of_virtual``), for a test
568        (``clone_of_test``), and for ``starts_at`` on a source with no start
569        (``clone_shift_needs_start``).
570        """
571        resp = self._http.request(
572            "POST",
573            f"/admin/competitions/{competition_id}/clone",
574            json={
575                "as_test": as_test,
576                "starts_at": starts_at.isoformat() if starts_at else None,
577            },
578        )
579        _raise_for_status(resp)
580        return CompetitionInfo.model_validate(resp.json())
581
582    def unfreeze(self, competition_id: str) -> CompetitionInfo:
583        """公布最终结果: publish a frozen event's final standings (#2133).
584
585        Ends the hold on the players' boards, mails the entrants their final
586        result and nudges every open board. Idempotent. ``409
587        board_not_frozen`` when the event has no freeze; ``409
588        event_not_over`` before ``ends_at`` (to lift a freeze during play,
589        ``update(id, freeze_minutes=0)``).
590        """
591        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/unfreeze")
592        _raise_for_status(resp)
593        return CompetitionInfo.model_validate(resp.json())
594
595    def drift(self, competition_id: str) -> builtins.list[CompetitionDriftInfo]:
596        """How a test differs from its official competition, field by field (#2041).
597
598        One entry for a test; one per test of it for an official competition;
599        none for a competition with no test. ``warnings`` counts the rules the
600        two do not share, ``intentional`` the allow-listed differences.
601        """
602        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/drift")
603        _raise_for_status(resp)
604        return [CompetitionDriftInfo.model_validate(d) for d in resp.json()]
605
606    def sync_rules(self, competition_id: str) -> CompetitionDriftInfo:
607        """Apply the clone rules to a test competition again, in place (#2041),
608        and return the drift that is left."""
609        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/sync-rules")
610        _raise_for_status(resp)
611        return CompetitionDriftInfo.model_validate(resp.json())
612
613    def challenge_changes(
614        self, competition_id: str
615    ) -> builtins.list[CompetitionChallengeChangeInfo]:
616        """Every challenge added to or taken out of a competition while it
617        locked its challenges in play (#2041), oldest first, with the reason."""
618        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-changes")
619        _raise_for_status(resp)
620        return [CompetitionChallengeChangeInfo.model_validate(r) for r in resp.json()]
621
622    def extend_session(self, competition_id: str, minutes: int) -> CompetitionInfo:
623        """Add ``minutes`` to the session running right now (#2128).
624
625        Refused (409) when no session is running (``session_not_running``)
626        or when the new end would run into the next session
627        (``session_extend_overlap``, carrying ``next_session_starts_at``).
628        Every registered team is told over SSE and by mail, and the
629        teardown job reads the new end on its next tick.
630        """
631        resp = self._http.request(
632            "POST",
633            f"/admin/competitions/{competition_id}/sessions/extend",
634            json={"minutes": minutes},
635        )
636        _raise_for_status(resp)
637        return CompetitionInfo.model_validate(resp.json())
638
639    def end_session(self, competition_id: str, *, confirm: bool = False) -> CompetitionInfo:
640        """Close the session running right now, at this instant.
641
642        ``confirm=True`` is required: every environment is stopped within
643        one teardown tick and there is no way back.
644        """
645        resp = self._http.request(
646            "POST",
647            f"/admin/competitions/{competition_id}/sessions/end",
648            json={"confirm": confirm},
649        )
650        _raise_for_status(resp)
651        return CompetitionInfo.model_validate(resp.json())
652
653    def delete(self, competition_id: str) -> None:
654        """Hard-delete a competition. Cascades against per-comp teams,
655        memberships, invites, solve / submission rows."""
656        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}")
657        _raise_for_status(resp)
658
659    def registrations(
660        self,
661        competition_id: str,
662        offset: int = 0,
663        limit: int = 50,
664    ) -> builtins.list[CompetitionRegistrationInfo]:
665        """Roster for ``competition_id``. Per the per-comp refactor, a
666        team's existence with the comp scope IS the registration."""
667        resp = self._http.request(
668            "GET",
669            f"/admin/competitions/{competition_id}/registrations",
670            params={"offset": offset, "limit": limit},
671        )
672        _raise_for_status(resp)
673        return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]

Create / update / delete competitions and inspect their roster.

AdminCompetitionsResource(http: ctfy.sdk.base.BaseHttpClient)
57    def __init__(self, http: BaseHttpClient) -> None:
58        self._http = http
def formats(self) -> list[ctfy.server.models.CompetitionFormatPreset]:
60    def formats(self) -> builtins.list[CompetitionFormatPreset]:
61        """The wizard's named starting points for a new competition.
62
63        Each entry is a set of axis values, not a type: apply one to
64        ``create(...)`` and then override whatever the event needs. The
65        platform stores no record of which one was used.
66        """
67        resp = self._http.request("GET", "/admin/competition-formats")
68        _raise_for_status(resp)
69        return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]

The wizard's named starting points for a new competition.

Each entry is a set of axis values, not a type: apply one to create(...) and then override whatever the event needs. The platform stores no record of which one was used.

def internet( self, competition_id: str) -> ctfy.server.models.CompetitionInternetInfo:
71    def internet(self, competition_id: str) -> CompetitionInternetInfo:
72        """Every challenge here: the author's internet declaration and its verdict (#1969).
73
74        ``rows`` sorts the conflicts first — a challenge whose author says
75        it needs the internet and that this competition denies it — and
76        ``conflicts`` counts them. Change the policy or a per-challenge
77        override with :meth:`update` (``internet_policy`` /
78        ``challenge_internet``, the latter replaced whole).
79        """
80        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/internet")
81        _raise_for_status(resp)
82        return CompetitionInternetInfo.model_validate(resp.json())

Every challenge here: the author's internet declaration and its verdict (#1969).

rows sorts the conflicts first — a challenge whose author says it needs the internet and that this competition denies it — and conflicts counts them. Change the policy or a per-challenge override with update() (internet_policy / challenge_internet, the latter replaced whole).

def apply_internet( self, competition_id: str, *, reason: str) -> ctfy.server.models.CompetitionInternetApplied:
84    def apply_internet(self, competition_id: str, *, reason: str) -> CompetitionInternetApplied:
85        """Apply today's internet policy to the boxes already running (#2067).
86
87        The policy is read at start, so a change leaves running boxes as they
88        were (:meth:`internet`'s ``running_differs``). This moves each of them
89        to the verdict the policy gives today, through its node, and audits
90        each move with *reason*. Every box tried is in ``results`` with its
91        outcome; a failure keeps the box as it was.
92        """
93        resp = self._http.request(
94            "POST",
95            f"/admin/competitions/{competition_id}/internet/apply",
96            json=CompetitionInternetApplyRequest(reason=reason).model_dump(mode="json"),
97        )
98        _raise_for_status(resp)
99        return CompetitionInternetApplied.model_validate(resp.json())

Apply today's internet policy to the boxes already running (#2067).

The policy is read at start, so a change leaves running boxes as they were (internet()'s running_differs). This moves each of them to the verdict the policy gives today, through its node, and audits each move with reason. Every box tried is in results with its outcome; a failure keeps the box as it was.

def tcp_access( self, competition_id: str) -> ctfy.server.models.CompetitionTcpAccessInfo:
101    def tcp_access(self, competition_id: str) -> CompetitionTcpAccessInfo:
102        """How players reach this competition's TCP challenges (#2082).
103
104        The default (``tls_sni`` / ``public_port`` / ``vpn``), every TCP
105        challenge's own way in, and how many live nodes can publish a port.
106        Change either with :meth:`update` (``tcp_access`` /
107        ``challenge_tcp_access``, the latter replaced whole).
108        """
109        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/tcp-access")
110        _raise_for_status(resp)
111        return CompetitionTcpAccessInfo.model_validate(resp.json())

How players reach this competition's TCP challenges (#2082).

The default (tls_sni / public_port / vpn), every TCP challenge's own way in, and how many live nodes can publish a port. Change either with update() (tcp_access / challenge_tcp_access, the latter replaced whole).

def pool( self, competition_id: str) -> ctfy.server.models.CompetitionPoolInfo:
113    def pool(self, competition_id: str) -> CompetitionPoolInfo:
114        """Where this event's work runs: its dedicated node pool (#1965 E).
115
116        ``state`` is ``shared`` (no pool declared), ``ready`` or
117        ``no_live_node`` — the last meaning every start is refused and every
118        smoke waits, since nothing falls back to the shared pools. ``nodes``
119        is every machine labelled ``competition=<id>``, live or not. Switch
120        the declaration with :meth:`update` (``dedicated_pool``).
121        """
122        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pool")
123        _raise_for_status(resp)
124        return CompetitionPoolInfo.model_validate(resp.json())

Where this event's work runs: its dedicated node pool (#1965 E).

state is shared (no pool declared), ready or no_live_node — the last meaning every start is refused and every smoke waits, since nothing falls back to the shared pools. nodes is every machine labelled competition=<id>, live or not. Switch the declaration with update() (dedicated_pool).

def authoring_checklist( self, competition_id: str) -> ctfy.server.models.AuthoringChecklistInfo:
126    def authoring_checklist(self, competition_id: str) -> AuthoringChecklistInfo:
127        """What must be right before this event opens automatic review (#1965 C3).
128
129        ``rows`` — the dedicated pool, the authoring windows, the contributor
130        agreement, the smoke quota, the internet policy — each ``ok`` or not,
131        with a ``reason`` code and the setting's ``value``; ``ready`` is all of
132        them. Advice: nothing refuses on it. Fix a row with :meth:`update`.
133        """
134        resp = self._http.request(
135            "GET", f"/admin/competitions/{competition_id}/authoring-checklist"
136        )
137        _raise_for_status(resp)
138        return AuthoringChecklistInfo.model_validate(resp.json())

What must be right before this event opens automatic review (#1965 C3).

rows — the dedicated pool, the authoring windows, the contributor agreement, the smoke quota, the internet policy — each ok or not, with a reason code and the setting's value; ready is all of them. Advice: nothing refuses on it. Fix a row with update().

def smoke_ledger( self, competition_id: str) -> ctfy.server.models.AuthoringSmokeLedgerInfo:
140    def smoke_ledger(self, competition_id: str) -> AuthoringSmokeLedgerInfo:
141        """Every automatic smoke run charged in this competition, oldest first
142        (#1979 E3).
143
144        Each row says whose allowance it counted against (``purpose``:
145        ``quota`` a team's window, ``retry`` nobody's, ``budget`` a person's
146        day) and whether the platform refunded it (``refunded_at``, with the
147        reason) because the run reached no verdict. The whole set, unpaged.
148        """
149        resp = self._http.request(
150            "GET", f"/admin/competitions/{competition_id}/authoring/smoke-runs"
151        )
152        _raise_for_status(resp)
153        return AuthoringSmokeLedgerInfo.model_validate(resp.json())

Every automatic smoke run charged in this competition, oldest first (#1979 E3).

Each row says whose allowance it counted against (purpose: quota a team's window, retry nobody's, budget a person's day) and whether the platform refunded it (refunded_at, with the reason) because the run reached no verdict. The whole set, unpaged.

def scoring_changes( self, competition_id: str) -> list[ctfy.server.models.AdminScoringChangeInfo]:
155    def scoring_changes(self, competition_id: str) -> builtins.list[AdminScoringChangeInfo]:
156        """Every recorded change to this competition's scoring made while it
157        was live (#1965 H), oldest first — with who made it and the board
158        each one replaced. The public copy is
159        ``client.competitions.scoring_changes``."""
160        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-changes")
161        _raise_for_status(resp)
162        return AdminScoringChangesResponse.model_validate(resp.json()).changes

Every recorded change to this competition's scoring made while it was live (#1965 H), oldest first — with who made it and the board each one replaced. The public copy is client.competitions.scoring_changes.

def scoring_manifest( self, competition_id: str) -> ctfy.server.models.ScoringManifestInfo:
164    def scoring_manifest(self, competition_id: str) -> ScoringManifestInfo:
165        """The frozen scoring manifest (#2731 B): per frozen challenge, the
166        price inputs it was frozen with beside what the catalog says now, and
167        every re-price so far. ``frozen`` is ``False`` before the start."""
168        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-manifest")
169        _raise_for_status(resp)
170        return ScoringManifestInfo.model_validate(resp.json())

The frozen scoring manifest (#2731 B): per frozen challenge, the price inputs it was frozen with beside what the catalog says now, and every re-price so far. frozen is False before the start.

def reprice_challenge( self, competition_id: str, challenge_id: str, *, reason: str) -> ctfy.server.models.ScoringManifestInfo:
172    def reprice_challenge(
173        self, competition_id: str, challenge_id: str, *, reason: str
174    ) -> ScoringManifestInfo:
175        """Re-price one frozen challenge from the catalog, with a recorded
176        reason (#2731 B) — the one door that moves a frozen price. Returns the
177        manifest as it now stands."""
178        resp = self._http.request(
179            "POST",
180            f"/admin/competitions/{competition_id}/scoring-manifest/{challenge_id}/reprice",
181            json=RepriceRequest(reason=reason).model_dump(mode="json"),
182        )
183        _raise_for_status(resp)
184        return ScoringManifestInfo.model_validate(resp.json())

Re-price one frozen challenge from the catalog, with a recorded reason (#2731 B) — the one door that moves a frozen price. Returns the manifest as it now stands.

def scoring_preview( self, scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'], Literal['']], scoring_params: dict[str, typing.Any], challenges: list[ctfy.server.models.ScoringPreviewChallenge], *, reference_id: str = '', max_solvers: int = 100, table_solvers: int = 10, compare_curves: list[str] | None = None) -> ctfy.server.models.ScoringPreview:
186    def scoring_preview(
187        self,
188        scoring_rule: ScoringRuleName | Literal[""],
189        scoring_params: dict[str, Any],
190        challenges: builtins.list[ScoringPreviewChallenge],
191        *,
192        reference_id: str = "",
193        max_solvers: int = 100,
194        table_solvers: int = 10,
195        compare_curves: builtins.list[str] | None = None,
196    ) -> ScoringPreview:
197        """Price a **draft** scoring config with the board's own arithmetic.
198
199        What the scoring form's live curve calls: per-challenge values, the
200        value curve for ``reference_id`` (and any ``compare_curves``) and the
201        first-blood ladder. Nothing is stored, and a draft the board could
202        not run is refused as the write path refuses it.
203        """
204        body = ScoringPreviewRequest(
205            scoring_rule=scoring_rule,
206            scoring_params=scoring_params,
207            challenges=challenges,
208            reference_id=reference_id,
209            max_solvers=max_solvers,
210            table_solvers=table_solvers,
211            compare_curves=compare_curves or [],
212        )
213        resp = self._http.request("POST", "/admin/scoring/preview", json=body.model_dump())
214        _raise_for_status(resp)
215        return ScoringPreview.model_validate(resp.json())

Price a draft scoring config with the board's own arithmetic.

What the scoring form's live curve calls: per-challenge values, the value curve for reference_id (and any compare_curves) and the first-blood ladder. Nothing is stored, and a draft the board could not run is refused as the write path refuses it.

def authoring_scale( self, competition_id: str) -> ctfy.server.models.AuthoringScaleInfo:
217    def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo:
218        """What this board's ``authoring_weight`` is worth, in challenges.
219
220        A ``peer_authoring`` weight is a fixed multiplier on a curve
221        output, so on its own it says nothing: 200 is generous on a board
222        of 100-point flags and a rounding error on one of 5000. This
223        prices it against the competition's own challenge set.
224
225        Read-only, and computed from the **stored** rule and challenge
226        set — a weight you have not saved is not what this answers about.
227        """
228        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale")
229        _raise_for_status(resp)
230        return AuthoringScaleInfo.model_validate(resp.json())

What this board's authoring_weight is worth, in challenges.

A peer_authoring weight is a fixed multiplier on a curve output, so on its own it says nothing: 200 is generous on a board of 100-point flags and a rounding error on one of 5000. This prices it against the competition's own challenge set.

Read-only, and computed from the stored rule and challenge set — a weight you have not saved is not what this answers about.

def records( self, competition_id: str) -> ctfy.server.models.CompetitionRecords:
232    def records(self, competition_id: str) -> CompetitionRecords:
233        """Every record of one competition, for scoring outside the platform.
234
235        Teams (with each agent's owner and the owner's team), solves as the
236        board ranks them, submissions without their text, agent runs, and
237        uploads with their authors — complete, never a page.
238        """
239        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records")
240        _raise_for_status(resp)
241        return CompetitionRecords.model_validate(resp.json())

Every record of one competition, for scoring outside the platform.

Teams (with each agent's owner and the owner's team), solves as the board ranks them, submissions without their text, agent runs, and uploads with their authors — complete, never a page.

def list( self, offset: int = 0, limit: int = 50, kind: str = '') -> list[ctfy.server.models.CompetitionInfo]:
243    def list(
244        self, offset: int = 0, limit: int = 50, kind: str = ""
245    ) -> builtins.list[CompetitionInfo]:
246        """Every competition — past, current, scheduled. ``kind`` ∈ ``"" |
247        "event" | "suite"`` narrows it ("" = both)."""
248        params: dict[str, Any] = {"offset": offset, "limit": limit}
249        if kind:
250            params["kind"] = kind
251        resp = self._http.request("GET", "/admin/competitions", params=params)
252        _raise_for_status(resp)
253        return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]

Every competition — past, current, scheduled. kind ∈ "" | "event" | "suite" narrows it ("" = both).

def create( self, *, title: str | dict[str, str], description: str | dict[str, str] = '', handbook: str | dict[str, str] = '', starts_at: datetime.datetime | None = None, ends_at: datetime.datetime | None = None, challenge_ids: list[str] | None = None, challenge_scope: Literal['set', 'catalog'] = 'set', status: str = 'draft', access: str = 'public', kind: str = 'event', eval_standard: bool = False, corpus_sha: str = '', scoring_rule: str = '', scoring_params: dict[str, typing.Any] | None = None, scoring_rule_version: Literal['legacy', 'challenge_total'] = 'challenge_total', sessions: list[tuple[datetime.datetime, datetime.datetime]] | None = None, authoring_windows: list[tuple[datetime.datetime | None, datetime.datetime | None]] | None = None, awd_match: ctfy.server.models.AwdMatchConfig | None = None, entrants_are_agents: bool = False, internet_policy: ctfy.core.enums.InternetPolicy = <InternetPolicy.ALLOW: 'allow'>, challenge_internet: dict[str, ctfy.core.enums.InternetAccess] | None = None, tcp_access: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>, challenge_tcp_access: dict[str, ctfy.core.enums.TcpAccess] | None = None, session_end_mode: ctfy.core.enums.SessionEndMode = <SessionEndMode.STOP_KEEP: 'stop_keep'>, session_end_grace_s: int = 900, solve_env_mode: ctfy.core.enums.SolveEnvMode = <SolveEnvMode.GRACE: 'grace'>, solve_grace_s: int = 900, freeze_minutes: int = 0, freeze_auto_unfreeze: bool = False) -> ctfy.server.models.CompetitionInfo:
255    def create(
256        self,
257        *,
258        title: str | LocalizedText,
259        description: str | LocalizedText = "",
260        handbook: str | LocalizedText = "",
261        starts_at: datetime | None = None,
262        ends_at: datetime | None = None,
263        challenge_ids: builtins.list[str] | None = None,
264        challenge_scope: ChallengeScope = "set",
265        status: str = "draft",
266        access: str = "public",
267        kind: str = "event",
268        eval_standard: bool = False,
269        corpus_sha: str = "",
270        scoring_rule: str = "",
271        scoring_params: dict[str, Any] | None = None,
272        scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION,
273        sessions: builtins.list[tuple[datetime, datetime]] | None = None,
274        authoring_windows: builtins.list[tuple[datetime | None, datetime | None]] | None = None,
275        awd_match: AwdMatchConfig | None = None,
276        entrants_are_agents: bool = False,
277        internet_policy: InternetPolicy = InternetPolicy.ALLOW,
278        challenge_internet: dict[str, InternetAccess] | None = None,
279        tcp_access: TcpAccess = TcpAccess.TLS_SNI,
280        challenge_tcp_access: dict[str, TcpAccess] | None = None,
281        session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP,
282        session_end_grace_s: int = 900,
283        solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE,
284        solve_grace_s: int = 900,
285        freeze_minutes: int = 0,
286        freeze_auto_unfreeze: bool = False,
287    ) -> CompetitionInfo:
288        """Create a new competition. ``title`` is a bare string — filed under
289        ``zh-CN`` when it holds a CJK ideograph, else ``en`` — or a
290        ``{locale: text}`` map naming both (#2230). ``None`` time fields = no bound
291        (sent as JSON ``null``). Unknown ``challenge_ids`` → 422.
292        ``challenge_scope`` is what entrants may play (#2553): ``"set"`` (the
293        default) — ``challenge_ids`` and nothing else, so an empty set starts
294        nothing — or ``"catalog"``, where any catalog challenge may start.
295        ``status`` defaults to ``draft`` (hidden from players); pass
296        ``"published"`` to make it immediately visible. ``access``
297        defaults to ``public``; pass ``"private_listed"`` /
298        ``"private_hidden"`` for an invite-only competition. ``kind`` is
299        ``"event"`` (a hosted contest, the default) or ``"suite"`` (a public
300        benchmark set open at any time: no window, no registration, listed on
301        the 测评集 page instead of the contest list; #2097).
302        ``eval_standard=True`` marks it for the platform's hosted evaluation
303        (only such a competition may back an official EvalCampaign);
304        ``corpus_sha`` pins the corpus snapshot it was certified against.
305        ``scoring_rule`` selects the board's ranking algorithm
306        (``""`` = flag count, ``static_points``, ``dynamic_points``) with
307        ``scoring_params`` carrying its tuning; set it here where you can,
308        because scoring freezes once the competition goes live.
309        ``scoring_rule_version`` is what a multi-flag challenge is worth
310        (#2731): ``challenge_total`` (the default) shares one total among
311        its scoring questions by weight; ``legacy`` pays every flag the
312        full value. It can be changed with :meth:`update` only before the
313        competition goes live (``409 scoring_rule_version_locked``).
314        ``sessions`` declares the playable slices of the window as
315        ``(start, end)`` pairs — a three-day event running 08:00-16:00
316        daily is three of them. Supplying it also *sets* ``starts_at`` /
317        ``ends_at``, which the server derives from the envelope, and
318        opts the competition into the play gate: outside a session,
319        submissions and instance starts are refused and running
320        instances are torn down. Omit it for a single continuous
321        window, which is how every competition behaves by default.
322        ``authoring_windows`` is the same idea for uploads: ``(start,
323        end)`` pairs, either side ``None`` for unbounded, outside which an
324        authored-challenge upload is refused — four mornings is four
325        pairs. Omit it to take uploads for as long as the event runs.
326        ``handbook`` is the organiser's long-form rules page, markdown,
327        capped server-side — ``description`` stays the one-line blurb the
328        listing and the page header truncate. Both are localised: pass a
329        ``{locale: text}`` map to write one translation, or a bare string,
330        which the server reads as ``en``. ⚠️ **A bare string is kept in the
331        signature rather than deprecated** — every caller that predates
332        localisation sends one, and a 422 for all of them would be a
333        breaking change dressed up as a feature; the coercion is the
334        server's (``coerce_localized``), so the two cannot disagree.
335        ``entrants_are_agents=True`` marks it an agent exhibition: human
336        registration is refused server-side and the browser shows a
337        spectate affordance rather than a sign-up button.
338        ``internet_policy`` decides whether instances started here may
339        reach the internet — ``allow`` (the default, today's behaviour),
340        ``deny``, or ``per_challenge`` (each challenge as its author
341        declared) — and ``challenge_internet`` overrides it per challenge
342        id (#1969). ``tcp_access`` is how players reach a TCP challenge —
343        ``tls_sni`` (the default), ``public_port`` or ``vpn`` — and
344        ``challenge_tcp_access`` overrides it per challenge id (#2082).
345        ``session_end_mode`` decides what a play session's end does
346        to running boxes -- ``pause`` / ``stop_keep`` (default) / ``grace``
347        (``session_end_grace_s``) / ``delete`` -- and the last session always
348        deletes (#2128). ``solve_env_mode`` decides what a full solve does to
349        the solved box -- ``destroy`` / ``grace`` (default,
350        ``solve_grace_s``) / ``keep_until_ttl`` (#2105). ``freeze_minutes`` holds the
351        players' scoreboard at ``ends_at - freeze_minutes`` until
352        :meth:`unfreeze` (or, with ``freeze_auto_unfreeze``, until the event
353        ends); ``0`` never freezes (#2133)."""
354        resp = self._http.request(
355            "POST",
356            "/admin/competitions",
357            json={
358                **({"title_i18n": title} if isinstance(title, dict) else {"title": title}),
359                "description": description,
360                "handbook": handbook,
361                "starts_at": starts_at.isoformat() if starts_at else None,
362                "ends_at": ends_at.isoformat() if ends_at else None,
363                "challenge_ids": challenge_ids or [],
364                "challenge_scope": challenge_scope,
365                "status": status,
366                "access": access,
367                "kind": kind,
368                "eval_standard": eval_standard,
369                "corpus_sha": corpus_sha,
370                "scoring_rule": scoring_rule,
371                "scoring_params": scoring_params or {},
372                "scoring_rule_version": scoring_rule_version,
373                # An agent exhibition: entrants are dispatched agents, so
374                # human registration is refused and the browser offers a
375                # spectate affordance. See ``CompetitionCreate``.
376                "entrants_are_agents": entrants_are_agents,
377                # #1969: the internet axis and its per-challenge overrides.
378                "internet_policy": str(internet_policy),
379                "challenge_internet": {k: str(v) for k, v in (challenge_internet or {}).items()},
380                # #2082: the way in for TCP challenges, and its overrides.
381                "tcp_access": str(tcp_access),
382                "challenge_tcp_access": {
383                    k: str(v) for k, v in (challenge_tcp_access or {}).items()
384                },
385                "session_end_mode": str(session_end_mode),
386                "session_end_grace_s": session_end_grace_s,
387                "solve_env_mode": str(solve_env_mode),
388                "solve_grace_s": solve_grace_s,
389                "freeze_minutes": freeze_minutes,
390                "freeze_auto_unfreeze": freeze_auto_unfreeze,
391                "sessions": [
392                    {"starts_at": s.isoformat(), "ends_at": e.isoformat()}
393                    for s, e in (sessions or [])
394                ],
395                "authoring_windows": [
396                    {
397                        "starts_at": s.isoformat() if s else None,
398                        "ends_at": e.isoformat() if e else None,
399                    }
400                    for s, e in (authoring_windows or [])
401                ],
402                # ⚠️ **Absent, never `{}`.** The server's model is
403                # `extra="forbid"` with two required cadence fields, so
404                # an empty object is a 422 on every competition that
405                # runs no rounds. `None` here means "not configured in
406                # this request", which the upsert route can still
407                # satisfy tomorrow — never "this competition has no
408                # match".
409                **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}),
410            },
411        )
412        _raise_for_status(resp)
413        return CompetitionInfo.model_validate(resp.json())

Create a new competition. title is a bare string — filed under zh-CN when it holds a CJK ideograph, else en — or a {locale: text} map naming both (#2230). None time fields = no bound (sent as JSON null). Unknown challenge_ids → 422. challenge_scope is what entrants may play (#2553): "set" (the default) — challenge_ids and nothing else, so an empty set starts nothing — or "catalog", where any catalog challenge may start. status defaults to draft (hidden from players); pass "published" to make it immediately visible. access defaults to public; pass "private_listed" / "private_hidden" for an invite-only competition. kind is "event" (a hosted contest, the default) or "suite" (a public benchmark set open at any time: no window, no registration, listed on the 测评集 page instead of the contest list; #2097). eval_standard=True marks it for the platform's hosted evaluation (only such a competition may back an official EvalCampaign); corpus_sha pins the corpus snapshot it was certified against. scoring_rule selects the board's ranking algorithm ("" = flag count, static_points, dynamic_points) with scoring_params carrying its tuning; set it here where you can, because scoring freezes once the competition goes live. scoring_rule_version is what a multi-flag challenge is worth (#2731): challenge_total (the default) shares one total among its scoring questions by weight; legacy pays every flag the full value. It can be changed with update() only before the competition goes live (409 scoring_rule_version_locked). sessions declares the playable slices of the window as (start, end) pairs — a three-day event running 08:00-16:00 daily is three of them. Supplying it also sets starts_at / ends_at, which the server derives from the envelope, and opts the competition into the play gate: outside a session, submissions and instance starts are refused and running instances are torn down. Omit it for a single continuous window, which is how every competition behaves by default. authoring_windows is the same idea for uploads: (start, end) pairs, either side None for unbounded, outside which an authored-challenge upload is refused — four mornings is four pairs. Omit it to take uploads for as long as the event runs. handbook is the organiser's long-form rules page, markdown, capped server-side — description stays the one-line blurb the listing and the page header truncate. Both are localised: pass a {locale: text} map to write one translation, or a bare string, which the server reads as en. ⚠️ A bare string is kept in the signature rather than deprecated — every caller that predates localisation sends one, and a 422 for all of them would be a breaking change dressed up as a feature; the coercion is the server's (coerce_localized), so the two cannot disagree. entrants_are_agents=True marks it an agent exhibition: human registration is refused server-side and the browser shows a spectate affordance rather than a sign-up button. internet_policy decides whether instances started here may reach the internet — allow (the default, today's behaviour), deny, or per_challenge (each challenge as its author declared) — and challenge_internet overrides it per challenge id (#1969). tcp_access is how players reach a TCP challenge — tls_sni (the default), public_port or vpn — and challenge_tcp_access overrides it per challenge id (#2082). session_end_mode decides what a play session's end does to running boxes -- pause / stop_keep (default) / grace (session_end_grace_s) / delete -- and the last session always deletes (#2128). solve_env_mode decides what a full solve does to the solved box -- destroy / grace (default, solve_grace_s) / keep_until_ttl (#2105). freeze_minutes holds the players' scoreboard at ends_at - freeze_minutes until unfreeze() (or, with freeze_auto_unfreeze, until the event ends); 0 never freezes (#2133).

def update( self, competition_id: str, **fields: Any) -> ctfy.server.models.CompetitionInfo:
415    def update(self, competition_id: str, **fields: Any) -> CompetitionInfo:
416        """PATCH any subset of ``title_i18n`` (the whole ``{locale: text}``
417        title map, replaced; #2230) / ``title`` (a bare string, merged into the
418        locale it is written in) / ``description`` / ``starts_at`` /
419        ``ends_at`` / ``challenge_ids`` / ``challenge_scope`` (``set`` |
420        ``catalog``, #2553 — a change to what may be played, so a locked event
421        takes it only like a change to the set) / ``status`` / ``access`` /
422        ``kind`` / ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` /
423        ``scoring_params`` / ``scoring_rule_version`` (before the start only) /
424        ``sessions`` / ``anonymous_entry`` /
425        ``anonymous_entry_kind`` / ``authoring_review`` (``human`` |
426        ``automatic``: under ``automatic`` a passing smoke on the event's
427        dedicated pool approves, publishes and curates an upload) /
428        ``authoring_smoke_quota`` (smoke runs per team per authoring window
429        under ``automatic``) / ``authoring_rate_limit_count`` +
430        ``authoring_rate_limit_window_s`` (uploads one team may make per
431        rolling window, #2079; ``0`` is off) / ``authoring_uploads_per_window``
432        (new uploads one team may start per authoring window, a re-upload of its
433        own upload never counted; ``0`` is **unlimited**) /
434        ``pinned`` (pin / unpin the event for everybody, #2070 — **platform
435        admins only**, ``403 pin_requires_platform_admin`` for an organiser) /
436        ``organiser_contact_email`` (where entrants reach the organisers, shown
437        on the competition page and in the registration mail, #2283; ``""``
438        removes it) /
439        ``authoring_overrides`` (#2885: a ``{setting: int}`` object of the
440        platform's own authoring setting names, e.g.
441        ``{"authored_challenge_memory_mb": 8192}``; only the keys sent move,
442        ``0`` defers to the platform; the runtime budget, build timeout,
443        auto-smoke budget and re-check interval are **platform admins only**,
444        ``403 authoring_override_requires_platform_admin``) /
445        ``authoring_cooldown`` (``default`` |
446        ``platform`` | ``competition`` | ``off``: the pause after repeated
447        rejections, with ``authoring_cooldown_threshold`` / ``_window_s`` /
448        ``_pause_s`` read under ``competition``) / ``report_disclosure``
449        (``default`` | ``after_lock`` | ``own`` | ``own_and_board``: what
450        players are shown about how they are doing, #2030) / ``freeze_minutes`` +
451        ``freeze_auto_unfreeze`` (hold the players' scoreboard for the last N
452        minutes, #2133; ``0`` is off). ⚠️ This is a **passthrough** — the list
453        is what the wire accepts today, so it is a pointer to
454        ``CompetitionUpdate`` rather than a gate, and :meth:`create` is
455        the narrow one (it builds its body by hand and cannot carry a
456        field it does not name). ⚠️ ``anonymous_entry`` may be *widened*
457        freely and is refused on a narrowing once entrants have relied
458        on it, ``required`` → ``optional`` included: that transition
459        un-anonymises the whole field at once. Omit a field to leave it; pass
460        ``None`` to clear. ``sessions`` is the one field where ``[]`` is
461        meaningful rather than empty: it clears the schedule and returns
462        the competition to a single continuous window; ``authoring_windows``
463        is the other, and ``[]`` there takes uploads for as long as the
464        event runs. Changing the scoring fields on a live competition needs
465        ``scoring_change_reason`` (``422 scoring_change_needs_reason``
466        without one): the change is recorded and shown on the board — see
467        ``competitions.scoring_live``. ``lock_challenge_versions`` holds an
468        event's uploaded challenges still while it is in play (#2041); a
469        change to ``challenge_ids`` of such an event then needs
470        ``challenge_change_confirmed=True`` and a ``challenge_change_reason``
471        (``409 challenge_set_locked`` otherwise), recorded per challenge —
472        see :meth:`challenge_changes`."""
473        resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields)
474        _raise_for_status(resp)
475        return CompetitionInfo.model_validate(resp.json())

PATCH any subset of title_i18n (the whole {locale: text} title map, replaced; #2230) / title (a bare string, merged into the locale it is written in) / description / starts_at / ends_at / challenge_ids / challenge_scope (set | catalog, #2553 — a change to what may be played, so a locked event takes it only like a change to the set) / status / access / kind / eval_standard / corpus_sha / scoring_rule / scoring_params / scoring_rule_version (before the start only) / sessions / anonymous_entry / anonymous_entry_kind / authoring_review (human | automatic: under automatic a passing smoke on the event's dedicated pool approves, publishes and curates an upload) / authoring_smoke_quota (smoke runs per team per authoring window under automatic) / authoring_rate_limit_count + authoring_rate_limit_window_s (uploads one team may make per rolling window, #2079; 0 is off) / authoring_uploads_per_window (new uploads one team may start per authoring window, a re-upload of its own upload never counted; 0 is unlimited) / pinned (pin / unpin the event for everybody, #2070 — platform admins only, 403 pin_requires_platform_admin for an organiser) / organiser_contact_email (where entrants reach the organisers, shown on the competition page and in the registration mail, #2283; "" removes it) / authoring_overrides (#2885: a {setting: int} object of the platform's own authoring setting names, e.g. {"authored_challenge_memory_mb": 8192}; only the keys sent move, 0 defers to the platform; the runtime budget, build timeout, auto-smoke budget and re-check interval are platform admins only, 403 authoring_override_requires_platform_admin) / authoring_cooldown (default | platform | competition | off: the pause after repeated rejections, with authoring_cooldown_threshold / _window_s / _pause_s read under competition) / report_disclosure (default | after_lock | own | own_and_board: what players are shown about how they are doing, #2030) / freeze_minutes + freeze_auto_unfreeze (hold the players' scoreboard for the last N minutes, #2133; 0 is off). ⚠️ This is a passthrough — the list is what the wire accepts today, so it is a pointer to CompetitionUpdate rather than a gate, and create() is the narrow one (it builds its body by hand and cannot carry a field it does not name). ⚠️ anonymous_entry may be widened freely and is refused on a narrowing once entrants have relied on it, required → optional included: that transition un-anonymises the whole field at once. Omit a field to leave it; pass None to clear. sessions is the one field where [] is meaningful rather than empty: it clears the schedule and returns the competition to a single continuous window; authoring_windows is the other, and [] there takes uploads for as long as the event runs. Changing the scoring fields on a live competition needs scoring_change_reason (422 scoring_change_needs_reason without one): the change is recorded and shown on the board — see competitions.scoring_live. lock_challenge_versions holds an event's uploaded challenges still while it is in play (#2041); a change to challenge_ids of such an event then needs challenge_change_confirmed=True and a challenge_change_reason (409 challenge_set_locked otherwise), recorded per challenge — see challenge_changes().

def challenge_source( self, competition_id: str) -> ctfy.server.models.CompetitionChallengeSource:
477    def challenge_source(self, competition_id: str) -> CompetitionChallengeSource:
478        """Where a competition's challenge set comes from (#2234): the
479        competition it follows, whether that source passes its set on now
480        (only while it is published), and the competitions that follow it."""
481        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-source")
482        _raise_for_status(resp)
483        return CompetitionChallengeSource.model_validate(resp.json())

Where a competition's challenge set comes from (#2234): the competition it follows, whether that source passes its set on now (only while it is published), and the competitions that follow it.

def set_challenge_source( self, competition_id: str, source_id: str) -> ctfy.server.models.CompetitionInfo:
485    def set_challenge_source(self, competition_id: str, source_id: str) -> CompetitionInfo:
486        """Make a competition's challenge set follow ``source_id``'s, or stop
487        following with ``""`` (#2234).
488
489        The follower then serves the source's set, read live, minus player
490        uploads; its own list is cleared and not editable while it follows
491        (``409 challenge_set_followed``). Stopping keeps the set it serves at
492        that moment as its own list. One level only: refused with
493        ``challenge_source_chained`` / ``challenge_source_followed``; the
494        caller must organise the source (``403 challenge_source_forbidden``).
495        Solves, instances and boards stay each competition's own."""
496        return self.update(competition_id, challenge_source_competition_id=source_id)

Make a competition's challenge set follow source_id's, or stop following with "" (#2234).

The follower then serves the source's set, read live, minus player uploads; its own list is cleared and not editable while it follows (409 challenge_set_followed). Stopping keeps the set it serves at that moment as its own list. One level only: refused with challenge_source_chained / challenge_source_followed; the caller must organise the source (403 challenge_source_forbidden). Solves, instances and boards stay each competition's own.

def reorder_challenges( self, competition_id: str, challenge_ids: list[str]) -> ctfy.server.models.CompetitionChallengeOrder:
498    def reorder_challenges(
499        self, competition_id: str, challenge_ids: builtins.list[str]
500    ) -> CompetitionChallengeOrder:
501        """Put a competition's challenges in the order its entrants see them
502        (#2037). ``challenge_ids`` must be exactly the set it holds now,
503        reordered — ``competition_challenge_order_stale`` when another
504        organiser changed it meanwhile."""
505        resp = self._http.request(
506            "PUT",
507            f"/admin/competitions/{competition_id}/challenge-order",
508            json={"challenge_ids": challenge_ids},
509        )
510        _raise_for_status(resp)
511        return CompetitionChallengeOrder.model_validate(resp.json())

Put a competition's challenges in the order its entrants see them (#2037). challenge_ids must be exactly the set it holds now, reordered — competition_challenge_order_stale when another organiser changed it meanwhile.

def pinned_challenges( self, competition_id: str) -> ctfy.server.models.CompetitionPinnedChallenges:
513    def pinned_challenges(self, competition_id: str) -> CompetitionPinnedChallenges:
514        """The challenges pinned to the head of a competition's list (#2070),
515        in the order they are shown."""
516        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pinned-challenges")
517        _raise_for_status(resp)
518        return CompetitionPinnedChallenges.model_validate(resp.json())

The challenges pinned to the head of a competition's list (#2070), in the order they are shown.

def set_pinned_challenges( self, competition_id: str, challenge_ids: list[str]) -> ctfy.server.models.CompetitionPinnedChallenges:
520    def set_pinned_challenges(
521        self, competition_id: str, challenge_ids: builtins.list[str]
522    ) -> CompetitionPinnedChallenges:
523        """Pin challenges to the head of a competition's list, in the order
524        given; the whole list is replaced (``[]`` clears it). Every id must be
525        one the competition serves now (``422 unknown_pinned_challenge``). A
526        pin is ordering and a marker, never visibility."""
527        resp = self._http.request(
528            "PUT",
529            f"/admin/competitions/{competition_id}/pinned-challenges",
530            json={"challenge_ids": challenge_ids},
531        )
532        _raise_for_status(resp)
533        return CompetitionPinnedChallenges.model_validate(resp.json())

Pin challenges to the head of a competition's list, in the order given; the whole list is replaced ([] clears it). Every id must be one the competition serves now (422 unknown_pinned_challenge). A pin is ordering and a marker, never visibility.

def preview_challenges( self, competition_id: str) -> ctfy.server.models.CompetitionPreviewChallenges:
535    def preview_challenges(self, competition_id: str) -> CompetitionPreviewChallenges:
536        """An invitation-only suite's free sample challenges (#2306), in the
537        order its page shows them."""
538        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/preview-challenges")
539        _raise_for_status(resp)
540        return CompetitionPreviewChallenges.model_validate(resp.json())

An invitation-only suite's free sample challenges (#2306), in the order its page shows them.

def set_preview_challenges( self, competition_id: str, challenge_ids: list[str]) -> ctfy.server.models.CompetitionPreviewChallenges:
542    def set_preview_challenges(
543        self, competition_id: str, challenge_ids: builtins.list[str]
544    ) -> CompetitionPreviewChallenges:
545        """Name an invitation-only suite's free sample challenges, replacing
546        the list (``[]`` offers none). Every id must be in the suite's set
547        (``422 unknown_preview_challenge``). Samples are open to everyone and
548        rank on no board of the suite."""
549        resp = self._http.request(
550            "PUT",
551            f"/admin/competitions/{competition_id}/preview-challenges",
552            json={"challenge_ids": challenge_ids},
553        )
554        _raise_for_status(resp)
555        return CompetitionPreviewChallenges.model_validate(resp.json())

Name an invitation-only suite's free sample challenges, replacing the list ([] offers none). Every id must be in the suite's set (422 unknown_preview_challenge). Samples are open to everyone and rank on no board of the suite.

def clone( self, competition_id: str, *, as_test: bool = True, starts_at: datetime.datetime | None = None) -> ctfy.server.models.CompetitionInfo:
557    def clone(
558        self, competition_id: str, *, as_test: bool = True, starts_at: datetime | None = None
559    ) -> CompetitionInfo:
560        """Clone a competition as its **test** (#2041) and return the test.
561
562        Every rule is copied, rules files included (their bytes); only the
563        allow-list differs — ``private_hidden``, the " · 测试" title suffix,
564        ``test_of`` naming the source, and every window cleared or, with
565        ``starts_at``, moved by one offset so the sessions keep their shape.
566        No team, registration, solve, submission or instance is copied.
567        Refused (422) for a replay (``clone_of_virtual``), for a test
568        (``clone_of_test``), and for ``starts_at`` on a source with no start
569        (``clone_shift_needs_start``).
570        """
571        resp = self._http.request(
572            "POST",
573            f"/admin/competitions/{competition_id}/clone",
574            json={
575                "as_test": as_test,
576                "starts_at": starts_at.isoformat() if starts_at else None,
577            },
578        )
579        _raise_for_status(resp)
580        return CompetitionInfo.model_validate(resp.json())

Clone a competition as its test (#2041) and return the test.

Every rule is copied, rules files included (their bytes); only the allow-list differs — private_hidden, the " · 测试" title suffix, test_of naming the source, and every window cleared or, with starts_at, moved by one offset so the sessions keep their shape. No team, registration, solve, submission or instance is copied. Refused (422) for a replay (clone_of_virtual), for a test (clone_of_test), and for starts_at on a source with no start (clone_shift_needs_start).

def unfreeze( self, competition_id: str) -> ctfy.server.models.CompetitionInfo:
582    def unfreeze(self, competition_id: str) -> CompetitionInfo:
583        """公布最终结果: publish a frozen event's final standings (#2133).
584
585        Ends the hold on the players' boards, mails the entrants their final
586        result and nudges every open board. Idempotent. ``409
587        board_not_frozen`` when the event has no freeze; ``409
588        event_not_over`` before ``ends_at`` (to lift a freeze during play,
589        ``update(id, freeze_minutes=0)``).
590        """
591        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/unfreeze")
592        _raise_for_status(resp)
593        return CompetitionInfo.model_validate(resp.json())

公布最终结果: publish a frozen event's final standings (#2133).

Ends the hold on the players' boards, mails the entrants their final result and nudges every open board. Idempotent. 409 board_not_frozen when the event has no freeze; 409 event_not_over before ends_at (to lift a freeze during play, update(id, freeze_minutes=0)).

def drift( self, competition_id: str) -> list[ctfy.server.models.CompetitionDriftInfo]:
595    def drift(self, competition_id: str) -> builtins.list[CompetitionDriftInfo]:
596        """How a test differs from its official competition, field by field (#2041).
597
598        One entry for a test; one per test of it for an official competition;
599        none for a competition with no test. ``warnings`` counts the rules the
600        two do not share, ``intentional`` the allow-listed differences.
601        """
602        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/drift")
603        _raise_for_status(resp)
604        return [CompetitionDriftInfo.model_validate(d) for d in resp.json()]

How a test differs from its official competition, field by field (#2041).

One entry for a test; one per test of it for an official competition; none for a competition with no test. warnings counts the rules the two do not share, intentional the allow-listed differences.

def sync_rules( self, competition_id: str) -> ctfy.server.models.CompetitionDriftInfo:
606    def sync_rules(self, competition_id: str) -> CompetitionDriftInfo:
607        """Apply the clone rules to a test competition again, in place (#2041),
608        and return the drift that is left."""
609        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/sync-rules")
610        _raise_for_status(resp)
611        return CompetitionDriftInfo.model_validate(resp.json())

Apply the clone rules to a test competition again, in place (#2041), and return the drift that is left.

def challenge_changes( self, competition_id: str) -> list[ctfy.server.models.CompetitionChallengeChangeInfo]:
613    def challenge_changes(
614        self, competition_id: str
615    ) -> builtins.list[CompetitionChallengeChangeInfo]:
616        """Every challenge added to or taken out of a competition while it
617        locked its challenges in play (#2041), oldest first, with the reason."""
618        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-changes")
619        _raise_for_status(resp)
620        return [CompetitionChallengeChangeInfo.model_validate(r) for r in resp.json()]

Every challenge added to or taken out of a competition while it locked its challenges in play (#2041), oldest first, with the reason.

def extend_session( self, competition_id: str, minutes: int) -> ctfy.server.models.CompetitionInfo:
622    def extend_session(self, competition_id: str, minutes: int) -> CompetitionInfo:
623        """Add ``minutes`` to the session running right now (#2128).
624
625        Refused (409) when no session is running (``session_not_running``)
626        or when the new end would run into the next session
627        (``session_extend_overlap``, carrying ``next_session_starts_at``).
628        Every registered team is told over SSE and by mail, and the
629        teardown job reads the new end on its next tick.
630        """
631        resp = self._http.request(
632            "POST",
633            f"/admin/competitions/{competition_id}/sessions/extend",
634            json={"minutes": minutes},
635        )
636        _raise_for_status(resp)
637        return CompetitionInfo.model_validate(resp.json())

Add minutes to the session running right now (#2128).

Refused (409) when no session is running (session_not_running) or when the new end would run into the next session (session_extend_overlap, carrying next_session_starts_at). Every registered team is told over SSE and by mail, and the teardown job reads the new end on its next tick.

def end_session( self, competition_id: str, *, confirm: bool = False) -> ctfy.server.models.CompetitionInfo:
639    def end_session(self, competition_id: str, *, confirm: bool = False) -> CompetitionInfo:
640        """Close the session running right now, at this instant.
641
642        ``confirm=True`` is required: every environment is stopped within
643        one teardown tick and there is no way back.
644        """
645        resp = self._http.request(
646            "POST",
647            f"/admin/competitions/{competition_id}/sessions/end",
648            json={"confirm": confirm},
649        )
650        _raise_for_status(resp)
651        return CompetitionInfo.model_validate(resp.json())

Close the session running right now, at this instant.

confirm=True is required: every environment is stopped within one teardown tick and there is no way back.

def delete(self, competition_id: str) -> None:
653    def delete(self, competition_id: str) -> None:
654        """Hard-delete a competition. Cascades against per-comp teams,
655        memberships, invites, solve / submission rows."""
656        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}")
657        _raise_for_status(resp)

Hard-delete a competition. Cascades against per-comp teams, memberships, invites, solve / submission rows.

def registrations( self, competition_id: str, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.CompetitionRegistrationInfo]:
659    def registrations(
660        self,
661        competition_id: str,
662        offset: int = 0,
663        limit: int = 50,
664    ) -> builtins.list[CompetitionRegistrationInfo]:
665        """Roster for ``competition_id``. Per the per-comp refactor, a
666        team's existence with the comp scope IS the registration."""
667        resp = self._http.request(
668            "GET",
669            f"/admin/competitions/{competition_id}/registrations",
670            params={"offset": offset, "limit": limit},
671        )
672        _raise_for_status(resp)
673        return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]

Roster for competition_id. Per the per-comp refactor, a team's existence with the comp scope IS the registration.