Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions admin/src/federated-element.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,49 @@ describe('PlaytestCreatePage', () => {
expect(within(dialog).getByText('b2')).toBeInTheDocument()
})

it('greys out and disables non-buildinfo (smartbuild) builds while keeping buildinfo selectable', async () => {
setLinkages()
mockGetAdtGames.mockReturnValue({
data: { games: [{ id: 'game-1', name: 'Starfield Dev' }] },
isLoading: false,
error: null
})
mockGetAdtBuilds.mockReturnValue({
data: {
builds: [
{ id: 'b-info', name: 'v1.3', version: 'v1.3', platform: 'windows', uploadedAt: '2026-05-10T00:00:00Z', buildType: 'buildinfo' },
{ id: 'b-smart', name: 'v1.3', version: 'v1.3', platform: 'windows', uploadedAt: '2026-05-09T00:00:00Z', buildType: 'smartbuild' }
]
},
isLoading: false,
error: null
})
renderAt('/new')
const user = userEvent.setup()
await user.click(screen.getByRole('radio', { name: /^ADT$/i }))
await user.click(screen.getByLabelText(/adt linkage/i))
await user.click(await screen.findByText(/adt-ns-1/i))
await user.click(await screen.findByRole('button', { name: /select game build/i }))

const dialog = await screen.findByRole('dialog', { name: /select game build/i })
await user.click(within(dialog).getByTestId('adt-picker-version-v1.3'))

// Both build-type labels render so operators can tell them apart.
expect(within(dialog).getByTestId('adt-picker-build-type-b-info')).toHaveTextContent('buildinfo')
expect(within(dialog).getByTestId('adt-picker-build-type-b-smart')).toHaveTextContent('smartbuild')
// The smartbuild card is greyed out + carries the not-downloadable note.
expect(within(dialog).getByTestId('adt-picker-build-b-smart')).toHaveStyle({ cursor: 'not-allowed' })
expect(within(dialog).getByText(/only buildinfo builds can be distributed/i)).toBeInTheDocument()

const useBtn = within(dialog).getByRole('button', { name: /use this build/i })
// Clicking the smartbuild card must NOT enable selection.
await user.click(within(dialog).getByTestId('adt-picker-build-b-smart'))
expect(useBtn).toBeDisabled()
// Clicking the buildinfo card does.
await user.click(within(dialog).getByTestId('adt-picker-build-b-info'))
expect(useBtn).toBeEnabled()
})

