[discovery][lists] semi working lists and discovery

This commit is contained in:
Devin Zuczek
2026-08-20 13:35:18 -04:00
parent f678f87d00
commit 5db02db172
10 changed files with 685 additions and 289 deletions
+144
View File
@@ -0,0 +1,144 @@
import curatedLists from '../static/curated-lists.json'
/**
* Which PAGE a curated list belongs to — the `Type` on the list and the `?type=` the client
* asks with. Not to be confused with the entity-type enum the algorithmic lists echo: this
* one names a page source (the Watch home, the store's Featured tab, …), and the list it
* keys is that page's row set.
*
* `None` is the client's unset sentinel; it never reaches the wire, and nothing is captured
* under it.
*/
export const CuratedListType = {
WatchHome: 0,
PlayHighlight: 1,
CommunityBoard: 2,
MobileHome: 3,
StoreFeatured: 4,
StoreClothing: 5,
StoreConsumables: 6,
PlayCategories: 7,
StoreInventions: 8,
TitleScreen: 9,
PlayMenuTabs: 10,
OrientationStoreFeatured: 11,
AppNavPortalPanel: 12,
WWPanelList: 13,
RecRoomPlusBenefits: 14,
RecCenterStorefront: 15,
RecCenterCommunityContent: 16,
None: 999,
} as const
/**
* One curated list as the client parses it. `Description` may be null but `ImageName` must
* be a STRING — the client reads it straight into a string field — and `ItemIds` are
* strings even where they stand for numeric ids.
*
* `ListId` is a string HERE ONLY, and never reaches the client as one: see
* `serializeCuratedList`. `Accessibility` rides along from the captures untouched.
*/
export interface CuratedList {
ListId: string
CreatorAccountId: number
Name: string
Description: string | null
ImageName: string
Type: number
ItemIds: string[]
Accessibility?: number
CreatedAt: string
}
/**
* Every list this server serves, all of them in `static/curated-lists.json` — drop a
* capture into that array and it is served; nothing else needs editing. (One file per list
* would read better, but wrangler bundles with esbuild, which has no glob import: a
* directory can only be picked up by naming every file in an `import`. `import.meta.glob`
* is a Vite feature — vitest runs through Vite and would resolve it, while the deployed
* build ships the call verbatim and throws at runtime, so tests would pass and the worker
* would not.)
*
* Nothing curates lists here, so these are static captures; ORDER matters only in that the
* first list of a given type is that page's default (see `BY_TYPE`).
*
* `ItemIds` are the discovery ROWS each page is built from (the section keys the `discovery`
* worker serves under `/sections/pagesource/*`), not room or item ids: the client resolves
* each row itself.
*/
const CURATED_LISTS: CuratedList[] = curatedLists
/**
* A list's `ListId` is the reference's own, and those are 64-bit
* (`624765592684307326`) — past what a JS number holds exactly, so parsing one rounds it
* (…307326 → …307300). The captures therefore carry it as a STRING, which survives the
* round trip, and this puts the digits back on the wire unquoted: the client's field is a
* number, and a quoted id fails its parser.
*
* Only a run of digits is unquoted, so a malformed id is left alone rather than corrupting
* the JSON — the integration tests assert every capture has one.
*/
export function serializeCuratedList(list: CuratedList): string {
return JSON.stringify(list).replace(/"ListId":"(\d+)"/, '"ListId":$1')
}
/** Names are matched case-insensitively — the casing that reaches us is the client's. */
function nameKey(name: string): string {
return name.toLowerCase()
}
/** All three keys the query carries. The most specific match wins. */
const BY_CREATOR_TYPE_NAME = new Map<string, CuratedList>()
/** Same list without the creator — the client sometimes asks with a creator nothing owns. */
const BY_TYPE_NAME = new Map<string, CuratedList>()
/** By name alone, for a name whose `type` doesn't line up with what it is captured under. */
const BY_NAME = new Map<string, CuratedList>()
/**
* By type alone: the page's DEFAULT list, which is what answers a `name` this server has
* nothing under — the store's Featured page, for one, asks for its rows by the reference's
* numeric list id (`name=17859340`) rather than by a name.
*/
const BY_TYPE = new Map<number, CuratedList>()
for (const list of CURATED_LISTS) {
const name = nameKey(list.Name)
BY_CREATOR_TYPE_NAME.set(`${list.CreatorAccountId}/${list.Type}/${name}`, list)
if (!BY_TYPE_NAME.has(`${list.Type}/${name}`)) BY_TYPE_NAME.set(`${list.Type}/${name}`, list)
if (!BY_NAME.has(name)) BY_NAME.set(name, list)
// First captured wins, so a page with more than one list defaults to whichever sits
// earliest in `static/curated-lists.json`.
if (!BY_TYPE.has(list.Type)) BY_TYPE.set(list.Type, list)
}
/**
* What a request that matches nothing at all gets: the Play menu's Explore panel. An empty
* body — or a 404 — renders as a blank page rather than an error, so answering with
* SOMETHING is the better failure, and it is what the reference was observed doing for a
* list it had nothing under. Falls back to whatever sits first if that capture is ever
* dropped from the array.
*/
const DEFAULT_CURATED_LIST =
BY_NAME.get(nameKey('Discovery.PageSource.PlayExplore')) ?? CURATED_LISTS[0]
/**
* The list behind `GET /curatedlists?creatorAccountId=&type=&name=`, resolved most-specific
* first and never empty. `type` is the page asking (see `CuratedListType`) and dictates the
* name, so the two normally agree; when they don't, the page's default list still answers.
*/
export function resolveCuratedList(
creatorAccountId: string | undefined,
type: string | undefined,
name: string | undefined
): CuratedList {
const key = nameKey(name ?? '')
const parsedType = Number.parseInt(type ?? '', 10)
const hasType = Number.isInteger(parsedType)
return (
(hasType ? BY_CREATOR_TYPE_NAME.get(`${creatorAccountId}/${parsedType}/${key}`) : undefined) ??
(hasType ? BY_TYPE_NAME.get(`${parsedType}/${key}`) : undefined) ??
BY_NAME.get(key) ??
(hasType ? BY_TYPE.get(parsedType) : undefined) ??
DEFAULT_CURATED_LIST
)
}
+32 -88
View File
@@ -5,6 +5,8 @@ import { getHotRooms } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
import type { Context } from 'hono'
import type { App } from './context'
@@ -22,8 +24,10 @@ function unauthorized(c: Context<App>) {
}
/**
* What the ids in a list ARE. One enum shared by the curated lists' `Type` and the
* algorithmic lists' — a BYTE on the client, so only 0255 round-trips.
* What the ids in an ALGORITHMIC list are — a BYTE on the client, so only 0255
* round-trips. Not the curated lists' `Type`, which names the page a list belongs to
* (see `CuratedListType` in `curated-lists.ts`); the two are different enums that happen to
* share a field name.
*
* It is what tells the client which service to resolve the ids against, which is why the
* algorithmic route echoes back the type it was asked for rather than asserting one of its
@@ -45,64 +49,6 @@ const ListEntityType = {
/** The largest value the client's byte-wide `Type` can carry back. */
const MAX_LIST_ENTITY_TYPE = 255
/**
* The canned discovery pages served by `GET /curatedlists`, keyed by the `Name` the client
* asks for. `ItemIds` are discovery row keys (strings), `Description` is null but
* `ImageName` must be a string, and `CreatedAt` keeps its 7-digit fractional seconds — all
* as the client's parser expects them.
*
* `ListId` is ours to choose and is deliberately SMALL. The reference's ids are 64-bit
* (`624765592684307326`), past what a JS number holds exactly, so serving them verbatim
* meant carrying them as bigints and hand-writing the JSON to keep the digits — machinery
* for an id nothing on this server looks up. What the id has to be is stable and unique
* per page: the client caches a list against it, so two pages sharing one id would serve
* each other's rows from cache, and changing a page's id would drop its cache. Renumber
* only when the page's contents are meant to be re-fetched.
*/
const CURATED_LISTS = [
{
ListId: 1,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayExplore',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: ListEntityType.DiscoverySection,
ItemIds: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
],
Accessibility: 1,
CreatedAt: '2025-04-23T18:27:03.2643786Z',
},
{
ListId: 2,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayLibrary',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: ListEntityType.DiscoverySection,
// The library page: what you were playing, saved, favorited and made. Note the
// second row is a `PlayHighlight` key rather than a `PlayLibrary` one — that is the
// reference's own naming, not a typo to tidy.
ItemIds: [
'Rooms_ContinuePlaying_PlayLibrary',
'Rooms_SavedForLater_PlayHighlight',
'Rooms_Favorites_PlayLibrary',
'Rooms_MyRooms_Play',
],
Accessibility: 1,
CreatedAt: '2025-04-23T18:25:31.5308539Z',
},
]
/**
* The entities an algorithmic list hands back (`GET /algorithmiclists/:list`) — ROOMS, which
* is what a Play/Explore row is built from. Nothing ranks anything here yet, so one canned
@@ -149,21 +95,6 @@ const HOT_LIST_FEED = 'community'
*/
const DEFAULT_ALGORITHMIC_LIST_TYPE = ListEntityType.Rooms
/**
* Each page keyed by its lowercased `Name` — the only thing a request decides is which one
* it gets. Each entry is an ARRAY of one list: the endpoint answers a collection, and the
* client reads it as such however many entries come back.
*/
const CURATED_LIST_PAGES = new Map(CURATED_LISTS.map((list) => [list.Name.toLowerCase(), [list]]))
/**
* What a request that names no page — or names one nothing is captured for — gets: the
* Explore page. A 404 or an empty array renders as an empty Play page, so answering with
* SOMETHING is the better failure, and it is also what the reference was observed doing
* for a `name` this server has no list under.
*/
const DEFAULT_CURATED_LIST_PAGE = CURATED_LIST_PAGES.get('discovery.pagesource.playexplore')!
const app = new Hono<App>()
.use(
'*',
@@ -202,22 +133,35 @@ const app = new Hono<App>()
])
})
// The curated lists behind a discovery page (`GET /curatedlists`). The client asks with
// `?creatorAccountId=&type=&name=`, and `name` is the page it wants
// (`Discovery.PageSource.PlayExplore`, `…PlayLibrary`): it picks which canned page comes
// back, matched case-insensitively. Nothing curates lists here, so the pages are static
// captures — the same stand-in posture as `/curatedlists/bulk` above.
// The curated list behind a discovery page (`GET /curatedlists`). The client asks with
// `?creatorAccountId=&type=&name=` and reads back ONE list object — not a collection.
// `type` is the page asking (`CuratedListType`: the Watch home, the store's Featured tab,
// …) and dictates the name it asks under, so the two normally agree.
//
// `creatorAccountId` and `type` are accepted and IGNORED, deliberately: the reference was
// observed answering a `type=5` request with a `Type` 7 list, so filtering on them would
// answer nothing where it answers a page. An unknown `name` falls back to Explore rather
// than an empty array, which renders as an empty Play page.
// Nothing curates lists here, so every list is a static capture in
// `static/curated-lists.json`. `resolveCuratedList` matches most-specific first and
// always answers something: a name this server has nothing under falls back to the page's
// default list rather than 404ing or answering empty, either of which renders as a blank
// page. That fallback is load-bearing — the store's Featured page asks for its rows by the
// reference's numeric list id (`name=17859340`), which is nothing's name here.
//
// `ItemIds` are the discovery ROWS the page is built from (algorithm/section keys), not
// room ids — the client resolves each one itself.
// `ItemIds` are the discovery ROWS the page is built from (the section keys the `discovery`
// worker serves), not room or item ids — the client resolves each one itself.
.get('/curatedlists', async (c) => {
const name = c.req.query('name') ?? ''
return c.json(CURATED_LIST_PAGES.get(name.toLowerCase()) ?? DEFAULT_CURATED_LIST_PAGE)
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are
// 64-bit and are carried as strings so their digits survive being parsed — see
// `serializeCuratedList`, which puts them back on the wire as numbers.
return c.body(
serializeCuratedList(
resolveCuratedList(
c.req.query('creatorAccountId'),
c.req.query('type'),
c.req.query('name')
)
),
200,
{ 'content-type': 'application/json' }
)
})
// One discovery ROW's contents (`GET /algorithmiclists/:list?type=1`). `:list` is the row
+98 -79
View File
@@ -103,99 +103,118 @@ it('serves the canned curated-list bulk lookup', async () => {
])
})
it('serves the canned discovery page from /curatedlists', async () => {
it('serves one curated list object, not a collection', async () => {
// The client reads a single list off this endpoint — a bare object, not an array.
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=RoomGenreTags`
`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=Discovery.PageSource.PlayExplore`
)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
const text = await res.text()
expect(JSON.parse(text)).toMatchObject([
{
ListId: 1,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayExplore',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: 7,
Accessibility: 1,
CreatedAt: '2025-04-23T18:27:03.2643786Z',
ItemIds: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
],
},
])
const body = await res.text()
// The reference's list ids are 64-bit: they must reach the client as an unquoted number
// with every digit intact, which parsing them here would round away (…307326 → …307300).
expect(body).toContain('"ListId":624765592684307326')
// Compared without the id: a literal here would round the same way the parse does, so
// the digits are checked on the raw body above and everything else on the object.
const { ListId: _id, ...rest } = JSON.parse(body) as Record<string, unknown>
expect(rest).toEqual({
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayExplore',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: 7,
ItemIds: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
],
Accessibility: 1,
CreatedAt: '2025-04-23T18:27:03.2643786Z',
})
})
it('serves the PlayLibrary page when the query names it', async () => {
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=Discovery.PageSource.PlayLibrary`
)
expect(res.status).toBe(200)
const text = await res.text()
expect(JSON.parse(text)).toMatchObject([
{
ListId: 2,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayLibrary',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: 7,
Accessibility: 1,
CreatedAt: '2025-04-23T18:25:31.5308539Z',
ItemIds: [
'Rooms_ContinuePlaying_PlayLibrary',
'Rooms_SavedForLater_PlayHighlight',
'Rooms_Favorites_PlayLibrary',
'Rooms_MyRooms_Play',
],
},
])
// The name is matched case-insensitively, and `type` is not filtered on — the request
// above asks for type 5 and gets the type 7 list, as the reference does.
const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`)
expect(await lower.text()).toBe(text)
})
it('gives each curated page a distinct, JS-safe ListId', async () => {
// The client caches a list against its ListId, so two pages must never share one — and
// an id past Number.MAX_SAFE_INTEGER would round on the way through JSON, which is why
// these are small numbers of our own rather than the reference's 64-bit ones.
const pages = ['Discovery.PageSource.PlayExplore', 'Discovery.PageSource.PlayLibrary']
const ids: number[] = []
for (const name of pages) {
const [list] = (await (
await SELF.fetch(`${ORIGIN}/curatedlists?name=${name}`)
).json()) as Array<{ ListId: number }>
expect(Number.isSafeInteger(list.ListId)).toBe(true)
ids.push(list.ListId)
it('serves every capture in static/curated-lists.json by name', async () => {
// One array holds every list, and each entry must be reachable by the keys the client
// asks with. `ImageName` must be a string even when empty — the client parses it into
// one — while `Description` may be null. The ids the client caches against have to be
// unique and reach it with their digits intact.
const names = [
'Discovery.PageSource.PlayExplore',
'Discovery.PageSource.PlayLibrary',
'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
]
const seen = new Set<string>()
for (const name of names) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`)
expect(res.status).toBe(200)
const body = await res.text()
// Never a quoted id: the client's field is a number.
expect(body).toMatch(/"ListId":\d+,/)
const list = JSON.parse(body) as {
ListId: number
Type: number
Name: string
ItemIds: string[]
Description: string | null
ImageName: string
}
expect(list.Name).toBe(name)
expect(list.Type).toBe(7)
expect(list.ItemIds.length).toBeGreaterThan(0)
expect(typeof list.ImageName).toBe('string')
const id = /"ListId":(\d+),/.exec(body)?.[1]
expect(seen.has(id!)).toBe(false)
seen.add(id!)
}
expect(new Set(ids).size).toBe(ids.length)
})
it('falls back to the Explore page for a name it has nothing for', async () => {
// An empty array renders as an empty Play page, so an unknown page source answers
// SOMETHING — which is also what the reference was observed doing for RoomGenreTags.
it('matches the name case-insensitively and prefers it over the type', async () => {
const canonical = await (
await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=Discovery.PageSource.PlayLibrary`
)
).text()
expect(canonical).toContain('"ListId":5321092632685904804')
expect(JSON.parse(canonical)).toMatchObject({ Name: 'Discovery.PageSource.PlayLibrary', Type: 7 })
// Casing reaching us is the client's, not ours.
const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`)
expect(await lower.text()).toBe(canonical)
// Every capture shares type 7, so the name is what tells them apart — a request naming
// Library must never come back with Explore's rows, and the type alone answers with the
// page default (the first list in the array).
const explore = await SELF.fetch(`${ORIGIN}/curatedlists?type=7`)
expect(((await explore.json()) as { Name: string }).Name).toBe('Discovery.PageSource.PlayExplore')
})
it('falls back to the default list for a name it has nothing under', async () => {
// The store's Featured page asks for its rows by the reference's numeric list id rather
// than by a name — nothing here is called that, and nothing is captured under its type
// either, so the default list answers. An empty body or a 404 would render as a blank
// page rather than an error.
const explore = await (
await SELF.fetch(`${ORIGIN}/curatedlists?name=Discovery.PageSource.PlayExplore`)
).text()
for (const query of ['?type=99&name=Nope', '?creatorAccountId=1&type=5&name=RoomGenreTags', '']) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
expect(res.status).toBe(200)
expect(await res.text()).toBe(explore)
for (const query of [
'?creatorAccountId=1&type=4&name=17859340',
'?type=99&name=Nope',
'?creatorAccountId=7&type=&name=',
'',
]) {
const bare = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
expect(bare.status).toBe(200)
expect(await bare.text()).toBe(explore)
}
})
+58
View File
@@ -0,0 +1,58 @@
[
{
"ListId": "624765592684307326",
"CreatorAccountId": 1,
"Name": "Discovery.PageSource.PlayExplore",
"Description": null,
"ImageName": "DefaultRoomImage.jpg",
"Type": 7,
"ItemIds": [
"Rooms_New_PlayHighlight_TabsTest_Explore",
"RoomCategories_MoodPlaylists_FeelingLucky",
"Rooms_RecentlyUpdated_TabsTest_Explore",
"Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore"
],
"Accessibility": 1,
"CreatedAt": "2025-04-23T18:27:03.2643786Z"
},
{
"ListId": "5321092632685904804",
"CreatorAccountId": 1,
"Name": "Discovery.PageSource.PlayLibrary",
"Description": null,
"ImageName": "DefaultRoomImage.jpg",
"Type": 7,
"ItemIds": [
"Rooms_ContinuePlaying_PlayLibrary",
"Rooms_SavedForLater_PlayHighlight",
"Rooms_Favorites_PlayLibrary",
"Rooms_MyRooms_Play"
],
"Accessibility": 1,
"CreatedAt": "2025-04-23T18:25:31.5308539Z"
},
{
"ListId": "8578579969342570774",
"CreatorAccountId": 1,
"Name": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky",
"Description": "I'm Feeling Lucky",
"ImageName": "DefaultRoomImage.jpg",
"Type": 7,
"ItemIds": [
"action_algorithmiclist_roomcategory",
"hangout_roomcategory_card",
"horror_roomcategory_card",
"pvp_roomcategory_card",
"quests_roomcategory_card",
"roleplay_roomcategory_card"
],
"Accessibility": 1,
"CreatedAt": "2024-05-22T05:37:43.7726633Z"
}
]