it('wires both adtGameId and adtBuildId on the parent form when Use This Build is clicked', async () => {
setLinkages()
const mutate = vi.fn()
Expand Down
3 changes: 2 additions & 1 deletion admin/src/playtesthubapi/generated-definitions/V1AdtBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const V1AdtBuild = z.object({
name: z.string().nullish(),
version: z.string().nullish(),
uploadedAt: z.string().nullish(),
platform: z.string().nullish()
platform: z.string().nullish(),
buildType: z.string().nullish()
})

export interface V1AdtBuild extends z.TypeOf<typeof V1AdtBuild> {}
34 changes: 29 additions & 5 deletions admin/src/shared/adt-build-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAppUIContext } from '@accelbyte/sdk-extend-app-ui'
import { Card, Modal, Select, Space, Typography } from 'antd'
import { Card, Modal, Select, Space, Tag, Typography } from 'antd'
import { useMemo, useState } from 'react'
import type { V1AdtBuild } from '../playtesthubapi/generated-definitions/V1AdtBuild'
import type { V1AdtGame } from '../playtesthubapi/generated-definitions/V1AdtGame'
Expand Down Expand Up @@ -144,17 +144,41 @@ export function ADTBuildPickerModal({
{versionName && (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{versionBuilds.map(b => {
// "smartbuild" (and any other explicitly non-buildinfo
// type) cannot mint a download URL, so it is surfaced but
// greyed out + non-selectable. An empty/absent buildType
// (legacy ADT drops predating the field) stays selectable
// so the picker is never wholesale-disabled — only a build
// ADT positively labels non-buildinfo is blocked.
const buildType = (b.buildType ?? '').toLowerCase()
const downloadable = buildType === '' || buildType === 'buildinfo'
const selected = b.id === pickedBuildId
return (
<Card
key={b.id ?? ''}
size="small"
hoverable
onClick={() => setPickedBuildId(b.id ?? null)}
style={{ borderColor: selected ? '#1677ff' : undefined }}
hoverable={downloadable}
onClick={downloadable ? () => setPickedBuildId(b.id ?? null) : undefined}
style={{
borderColor: selected ? '#1677ff' : undefined,
opacity: downloadable ? 1 : 0.6,
cursor: downloadable ? 'pointer' : 'not-allowed'
}}
data-testid={`adt-picker-build-${b.id}`}>
<Space direction="vertical" size={2}>
<Typography.Text strong>{b.platform ?? 'unknown platform'}</Typography.Text>
<Space size={8}>
<Typography.Text strong>{b.platform ?? 'unknown platform'}</Typography.Text>
{b.buildType && (
<Tag color={downloadable ? 'green' : 'default'} data-testid={`adt-picker-build-type-${b.id}`}>
{b.buildType}
</Tag>
)}
</Space>
{!downloadable && (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
Not downloadable — only buildinfo builds can be distributed.
</Typography.Text>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Uploaded {b.uploadedAt ?? '—'}
</Typography.Text>
Expand Down
4 changes: 4 additions & 0 deletions admin/swaggers/playtesthub.json
Original file line number Diff line number Diff line change
Expand Up @@ -2389,6 +2389,10 @@
"platform": {
"type": "string",
"description": "Platform — the ADT-reported target (\"windows\", \"linux\", ...). Empty\nwhen ADT does not surface it. Wire-added 2026-05-20 alongside the\nADT-eng API spec resolution."
},
"buildType": {
"type": "string",
"description": "BuildType — the ADT `build_type` discriminator (wire-added 2026-05-28\nper the ADT-eng builds-list addendum). Observed values: \"buildinfo\"\n(a real, downloadable build) and \"smartbuild\" (a SmartBuild entry\nthat cannot mint a download URL). Only \"buildinfo\" builds are\nselectable in the admin picker; everything else is greyed out as\nnon-downloadable. Empty when ADT omits it on older drops."
}
},
"description": "ADTBuild is the minimum build row the admin UI / pth CLI needs to\ndrive the build picker. Mirrors pkg/adt.Build but rendered as proto."
Expand Down
4 changes: 3 additions & 1 deletion docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Applies only to playtests with `distributionModel = AGS_CAMPAIGN`.
| CompleteADTLink | admin | `state`, `adt_namespace` | `ADTLinkage` | natural key (`state` — single-use; replay returns `InvalidArgument`) | M5.B |
| UnlinkADT | admin | `adtLinkageId` | success/empty | idempotent (re-unlink is no-op); best-effort propagates the unlink to ADT (`adt.Client.DeleteLinkage`) in the same flow — failures are logged + counted on `ADTUnlinkADTSideFailures` but do not block the local soft-delete (PRD §4.8). | M5.B |
| RecoverADTLinkage | admin | `adtNamespace` | `ADTLinkage` | not idempotent (single-use orphan adoption); rejected with `AlreadyExists` when a live row for the pair already exists | M5.B |
| ListADTBuilds | admin | `adtLinkageId`, `adtGameId` | `[]Build` (proxied through `adt.Client`) | read-only | M5.B |
| ListADTBuilds | admin | `adtLinkageId`, `adtGameId` | `[]Build` (proxied through `adt.Client`; each `Build` carries `buildType` — `buildinfo` is downloadable, `smartbuild` is not, see §4.8.3) | read-only | M5.B |
| ListADTGames | admin | `adtLinkageId` | `[]Game` (proxied through `adt.Client.ListGames`; drives the create-playtest build-picker top-level dropdown — see [`STATUS_M5.md`](STATUS_M5.md) "Addendum 2026-05-21 — games-list endpoint") | read-only | M5.B |
| ChangeADTBuild | admin | `playtestId`, `adtGameId`, `adtBuildId` | Playtest | repoints an ADT playtest at a new `(adtGameId, adtBuildId)` pair under its existing `adtNamespace` (immutable here — see §4.8.6); verifies the pair against the linkage via the same `adt.Client.ListBuilds` round-trip `CreatePlaytest` runs before persisting; `FailedPrecondition` on a non-ADT playtest | M5.C |
| GetADTDownloadInfo | player | `playtestId` | `{url, expiresAt?, source ('issued'|'fallback')}` (gated on `applicant.status='APPROVED'` exactly like `GetGrantedCode`) | read-only (each call may mint a fresh URL per ADT semantics) | M5.B |
Expand Down Expand Up @@ -178,6 +178,8 @@ ADT (AccelByte Development Toolkit) playtests distribute an in-development build

Per the 2026-05-20 ADT API spec (see [`STATUS_M5.md`](STATUS_M5.md) §"Open questions"), ADT issues **per-build** download URLs — not per-applicant. Every approved applicant for a given playtest receives the same URL; ADT bounds it with a fixed 24-hour CDN TTL. Per-applicant audit attribution lives on the playtesthub side (`applicant.approve` row carries the applicant id + URL); per-applicant revocation is not available via ADT — `RejectApplicant` is the revocation primitive (cuts off `GetADTDownloadInfo` access on the playtesthub side; the URL itself stays valid for the TTL).

**Only `buildinfo` builds are downloadable.** ADT's builds-list response (`ListADTBuilds`) tags each row with a `build_type` discriminator (added 2026-05-28): `buildinfo` is a real build that can mint a download URL, while `smartbuild` is a SmartBuild entry that cannot. The admin build picker surfaces every build with its `build_type` label but **greys out and disables selection of non-`buildinfo` builds**, so an operator cannot point a playtest at a build that would fail at approve time. The list is shown unfiltered (both types) for transparency.

`ApproveApplicant` against an ADT playtest:
- Skips code reservation entirely (no `Code` row).
- Calls `adt.Client.IssueDownloadURL(adtNamespace, adtGameId, adtBuildId)` against `GET <ADT_BASE>/profiling/namespaces/<adt_namespace>/agsplaytesthub/games/<adt_game_id>/builds/<adt_build_id>/downloadUrls?limit=20`. ADT returns `{urls: [...], expiresAt}`; playtesthub surfaces the **full URL list** in ADT's original order so multi-asset builds (game binary + patcher + manifest, etc.) round-trip without data loss. Single-file builds map to a single-element list.
Expand Down
2 changes: 2 additions & 0 deletions docs/STATUS_M5.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ C13. `[x]` **ChangeADTBuild — repoint an ADT playtest's build post-create + re

C14. `[x]` **ADT build-health surfacing ("build gone" on the detail page) + ChangeADTBuild persistence bug fix** — live-investigation follow-up: an `ApproveApplicant` 400 (`errorCode=1003303402` / `ErrBuildNotFound`) was only discoverable at approve time. Confirmed against live ADT (`develop.blackbox.accelbyte.io`): the `downloadUrls` issue 404s "build not found" even though the build still appears in `ListBuilds` — so a list-membership check is **insufficient**; the faithful signal is the side-effecting `IssueDownloadURL` call. Surfacing chosen = **persist-at-approve + on-demand check** (not auto-probe-on-load). Migration 0009 adds nullable `playtest.adt_build_status` (`OK`/`UNAVAILABLE`, CHECK-enforced) + `adt_build_checked_at`. New admin RPC `CheckADTBuild(playtestId)` ([`pkg/service/adt.go`](../pkg/service/adt.go)) probes `IssueDownloadURL` (ignoring the fallback URL — a fallback masks a dead build for approval but doesn't make the build healthy), maps `ErrBuildNotFound`→`UNAVAILABLE` / success→`OK`, persists via the new repo writer `SetADTBuildHealth` (which deliberately does NOT bump `updated_at`); transient ADT errors surface as gRPC errors without clobbering the last-known status. `resolveADTDownloadURL` (approve + RetryDM) records the same health opportunistically before any fallback masks it. Admin Distribution-tab Game Build card gains a health `Alert` (error when `UNAVAILABLE` w/ last-checked + "set a fallback URL or change the build" guidance; success when `OK`) + a "Check build" button. **Bug fix bundled**: `ChangeADTBuild` routed through `PgPlaytestStore.Update`, whose SET whitelist omits `adt_game_id`/`adt_build_id` — so the build change was silently dropped at the DB (the recovery path no-oped); its happy-path test passed only because the fake store cloned the whole struct. Added dedicated writer `UpdateADTBuild` + a real-PgStore integration regression test. `pth adt build check --playtest-id` CLI + smoke dry-run probe. PRD §4.8.3 + [`errors.md`](errors.md) (4 `CheckADTBuild` rows) amended. Tests: repo integration (migration cols, `UpdateADTBuild` persist + soft-deleted refusal, `SetADTBuildHealth` round-trip + updated_at untouched), 6 service cases (Check OK/UNAVAILABLE/non-ADT/not-found/transient-no-persist + approve-time persist OK/UNAVAILABLE), 4 admin vitest cases (no alert pre-check, UNAVAILABLE alert, OK alert, Check button mutation), `TestServiceDescriptorMethods` extended.

C15. `[x]` **ADT `build_type` surfacing — grey out non-downloadable (smartbuild) builds in the picker** (2026-05-28) — ADT-eng added a `build_type` discriminator to the builds-list response (`GET …/builds`, confirmed live against `develop.blackbox.accelbyte.io` v1.35.0 swagger `models.BuildSummary` + a real probe of namespace `avengers`: values observed are `buildinfo` (a real, downloadable build) and `smartbuild` (a SmartBuild entry that cannot mint a download URL). Only `buildinfo` builds can be distributed. New `Build.BuildType` field + `Build.Downloadable()` helper (case-insensitive `== "buildinfo"`) in [`pkg/adt/client.go`](../pkg/adt/client.go); [`pkg/adt/http.go`](../pkg/adt/http.go) parses `build_type`; proto `ADTBuild.build_type` (field 6) carries it through `ListADTBuilds`; [`pkg/service/adt.go`](../pkg/service/adt.go) `adtBuildToProto` maps it. The admin [`ADTBuildPickerModal`](../admin/src/shared/adt-build-picker.tsx) renders a per-build `build_type` tag (green for buildinfo, default otherwise) and **greys out + disables selection** of non-buildinfo cards (opacity, `cursor: not-allowed`, no `onClick`, "Not downloadable — only buildinfo builds can be distributed." note). The list stays **unfiltered** — both types render so operators see the full set; ADT's server-side `?buildType=` filter is deliberately not sent (documented in [`cli.md`](cli.md)). Tests: extended `pkg/adt/http_test.go` happy-path (buildinfo + smartbuild rows assert `BuildType` + `Downloadable()`), new `TestListADTBuilds_MapsBuildType` service case, new federated-element picker case (both tags render, smartbuild card greyed/disabled, "Use This Build" stays disabled on smartbuild click + enables on buildinfo). Additive response field — no migration, no new RPC, existing smoke harness stays green. **Not done (out of scope, possible follow-up)**: server-side `CreatePlaytest`/`ChangeADTBuild` rejection of a non-buildinfo `adt_build_id` (today the UI prevents selection but the API would still accept one).

**Track C milestone definition of done**: every Track A + Track B phase still green; admin shell uses the detail-page-with-tabs layout end-to-end; bulk announcement DM verified end-to-end in the golden-m5 e2e (recipients reach `SENT`); platforms column replacement deployed with the migration 0007 cutover runbook step documented and tested in a fresh testcontainers boot; PRD §4 / §5.1 / §5.4 / §5.7 / §5.9 + schema.md + errors.md + CHANGELOG v2.6 complete; the prototype's three admin scenarios match the live build screen-for-screen (UX sign-off check) **modulo the telemetry surface which is explicitly deferred to M6**.

### Track D — Survey discovery (post-M5.C polish)
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ PRD §4.8 / [`docs/runbooks/adt-linking.md`](runbooks/adt-linking.md). All seven
| `pth adt linkage complete --state <s> --adt-namespace <ns>` | `CompleteADTLink` | Idempotent on duplicate `(studio, adt_namespace)`; replay with a consumed `state` returns `InvalidArgument`. |
| `pth adt linkage unlink --id <adt_linkage_id>` | `UnlinkADT` | Idempotent; best-effort `DELETE` against ADT to drop its side's flag. |
| `pth adt linkage recover --adt-namespace <ns>` | `RecoverADTLinkage` | Adopts an orphan ADT-side flag (flag present on ADT, no local row) without an OAuth round-trip. Probes ADT first; `FailedPrecondition` if no flag exists, `AlreadyExists` if a non-deleted local row already covers `(studio, adt_namespace)`. |
| `pth adt build list --linkage-id <id> --game-id <gid>` | `ListADTBuilds` | Defense-in-depth check: `CreatePlaytest` ADT branch reuses the same path to verify the picked `adt_build_id` belongs to `(adt_namespace, adt_game_id)`. |
| `pth adt build list --linkage-id <id> --game-id <gid>` | `ListADTBuilds` | Defense-in-depth check: `CreatePlaytest` ADT branch reuses the same path to verify the picked `adt_build_id` belongs to `(adt_namespace, adt_game_id)`. Each row carries `buildType` (ADT `build_type`): `buildinfo` builds are downloadable; `smartbuild` (and any other type) cannot mint a download URL and are greyed out / non-selectable in the admin picker. The list is unfiltered (both types returned); ADT also accepts a server-side `?buildType=buildinfo\|smartbuild` filter, which playtesthub deliberately does not send so operators see the full set. |
| `pth adt build check --playtest-id <id>` | `CheckADTBuild` | Probes whether the playtest's current build can still mint a download URL (same call as `ApproveApplicant`) and persists `adt_build_status`. `healthy=false` + `UNAVAILABLE` when ADT returns build-not-found; surfaces the dead build on the detail page without an approval attempt (M5.C). |
| `pth adt games list --linkage-id <id>` | `ListADTGames` | Drives the admin build-picker top-level dropdown so operators no longer type the `adt_game_id` by hand. STATUS_M5.md B12. |
| `pth adt diagnostics` | `GetADTClientDiagnostics` | Reports which `adt.Client` kind the bootapp wired (`http` vs `mem`) plus the presence (booleans only — never values) of every env var that feeds the gate (`PLUGIN_GRPC_SERVER_AUTH_ENABLED`, `ADT_BASE_URL`, `AGS_BASE_URL`, `AGS_IAM_CLIENT_ID`, `AGS_IAM_CLIENT_SECRET`). Use when `UnlinkADT` appears to soft-delete locally but ADT still reports the linkage — `mem` here means the boot gate silently fell back and ADT-side propagation is a no-op. |
Expand Down
4 changes: 4 additions & 0 deletions gateway/apidocs/api.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2389,6 +2389,10 @@
"platform": {
"type": "string",
"description": "Platform — the ADT-reported target (\"windows\", \"linux\", ...). Empty\nwhen ADT does not surface it. Wire-added 2026-05-20 alongside the\nADT-eng API spec resolution."
},
"buildType": {
"type": "string",
"description": "BuildType — the ADT `build_type` discriminator (wire-added 2026-05-28\nper the ADT-eng builds-list addendum). Observed values: \"buildinfo\"\n(a real, downloadable build) and \"smartbuild\" (a SmartBuild entry\nthat cannot mint a download URL). Only \"buildinfo\" builds are\nselectable in the admin picker; everything else is greyed out as\nnon-downloadable. Empty when ADT omits it on older drops."
}
},
"description": "ADTBuild is the minimum build row the admin UI / pth CLI needs to\ndrive the build picker. Mirrors pkg/adt.Build but rendered as proto."
Expand Down
Loading
Loading