From d484292b0d53a0aee8fc0e5700d67bef176f8bf4 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Fri, 24 Oct 2025 12:58:42 +0000 Subject: [PATCH 01/16] feat: add Universes API client --- src/index.ts | 7 ++- src/resources/universes.ts | 112 +++++++++++++++++++++++++++++++++++++ src/types/universes.ts | 89 +++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src/resources/universes.ts create mode 100644 src/types/universes.ts diff --git a/src/index.ts b/src/index.ts index cd57e51..2373ffd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { HttpClient, HttpOptions } from "./http"; import { Groups } from "./resources/groups"; +import { Universes } from "./resources/universes"; import { Users } from "./resources/users"; import type { AuthConfig } from "./types/auth"; @@ -45,8 +46,9 @@ export interface OpenCloudConfig { * ``` */ export class OpenCloud { - public users!: Users; public groups!: Groups; + public universes!: Universes; + public users!: Users; private http!: HttpClient; /** @@ -77,8 +79,9 @@ export class OpenCloud { * @private */ private initializeResources(http: HttpClient): void { - this.users = new Users(http); this.groups = new Groups(http); + this.universes = new Universes(http); + this.users = new Users(http); } /** diff --git a/src/resources/universes.ts b/src/resources/universes.ts new file mode 100644 index 0000000..12951dd --- /dev/null +++ b/src/resources/universes.ts @@ -0,0 +1,112 @@ +import { HttpClient } from "../http"; +import { ListOptions } from "../types"; +import { + Universe, + UniverseBody, + UserRestrictionPage, +} from "../types/universes"; +import { buildFieldMask } from "../utils/fieldMask"; + +/** + * API client for Roblox universe endpoints. + * Provides methods to retrieve universe information, shouts, and universe memberships. + * + * @see https://create.roblox.com/docs/cloud/reference/Universe + */ +export class Universes { + /** + * Creates a new Universes API client. + * + * @param http - HTTP client for making API requests + */ + constructor(private http: HttpClient) {} + + /** + * Retrieves a universes information by universe ID. + * + * @param universeId - The unique universe ID (numeric string) + * @returns Promise resolving to the universes's data + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universe is not found or other API error occurs + * + * @example + * ```typescript + * const universe = await client.universes.get('123456789'); + * console.log(universe.displayName); // "Roblox" + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/universe#Cloud_Getuniverse + */ + async get(universeId: string): Promise { + return this.http.request(`/cloud/v2/universes/${universeId}`); + } + + /** + * Updates universe by universe ID. + * + * @param universeId - The unique universe ID (numeric string) + * @param body - The universe data to update + * @returns Promise resolving to the updated universe's data + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universe is not found or other API error occurs + * * @example + * ```typescript + * const updatedUniverse = await client.universes.update('123456789', { + * voiceChatEnabled: true, + * desktopEnabled: true + * }); + * console.log(updatedUniverse.voiceChatEnabled); // "true" + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/universe#Cloud_UpdateUniverse + */ + async update(universeId: string, body: UniverseBody): Promise { + const searchParams = new URLSearchParams(); + + const fieldMask = buildFieldMask(body); + searchParams.append("updateMask", fieldMask); + + return this.http.request(`/cloud/v2/universes/${universeId}`, { + method: "PATCH", + body: JSON.stringify(body), + searchParams, + }); + } + + /** + * List user restrictions for users that have ever been banned in either a universe or a specific place. + * + * @param universeId - The unique universe ID (numeric string) + * @param options - List options for pagination + * @param options.maxPageSize - Maximum items per page (default set by API) + * @param options.pageToken - Token from previous response for next page + * @returns Promise resolving to a page of user restrictions + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universe is not found or other API error occurs + * + * @example + * ```typescript + * const userRestrictions = await client.universes.listUserRestrictions('123456789'); + * console.log(userRestrictions); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_ListUserRestrictions__Using_Universes + */ + async listUserRestrictions( + universeId: string, + options: ListOptions = {}, + ): Promise { + const searchParams = new URLSearchParams(); + if (options.maxPageSize) + searchParams.append("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.append("pageToken", options.pageToken); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions`, + { + method: "GET", + searchParams, + }, + ); + } +} diff --git a/src/types/universes.ts b/src/types/universes.ts new file mode 100644 index 0000000..822c92e --- /dev/null +++ b/src/types/universes.ts @@ -0,0 +1,89 @@ +import { Page } from "./common"; + +export interface Universe { + path: string; + createTime: string; + updateTime: string; + displayName: string; + description: string; + owner: Owner; + visibility: Visibility; + facebookSocialLink: SocialLink; + twitterSocialLink: SocialLink; + youtubeSocialLink: SocialLink; + twitchSocialLink: SocialLink; + discordSocialLink: SocialLink; + robloxGroupSocialLink: SocialLink; + guildedSocialLink: SocialLink; + voiceChatEnabled: boolean; + ageRating: AgeRating; + privateServerPriceRobux: number; + desktopEnabled: boolean; + mobileEnabled: boolean; + tabletEnabled: boolean; + consoleEnabled: boolean; + vrEnabled: boolean; +} + +export interface UniverseBody { + facebookSocialLink?: SocialLink; + twitterSocialLink?: SocialLink; + youtubeSocialLink?: SocialLink; + twitchSocialLink?: SocialLink; + discordSocialLink?: SocialLink; + robloxGroupSocialLink?: SocialLink; + guildedSocialLink?: SocialLink; + voiceChatEnabled?: boolean; + privateServerPriceRobux?: number; + desktopEnabled?: boolean; + mobileEnabled?: boolean; + tabletEnabled?: boolean; + consoleEnabled?: boolean; + vrEnabled?: boolean; +} + +export interface SocialLink { + title: string; + uri: string; +} + +export interface SpeechAsset { + assetId: string; + remainingQuota: number; +} + +export interface SpeechAssetBody { + text: string; + speechStyle: { + voiceId: string; + pitch: number; + speed: number; + }; +} + +export interface GameJoinRestriction { + active: boolean; + startTime: string; + duration: string; + privateReason: string; + displayReason: string; + excludeAltAccounts: boolean; + inherited: boolean; +} + +export interface UserRestriction { + path: string; + updateTime: string; + user: string; + gameJoinRestriction: GameJoinRestriction; +} + +export type UserRestrictionPage = Page; +export type Owner = "user" | "group"; +export type Visibility = "VISIBILITY_UNSPECIFIED" | "PUBLIC" | "PRIVATE"; +export type AgeRating = + | "AGE_RATING_UNSPECIFIED" + | "AGE_RATING_ALL" + | "AGE_RATING_9_PLUS" + | "AGE_RATING_13_PLUS" + | "AGE_RATING_17_PLUS"; From b7acdbb6130315649f8a0cb1a330c288d7b59ff2 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Tue, 28 Oct 2025 14:02:11 +0000 Subject: [PATCH 02/16] feat: implement idempotency key generation --- src/utils/idempotency.ts | 19 +++++++++++++++ test/idempotency.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/utils/idempotency.ts create mode 100644 test/idempotency.test.ts diff --git a/src/utils/idempotency.ts b/src/utils/idempotency.ts new file mode 100644 index 0000000..efa8c97 --- /dev/null +++ b/src/utils/idempotency.ts @@ -0,0 +1,19 @@ +/** + * Generates a unique idempotency key using timestamp and random values. + * + * Format: `{timestamp}-{random1}-{random2}-{random3}-{random4}` + * Example: `1730073600000-a3f9-b2c8-d4e1-f5a7` + * + * @returns A unique idempotency key string + */ +export function generateIdempotencyKey(): string { + const timestamp = Date.now(); + + const segments: string[] = []; + for (let i = 0; i < 4; i++) { + const randomValue = Math.floor(Math.random() * 0x10000); + segments.push(randomValue.toString(16).padStart(4, "0")); + } + + return `${timestamp}-${segments.join("-")}`; +} diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 0000000..30e8481 --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { generateIdempotencyKey } from "../src/utils/idempotency"; + +describe("generateIdempotencyKey", () => { + it("should generate a unique key", () => { + const key1 = generateIdempotencyKey(); + const key2 = generateIdempotencyKey(); + + expect(key1).not.toBe(key2); + }); + + it("should return a string in the correct format", () => { + const key = generateIdempotencyKey(); + + // Format: {timestamp}-{hex1}-{hex2}-{hex3}-{hex4} + const parts = key.split("-"); + + expect(parts).toHaveLength(5); + + // First part should be a timestamp (numeric) + expect(Number(parts[0])).toBeGreaterThan(0); + expect(Number(parts[0])).toBeLessThanOrEqual(Date.now()); + + // Next 4 parts should be 4-character hex strings + for (let i = 1; i < 5; i++) { + expect(parts[i]).toMatch(/^[0-9a-f]{4}$/); + } + }); + + it("should generate multiple unique keys", () => { + const keys = new Set(); + const count = 1000; + + for (let i = 0; i < count; i++) { + keys.add(generateIdempotencyKey()); + } + + // All keys should be unique + expect(keys.size).toBe(count); + }); + + it("should include current timestamp", () => { + const beforeTimestamp = Date.now(); + const key = generateIdempotencyKey(); + const afterTimestamp = Date.now(); + + const keyTimestamp = Number(key.split("-")[0]); + + expect(keyTimestamp).toBeGreaterThanOrEqual(beforeTimestamp); + expect(keyTimestamp).toBeLessThanOrEqual(afterTimestamp); + }); +}); From 6b4a05faed54b349f88c7536a5e18920a22b6aca Mon Sep 17 00:00:00 2001 From: marinofranz Date: Tue, 28 Oct 2025 14:06:47 +0000 Subject: [PATCH 03/16] feat(universes): port UserRestriction endpoints from #3 - getUserRestriction - updateUserRestriction - updateUserRestriction - listUserRestrictionLogs - types moved to src/types/universes.ts Co-authored-by: Commonly <51011212+commonly-ts@users.noreply.github.com> --- src/resources/universes.ts | 130 +++++++++++++++++++++++++++++++++++++ src/types/universes.ts | 21 +++++- 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 12951dd..5608856 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -1,11 +1,15 @@ import { HttpClient } from "../http"; import { ListOptions } from "../types"; import { + GameJoinRestriction, Universe, UniverseBody, + UserRestriction, + UserRestrictionLogPage, UserRestrictionPage, } from "../types/universes"; import { buildFieldMask } from "../utils/fieldMask"; +import { generateIdempotencyKey } from "../utils/idempotency"; /** * API client for Roblox universe endpoints. @@ -109,4 +113,130 @@ export class Universes { }, ); } + + /** + * Get the user restriction. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID. (numeric string) + * @param userRestrictionId - The user ID. (numeric string) + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If an API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.users.getUserRestriction('123456789', '123456789'); + * console.log(userRestriction); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async getUserRestriction( + universeId: string, + userRestrictionId: string, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, + { + method: "GET", + }, + ); + } + + /** + * Update the user restriction. + * + * *Requires `universe.user-restriction:write` scope.* + * + * @param universeId - The universe ID (numeric string) + * @param userRestrictionId - The user ID (numeric string) + * @param body - The user restriction data to update + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If an API error occurs + * + * @example + * ```typescript + * const userRestrction = await client.users.updateUserRestriction('123456789', '123456789', { + * active: true, + * duration: "3s", + * privateReason: "some private reason", + * displayReason: "some display reason", + * excludeAltAccounts: true + * }); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async updateUserRestriction( + universeId: string, + userRestrictionId: string, + body: GameJoinRestriction, + ): Promise { + const searchParams = new URLSearchParams(); + + searchParams.append("updateMask", "game_join_restriction"); + + const idempotencyKey = generateIdempotencyKey(); + const firstSent = new Date().toISOString(); + + searchParams.append("idempotencyKey.key", idempotencyKey); + searchParams.append("idempotencyKey.firstSent", firstSent); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, + { + method: "PATCH", + body: JSON.stringify(body), + searchParams, + }, + ); + } + + /** + * List changes to UserRestriction resources within a given universe. This includes both universe-level and place-level restrictions. + * For universe-level restriction logs, the place field will be empty. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID (numeric string) + * @param options - List options including pagination and filtering + * @param options.maxPageSize - Maximum items per page (default set by API) + * @param options.pageToken - Token from previous response for next page + * @param options.filter - Filter expression (e.g., "user == 'users/123'" && "place == 'places/456'") + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.users.listUserRestrictions('123456789', { + * maxPageSize: 50, + * filter: `"user == 'users/123'" && "place == 'places/456'"` + * }); + * console.log(userRestriction); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async listUserRestrictionLogs( + universeId: string, + options: ListOptions & { filter?: string } = {}, + ): Promise { + const searchParams = new URLSearchParams(); + if (options.maxPageSize) + searchParams.set("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.set("pageToken", options.pageToken); + if (options.filter) searchParams.set("filter", options.filter); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions:listLogs`, + { + method: "GET", + searchParams, + }, + ); + } } diff --git a/src/types/universes.ts b/src/types/universes.ts index 822c92e..bfa023f 100644 --- a/src/types/universes.ts +++ b/src/types/universes.ts @@ -63,11 +63,14 @@ export interface SpeechAssetBody { export interface GameJoinRestriction { active: boolean; - startTime: string; duration: string; privateReason: string; displayReason: string; excludeAltAccounts: boolean; +} + +export interface GameJoinRestrictionResponse extends GameJoinRestriction { + startTime: string; inherited: boolean; } @@ -75,10 +78,24 @@ export interface UserRestriction { path: string; updateTime: string; user: string; - gameJoinRestriction: GameJoinRestriction; + gameJoinRestriction: GameJoinRestrictionResponse; +} + +export interface UserRestrictionLog extends GameJoinRestriction { + user: string; + place: string; + moderator: { + robloxUser: string; + }; + createTime: string; + startTime: string; + restrictionType: { + gameJoinRestriction: object; + }; } export type UserRestrictionPage = Page; +export type UserRestrictionLogPage = Page; export type Owner = "user" | "group"; export type Visibility = "VISIBILITY_UNSPECIFIED" | "PUBLIC" | "PRIVATE"; export type AgeRating = From a52d9f9aa345a491b23975bdc85ece2fe04763c7 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 16:50:56 +0000 Subject: [PATCH 04/16] feat(universes): add speech asset generation, publishing, and translation methods --- src/resources/universes.ts | 46 ++++++++++++++++++++++++++++++++++++++ src/types/universes.ts | 46 +++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 5608856..6b4f82a 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -2,6 +2,12 @@ import { HttpClient } from "../http"; import { ListOptions } from "../types"; import { GameJoinRestriction, + PublishMessageBody, + SpeechAssetBody, + SpeechAssetOperation, + SpeechAssetResponse, + TranslateTextBody, + TranslateTextResponse, Universe, UniverseBody, UserRestriction, @@ -239,4 +245,44 @@ export class Universes { }, ); } + + async generateSpeechAsset(universeId: string, body: SpeechAssetBody) { + const speechOperation = await this.http.request( + `/cloud/v2/universes/${universeId}:generateSpeechAsset`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + + return this.http.request( + `/assets/v1/${speechOperation.path}`, + ); + } + + async publishMessage( + universeId: string, + body: PublishMessageBody, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}:publishMessage`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + } + + async translateText( + universeId: string, + body: TranslateTextBody, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}:translateText`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + } } diff --git a/src/types/universes.ts b/src/types/universes.ts index bfa023f..8f10dcc 100644 --- a/src/types/universes.ts +++ b/src/types/universes.ts @@ -47,9 +47,9 @@ export interface SocialLink { uri: string; } -export interface SpeechAsset { - assetId: string; - remainingQuota: number; +export interface SpeechAssetOperation { + path: string; + done: boolean; } export interface SpeechAssetBody { @@ -61,6 +61,29 @@ export interface SpeechAssetBody { }; } +export interface SpeechAssetResponse extends SpeechAssetOperation { + operationId: string; + response: { + path: string; + revisionId: string; + revisionCreateTime: string; + assetId: string; + displayName: string; + description: string; + assetType: string; + creationContext: { + creator: { + userId?: string; + groupId?: string; + }; + }; + moderationResult: { + moderationState: "Reviewing" | "Rejected" | "Approved"; + }; + state: string; + }; +} + export interface GameJoinRestriction { active: boolean; duration: string; @@ -94,6 +117,22 @@ export interface UserRestrictionLog extends GameJoinRestriction { }; } +export interface PublishMessageBody { + topic: string; + message: string; +} + +export interface TranslateTextBody { + text: string; + sourceLanguageCode: LanguageCode; + targetLanguageCodes: LanguageCode[]; +} + +export interface TranslateTextResponse { + sourceLanguageCode: string; + translations: Record; +} + export type UserRestrictionPage = Page; export type UserRestrictionLogPage = Page; export type Owner = "user" | "group"; @@ -104,3 +143,4 @@ export type AgeRating = | "AGE_RATING_9_PLUS" | "AGE_RATING_13_PLUS" | "AGE_RATING_17_PLUS"; +export type LanguageCode = ""; From cde8268a30a324235765b444d698e339049bc3cd Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 16:56:26 +0000 Subject: [PATCH 05/16] refactor(universes): update import path for universe types --- src/resources/universes.ts | 2 +- src/types/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 6b4f82a..93dd066 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -13,7 +13,7 @@ import { UserRestriction, UserRestrictionLogPage, UserRestrictionPage, -} from "../types/universes"; +} from "../types"; import { buildFieldMask } from "../utils/fieldMask"; import { generateIdempotencyKey } from "../utils/idempotency"; diff --git a/src/types/index.ts b/src/types/index.ts index 404c2af..8154dfa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,3 +2,4 @@ export * from "./auth"; export * from "./common"; export * from "./users"; export * from "./groups"; +export * from "./universes"; From 931fd78ac8f35888e2c254098df450e27c9b42b0 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 16:58:36 +0000 Subject: [PATCH 06/16] test: add unit tests for Universes API --- test/universes.test.ts | 514 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 test/universes.test.ts diff --git a/test/universes.test.ts b/test/universes.test.ts new file mode 100644 index 0000000..1ffa60a --- /dev/null +++ b/test/universes.test.ts @@ -0,0 +1,514 @@ +import { describe, it, expect } from "vitest"; +import { OpenCloud } from "../src"; +import { makeFetchMock } from "./_utils"; +import type { + Universe, + UserRestrictionPage, + UserRestriction, + UserRestrictionLogPage, + GameJoinRestriction, + SpeechAssetBody, + SpeechAssetOperation, + SpeechAssetResponse, + PublishMessageBody, + TranslateTextBody, + TranslateTextResponse, +} from "../src/types"; + +const baseUrl = "https://apis.roblox.com"; + +describe("Universes", () => { + it("GET /universes/{id}", async () => { + const mockUniverse: Universe = { + path: "universes/123456789", + createTime: "2020-01-15T10:30:00.000Z", + updateTime: "2024-10-15T12:00:00.000Z", + displayName: "Test Universe", + description: "This is a test universe for unit testing", + owner: "user", + visibility: "PUBLIC", + facebookSocialLink: { + title: "Facebook", + uri: "https://facebook.com/test", + }, + twitterSocialLink: { title: "Twitter", uri: "https://twitter.com/test" }, + youtubeSocialLink: { title: "YouTube", uri: "https://youtube.com/test" }, + twitchSocialLink: { title: "Twitch", uri: "https://twitch.tv/test" }, + discordSocialLink: { title: "Discord", uri: "https://discord.gg/test" }, + robloxGroupSocialLink: { + title: "Group", + uri: "https://roblox.com/groups/123", + }, + guildedSocialLink: { title: "Guilded", uri: "https://guilded.gg/test" }, + voiceChatEnabled: true, + ageRating: "AGE_RATING_13_PLUS", + privateServerPriceRobux: 100, + desktopEnabled: true, + mobileEnabled: true, + tabletEnabled: true, + consoleEnabled: false, + vrEnabled: false, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUniverse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.get("123456789"); + + expect(result.displayName).toBe("Test Universe"); + expect(result.voiceChatEnabled).toBe(true); + expect(result.desktopEnabled).toBe(true); + expect(result.privateServerPriceRobux).toBe(100); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789`, + ); + }); + + it("PATCH /universes/{id}", async () => { + const mockUniverse: Universe = { + path: "universes/123456789", + createTime: "2020-01-15T10:30:00.000Z", + updateTime: "2024-10-15T12:00:00.000Z", + displayName: "Test Universe", + description: "Updated description", + owner: "user", + visibility: "PUBLIC", + facebookSocialLink: { title: "", uri: "" }, + twitterSocialLink: { title: "", uri: "" }, + youtubeSocialLink: { title: "", uri: "" }, + twitchSocialLink: { title: "", uri: "" }, + discordSocialLink: { title: "", uri: "" }, + robloxGroupSocialLink: { title: "", uri: "" }, + guildedSocialLink: { title: "", uri: "" }, + voiceChatEnabled: false, + ageRating: "AGE_RATING_13_PLUS", + privateServerPriceRobux: 200, + desktopEnabled: false, + mobileEnabled: true, + tabletEnabled: true, + consoleEnabled: true, + vrEnabled: true, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUniverse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.update("123456789", { + voiceChatEnabled: false, + desktopEnabled: false, + privateServerPriceRobux: 200, + consoleEnabled: true, + vrEnabled: true, + }); + + expect(result.voiceChatEnabled).toBe(false); + expect(result.desktopEnabled).toBe(false); + expect(result.privateServerPriceRobux).toBe(200); + expect(result.consoleEnabled).toBe(true); + expect(result.vrEnabled).toBe(true); + + const url = calls[0]?.url.toString(); + expect(url).toContain(`${baseUrl}/cloud/v2/universes/123456789`); + expect(url).toContain("updateMask="); + expect(calls[0]?.init?.method).toBe("PATCH"); + }); + + it("GET /universes/{id}/user-restrictions without options", async () => { + const mockUserRestrictions: UserRestrictionPage = { + userRestrictions: [ + { + path: "universes/123456789/user-restrictions/111111111", + updateTime: "2024-10-10T08:00:00.000Z", + user: "users/111111111", + gameJoinRestriction: { + active: true, + duration: "3600s", + privateReason: "Cheating detected", + displayReason: "Violation of terms", + excludeAltAccounts: true, + startTime: "2024-10-10T08:00:00.000Z", + inherited: false, + }, + }, + { + path: "universes/123456789/user-restrictions/222222222", + updateTime: "2024-10-11T09:15:00.000Z", + user: "users/222222222", + gameJoinRestriction: { + active: false, + duration: "7200s", + privateReason: "Harassment", + displayReason: "Community guidelines violation", + excludeAltAccounts: false, + startTime: "2024-10-11T09:15:00.000Z", + inherited: true, + }, + }, + ], + nextPageToken: "restriction-token-123", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestrictions }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.listUserRestrictions("123456789"); + + expect(result.userRestrictions).toHaveLength(2); + expect(result.userRestrictions[0]?.user).toBe("users/111111111"); + expect(result.userRestrictions[0]?.gameJoinRestriction.active).toBe(true); + expect(result.nextPageToken).toBe("restriction-token-123"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions`, + ); + }); + + it("GET /universes/{id}/user-restrictions with pagination", async () => { + const mockUserRestrictions: UserRestrictionPage = { + userRestrictions: [ + { + path: "universes/123456789/user-restrictions/333333333", + updateTime: "2024-10-12T10:30:00.000Z", + user: "users/333333333", + gameJoinRestriction: { + active: true, + duration: "1800s", + privateReason: "Spam", + displayReason: "Spamming", + excludeAltAccounts: true, + startTime: "2024-10-12T10:30:00.000Z", + inherited: false, + }, + }, + ], + nextPageToken: "restriction-token-456", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestrictions }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.listUserRestrictions("123456789", { + maxPageSize: 50, + pageToken: "previous-token", + }); + + expect(result.userRestrictions).toHaveLength(1); + const url = calls[0]?.url.toString(); + expect(url).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions?maxPageSize=50&pageToken=previous-token`, + ); + }); + + it("GET /universes/{id}/user-restrictions/{userRestrictionId}", async () => { + const mockUserRestriction: UserRestriction = { + path: "universes/123456789/user-restrictions/111111111", + updateTime: "2024-10-10T08:00:00.000Z", + user: "users/111111111", + gameJoinRestriction: { + active: true, + duration: "3600s", + privateReason: "Cheating detected", + displayReason: "Violation of terms", + excludeAltAccounts: true, + startTime: "2024-10-10T08:00:00.000Z", + inherited: false, + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestriction }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.getUserRestriction( + "123456789", + "111111111", + ); + + expect(result.user).toBe("users/111111111"); + expect(result.gameJoinRestriction.active).toBe(true); + expect(result.gameJoinRestriction.duration).toBe("3600s"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions/111111111`, + ); + }); + + it("PATCH /universes/{id}/user-restrictions/{userRestrictionId}", async () => { + const mockUserRestriction: UserRestriction = { + path: "universes/123456789/user-restrictions/111111111", + updateTime: "2024-10-22T15:00:00.000Z", + user: "users/111111111", + gameJoinRestriction: { + active: true, + duration: "7200s", + privateReason: "Updated reason", + displayReason: "Updated display reason", + excludeAltAccounts: false, + startTime: "2024-10-22T15:00:00.000Z", + inherited: false, + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestriction }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: GameJoinRestriction = { + active: true, + duration: "7200s", + privateReason: "Updated reason", + displayReason: "Updated display reason", + excludeAltAccounts: false, + }; + + const result = await openCloud.universes.updateUserRestriction( + "123456789", + "111111111", + body, + ); + + expect(result.user).toBe("users/111111111"); + expect(result.gameJoinRestriction.active).toBe(true); + expect(result.gameJoinRestriction.duration).toBe("7200s"); + + const url = calls[0]?.url.toString(); + expect(url).toContain( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions/111111111`, + ); + expect(url).toContain("updateMask=game_join_restriction"); + expect(url).toContain("idempotencyKey.key="); + expect(url).toContain("idempotencyKey.firstSent="); + expect(calls[0]?.init?.method).toBe("PATCH"); + }); + + it("GET /universes/{id}/user-restrictions:listLogs without options", async () => { + const mockLogs: UserRestrictionLogPage = { + logs: [ + { + user: "users/111111111", + place: "places/456789", + moderator: { + robloxUser: "users/999999999", + }, + createTime: "2024-10-10T08:00:00.000Z", + startTime: "2024-10-10T08:00:00.000Z", + active: true, + duration: "3600s", + privateReason: "Cheating", + displayReason: "Terms violation", + excludeAltAccounts: true, + restrictionType: { + gameJoinRestriction: {}, + }, + }, + ], + nextPageToken: "log-token-123", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockLogs }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = + await openCloud.universes.listUserRestrictionLogs("123456789"); + + expect(result.logs).toHaveLength(1); + expect(result.logs[0]?.user).toBe("users/111111111"); + expect(result.nextPageToken).toBe("log-token-123"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions:listLogs`, + ); + }); + + it("GET /universes/{id}/user-restrictions:listLogs with all options", async () => { + const mockLogs: UserRestrictionLogPage = { + logs: [], + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockLogs }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.listUserRestrictionLogs( + "123456789", + { + maxPageSize: 25, + pageToken: "token-abc", + filter: "user == 'users/123' && place == 'places/456'", + }, + ); + + expect(result.logs).toHaveLength(0); + const url = calls[0]?.url.toString(); + expect(url).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions:listLogs?maxPageSize=25&pageToken=token-abc&filter=user+%3D%3D+%27users%2F123%27+%26%26+place+%3D%3D+%27places%2F456%27`, + ); + }); + + it("POST /universes/{id}:generateSpeechAsset", async () => { + const mockOperation: SpeechAssetOperation = { + path: "operations/speech-123", + done: true, + }; + + const mockResponse: SpeechAssetResponse = { + path: "operations/speech-123", + done: true, + operationId: "speech-123", + response: { + path: "assets/12345", + revisionId: "1", + revisionCreateTime: "2024-10-22T15:00:00.000Z", + assetId: "12345", + displayName: "Generated Speech", + description: "AI generated speech", + assetType: "Audio", + creationContext: { + creator: { + userId: "123456789", + }, + }, + moderationResult: { + moderationState: "Approved", + }, + state: "Active", + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockOperation }, + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: SpeechAssetBody = { + text: "Hello, world!", + speechStyle: { + voiceId: "en_us_001", + pitch: 1.0, + speed: 1.0, + }, + }; + + const result = await openCloud.universes.generateSpeechAsset( + "123456789", + body, + ); + + expect(result.operationId).toBe("speech-123"); + expect(result.response.assetId).toBe("12345"); + expect(result.response.moderationResult.moderationState).toBe("Approved"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:generateSpeechAsset`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[1]?.url.toString()).toBe( + `${baseUrl}/assets/v1/operations/speech-123`, + ); + }); + + it("POST /universes/{id}:publishMessage", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: undefined }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: PublishMessageBody = { + topic: "game-updates", + message: "Server maintenance at 3 PM", + }; + + await openCloud.universes.publishMessage("123456789", body); + + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:publishMessage`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[0]?.init?.body).toContain("game-updates"); + expect(calls[0]?.init?.body).toContain("Server maintenance at 3 PM"); + }); + + it("POST /universes/{id}:translateText", async () => { + const mockResponse: TranslateTextResponse = { + sourceLanguageCode: "en", + translations: { + "": "Hola mundo", + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: TranslateTextBody = { + text: "Hello world", + sourceLanguageCode: "", + targetLanguageCodes: [""], + }; + + const result = await openCloud.universes.translateText("123456789", body); + + expect(result.sourceLanguageCode).toBe("en"); + expect(result.translations[""]).toBe("Hola mundo"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:translateText`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[0]?.init?.body).toContain("Hello world"); + }); +}); From c96fc5685a84ed7655489ca842365ef1026fca5a Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:07:25 +0000 Subject: [PATCH 07/16] docs: add Universes resource documentation --- src/docs/.vitepress/config.ts | 1 + src/docs/guide/resources/universes.md | 185 ++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/docs/guide/resources/universes.md diff --git a/src/docs/.vitepress/config.ts b/src/docs/.vitepress/config.ts index 93b0895..f1302e3 100644 --- a/src/docs/.vitepress/config.ts +++ b/src/docs/.vitepress/config.ts @@ -38,6 +38,7 @@ export default defineConfig({ items: [ { text: "Users", link: "/guide/resources/users" }, { text: "Groups", link: "/guide/resources/groups" }, + { text: "Universes", link: "/guide/resources/universes" }, ] } ], diff --git a/src/docs/guide/resources/universes.md b/src/docs/guide/resources/universes.md new file mode 100644 index 0000000..81772ab --- /dev/null +++ b/src/docs/guide/resources/universes.md @@ -0,0 +1,185 @@ +# Universes Resource + +The Universes resource provides methods to interact with Roblox universe (game) data, including settings, user restrictions, messaging, translation, and speech generation. + +## Getting Universe Information + +Retrieve universe information by universe ID: + +```typescript +const universe = await client.universes.get("123456789"); + +console.log(universe.displayName); +console.log(universe.voiceChatEnabled); +console.log(universe.desktopEnabled); +console.log(universe.privateServerPriceRobux); +``` + +## Updating Universe Settings + +Update platform availability and other settings: + +```typescript +await client.universes.update("123456789", { + voiceChatEnabled: true, + desktopEnabled: true, + mobileEnabled: true, + privateServerPriceRobux: 100 +}); +``` + +## Managing User Restrictions + +### Listing User Restrictions + +Get all banned users in your universe: + +```typescript +const restrictions = await client.universes.listUserRestrictions("123456789"); + +for (const restriction of restrictions.userRestrictions) { + console.log(restriction.user); + console.log(restriction.gameJoinRestriction.active); + console.log(restriction.gameJoinRestriction.displayReason); +} +``` + +### Getting a User Restriction + +Check restriction details for a specific user: + +```typescript +const restriction = await client.universes.getUserRestriction( + "123456789", // Universe ID + "987654321" // User ID +); + +console.log(restriction.gameJoinRestriction.duration); +console.log(restriction.gameJoinRestriction.displayReason); +``` + +### Banning a User + +Apply a temporary or permanent ban: + +```typescript +await client.universes.updateUserRestriction( + "123456789", + "987654321", + { + active: true, + duration: "86400s", // 24 hours in seconds + privateReason: "Cheating detected", + displayReason: "Violation of game rules", + excludeAltAccounts: true + } +); +``` + +### Unbanning a User + +Remove a user restriction: + +```typescript +await client.universes.updateUserRestriction( + "123456789", + "987654321", + { + active: false, + duration: "0s", + privateReason: "Ban appeal approved", + displayReason: "Restriction lifted", + excludeAltAccounts: false + } +); +``` + +### Viewing Restriction Logs + +Track all restriction changes: + +```typescript +const logs = await client.universes.listUserRestrictionLogs("123456789"); + +for (const log of logs.logs) { + console.log(`User: ${log.user}`); + console.log(`Moderator: ${log.moderator.robloxUser}`); + console.log(`Reason: ${log.displayReason}`); +} +``` + +Filter logs by user or place: + +```typescript +const userLogs = await client.universes.listUserRestrictionLogs("123456789", { + filter: "user == 'users/987654321'" +}); +``` + +## Publishing Messages + +Send messages to subscribed game servers: + +```typescript +await client.universes.publishMessage("123456789", { + topic: "server-announcements", + message: JSON.stringify({ + type: "maintenance", + scheduledTime: "2024-11-15T03:00:00Z" + }) +}); +``` + +::: tip +Use MessagingService in your game to subscribe to topics and receive these messages. +::: + +## Translating Text + +Translate strings into multiple languages: + +```typescript +const translation = await client.universes.translateText("123456789", { + text: "Welcome to the game!", + sourceLanguageCode: "", + targetLanguageCodes: [""] +}); + +console.log(translation.translations); +``` + +## Generating Speech Assets + +Create AI-generated speech from text: + +```typescript +const speechAsset = await client.universes.generateSpeechAsset("123456789", { + text: "Welcome to the game!", + speechStyle: { + voiceId: "en_us_001", + pitch: 1.0, + speed: 1.0 + } +}); + +if (speechAsset.response.moderationResult.moderationState === "Approved") { + console.log(`Asset ID: ${speechAsset.response.assetId}`); +} +``` + +Customize voice characteristics: + +```typescript +const deepVoice = await client.universes.generateSpeechAsset("123456789", { + text: "This is a deep voice", + speechStyle: { + voiceId: "en_us_001", + pitch: 0.7, // Lower pitch + speed: 0.8 // Slower speed + } +}); +``` + +::: warning +Generated speech assets require moderation approval before use. Check the `moderationState` field. +::: From 40525ac63533c7da6474d020b18b06f573ae0fd8 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:24:46 +0000 Subject: [PATCH 08/16] feat(universes): add restart universe servers endpoint --- src/resources/universes.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 93dd066..525d933 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -273,6 +273,31 @@ export class Universes { ); } + /** + * Restarts all active servers for a specific universe if and only if a new version of the experience has been published. + * Used for releasing experience updates. + * + * @param universeId The universe ID (numeric string) + * @returns Promise resolving to void + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * await client.universes.restartUniverseServers('123456789'); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_RestartUniverseServers + */ + async restartUniverseServers(universeId: string): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}:restartServers`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); + } async translateText( universeId: string, body: TranslateTextBody, From f9bd3f68be9b974586cddd027667649ed482d8ee Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:25:23 +0000 Subject: [PATCH 09/16] docs: add jsdoc comments to new endpoints --- src/resources/universes.ts | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 525d933..bdbb3d9 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -246,6 +246,32 @@ export class Universes { ); } + /** + * Generates an English speech audio asset from the specified text. + * + * This endpoint requires the `asset:read` and `asset:write` scopes in addition to the `universe:write` scope. + * + * @param universeId - The universe ID (numeric string) + * @param body - The speech asset generation request body + * @returns Promise resolving to the speech asset response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const speechAsset = await client.universes.generateSpeechAsset('123456789', { + * text: "Hello, world!", + * speechStyle: { + * voiceId: "rbx_voice_001", + * pitch: 1, + * speed: 1 + * } + * }); + * console.log(speechAsset); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_GenerateSpeechAsset + */ async generateSpeechAsset(universeId: string, body: SpeechAssetBody) { const speechOperation = await this.http.request( `/cloud/v2/universes/${universeId}:generateSpeechAsset`, @@ -260,6 +286,26 @@ export class Universes { ); } + /** + * Publishes a message to the universe's live servers. + * Servers can consume messages via MessagingService. + * + * @param universeId The universe ID (numeric string) + * @param body The publish message request body + * @returns Promise resolving to void + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * await client.universes.publishMessage('123456789', { + * topic: "UpdateAvailable", + * message: "New update has been deployed!" + * }); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_PublishUniverseMessage + */ async publishMessage( universeId: string, body: PublishMessageBody, @@ -298,6 +344,48 @@ export class Universes { }, ); } + + /** + * Translates the provided text from one language to another. + * + * The language codes are represented as IETF BCP-47 language tags. The currently supported language codes are: + * - English (en-us) + * - French (fr-fr) + * - Vietnamese (vi-vn) + * - Thai (th-th) + * - Turkish (tr-tr) + * - Russian (ru-ru) + * - Spanish (es-es) + * - Portuguese (pt-br) + * - Korean (ko-kr) + * - Japanese (ja-jp) + * - Chinese Simplified (zh-cn) + * - Chinese Traditional (zh-tw) + * - German (de-de) + * - Polish (pl-pl) + * - Italian (it-it) + * - Indonesian (id-id). + * + * If a source language code is not provided, the API will attempt to auto-detect the source language. + * + * @param universeId The universe ID (numeric string) + * @param body The translate text request body + * @returns Promise resolving to the text translation response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const translation = await client.universes.translateText('123456789', { + * text: "Hello, world!", + * sourceLanguageCode: "en-us", + * targetLanguageCodes: ["fr-fr", "ja-jp"] + * }); + * console.log(translation); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_TranslateText + */ async translateText( universeId: string, body: TranslateTextBody, From 65fc57627709d8f6760b33373e6322fd57784ebf Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:28:32 +0000 Subject: [PATCH 10/16] docs: remove redundant descriptions from API client documentation --- src/resources/groups.ts | 1 - src/resources/universes.ts | 14 +++++++------- src/resources/users.ts | 1 - 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/resources/groups.ts b/src/resources/groups.ts index 858c496..0ff0c29 100644 --- a/src/resources/groups.ts +++ b/src/resources/groups.ts @@ -12,7 +12,6 @@ import type { /** * API client for Roblox Group endpoints. - * Provides methods to retrieve group information, shouts, and group memberships. * * @see https://create.roblox.com/docs/cloud/reference/Group */ diff --git a/src/resources/universes.ts b/src/resources/universes.ts index bdbb3d9..22829d8 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -19,7 +19,6 @@ import { generateIdempotencyKey } from "../utils/idempotency"; /** * API client for Roblox universe endpoints. - * Provides methods to retrieve universe information, shouts, and universe memberships. * * @see https://create.roblox.com/docs/cloud/reference/Universe */ @@ -32,10 +31,10 @@ export class Universes { constructor(private http: HttpClient) {} /** - * Retrieves a universes information by universe ID. + * Retrieves a universe's information by universe ID. * * @param universeId - The unique universe ID (numeric string) - * @returns Promise resolving to the universes's data + * @returns Promise resolving to the universe's data * @throws {AuthError} If API key is invalid * @throws {OpenCloudError} If the universe is not found or other API error occurs * @@ -59,7 +58,8 @@ export class Universes { * @returns Promise resolving to the updated universe's data * @throws {AuthError} If API key is invalid * @throws {OpenCloudError} If the universe is not found or other API error occurs - * * @example + * + * @example * ```typescript * const updatedUniverse = await client.universes.update('123456789', { * voiceChatEnabled: true, @@ -133,7 +133,7 @@ export class Universes { * * @example * ```typescript - * const userRestriction = await client.users.getUserRestriction('123456789', '123456789'); + * const userRestriction = await client.universes.getUserRestriction('123456789', '123456789'); * console.log(userRestriction); * ``` * @@ -165,7 +165,7 @@ export class Universes { * * @example * ```typescript - * const userRestrction = await client.users.updateUserRestriction('123456789', '123456789', { + * const userRestriction = await client.universes.updateUserRestriction('123456789', '123456789', { * active: true, * duration: "3s", * privateReason: "some private reason", @@ -218,7 +218,7 @@ export class Universes { * * @example * ```typescript - * const userRestriction = await client.users.listUserRestrictions('123456789', { + * const userRestriction = await client.universes.listUserRestrictions('123456789', { * maxPageSize: 50, * filter: `"user == 'users/123'" && "place == 'places/456'"` * }); diff --git a/src/resources/users.ts b/src/resources/users.ts index e29bea7..97630c1 100644 --- a/src/resources/users.ts +++ b/src/resources/users.ts @@ -12,7 +12,6 @@ import type { /** * API client for Roblox Users endpoints. - * Provides methods to retrieve user information, inventory, and asset quotas. * * @see https://create.roblox.com/docs/cloud/reference/User */ From f742eb8d18e858917ced5d8173d5180755f17910 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:29:44 +0000 Subject: [PATCH 11/16] feat(universes): export Universes resource from index --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 2373ffd..24d4b7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,3 +129,4 @@ export type { HttpOptions } from "./http"; export { Users } from "./resources/users"; export { Groups } from "./resources/groups"; +export { Universes } from "./resources/universes"; From 9ecd45363616b071c6dc41de3667308da1f2c641 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:30:26 +0000 Subject: [PATCH 12/16] test: add unit test for restart universe servers endpoint --- test/universes.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/universes.test.ts b/test/universes.test.ts index 1ffa60a..1a0c4d5 100644 --- a/test/universes.test.ts +++ b/test/universes.test.ts @@ -478,6 +478,25 @@ describe("Universes", () => { expect(calls[0]?.init?.body).toContain("Server maintenance at 3 PM"); }); + it("POST /universes/{id}:restartServers", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: undefined }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + await openCloud.universes.restartUniverseServers("123456789"); + + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:restartServers`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[0]?.init?.body).toBe("{}"); + }); + it("POST /universes/{id}:translateText", async () => { const mockResponse: TranslateTextResponse = { sourceLanguageCode: "en", From dcca2b1d25a3258d2b60811558c39ed43c1b806e Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:42:18 +0000 Subject: [PATCH 13/16] fix(universes): replace append with set for URLSearchParams --- src/resources/universes.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 22829d8..4093936 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -74,7 +74,7 @@ export class Universes { const searchParams = new URLSearchParams(); const fieldMask = buildFieldMask(body); - searchParams.append("updateMask", fieldMask); + searchParams.set("updateMask", fieldMask); return this.http.request(`/cloud/v2/universes/${universeId}`, { method: "PATCH", @@ -108,8 +108,8 @@ export class Universes { ): Promise { const searchParams = new URLSearchParams(); if (options.maxPageSize) - searchParams.append("maxPageSize", options.maxPageSize.toString()); - if (options.pageToken) searchParams.append("pageToken", options.pageToken); + searchParams.set("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.set("pageToken", options.pageToken); return this.http.request( `/cloud/v2/universes/${universeId}/user-restrictions`, @@ -183,13 +183,13 @@ export class Universes { ): Promise { const searchParams = new URLSearchParams(); - searchParams.append("updateMask", "game_join_restriction"); + searchParams.set("updateMask", "game_join_restriction"); const idempotencyKey = generateIdempotencyKey(); const firstSent = new Date().toISOString(); - searchParams.append("idempotencyKey.key", idempotencyKey); - searchParams.append("idempotencyKey.firstSent", firstSent); + searchParams.set("idempotencyKey.key", idempotencyKey); + searchParams.set("idempotencyKey.firstSent", firstSent); return this.http.request( `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, From 9a082461505a91d4882eff573003ae985e205b17 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:52:10 +0000 Subject: [PATCH 14/16] fix(universes): update translations to use Partial and correct language codes --- src/types/universes.ts | 21 +++++++++++++++++++-- test/universes.test.ts | 4 ++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/types/universes.ts b/src/types/universes.ts index 8f10dcc..b27614e 100644 --- a/src/types/universes.ts +++ b/src/types/universes.ts @@ -130,7 +130,7 @@ export interface TranslateTextBody { export interface TranslateTextResponse { sourceLanguageCode: string; - translations: Record; + translations: Partial>; } export type UserRestrictionPage = Page; @@ -143,4 +143,21 @@ export type AgeRating = | "AGE_RATING_9_PLUS" | "AGE_RATING_13_PLUS" | "AGE_RATING_17_PLUS"; -export type LanguageCode = ""; +export type LanguageCode = + | "en-us" + | "fr-fr" + | "vi-vn" + | "th-th" + | "tr-tr" + | "ru-ru" + | "es-es" + | "pt-br" + | "ko-kr" + | "ja-jp" + | "zh-cn" + | "zh-tw" + | "de-de" + | "pl-pl" + | "it-it" + | "id-id" + | ""; diff --git a/test/universes.test.ts b/test/universes.test.ts index 1a0c4d5..85dc70c 100644 --- a/test/universes.test.ts +++ b/test/universes.test.ts @@ -499,9 +499,9 @@ describe("Universes", () => { it("POST /universes/{id}:translateText", async () => { const mockResponse: TranslateTextResponse = { - sourceLanguageCode: "en", + sourceLanguageCode: "en-us", translations: { - "": "Hola mundo", + "es-es": "Hola mundo", }, }; From 73dcd62f796aadb45333efe2382b9e2849a12637 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:53:11 +0000 Subject: [PATCH 15/16] docs: update guide & jsdoc comments with correct links and references --- src/docs/guide/resources/universes.md | 7 ++++--- src/resources/universes.ts | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/docs/guide/resources/universes.md b/src/docs/guide/resources/universes.md index 81772ab..c1f4ea4 100644 --- a/src/docs/guide/resources/universes.md +++ b/src/docs/guide/resources/universes.md @@ -141,11 +141,12 @@ Translate strings into multiple languages: ```typescript const translation = await client.universes.translateText("123456789", { text: "Welcome to the game!", - sourceLanguageCode: "", - targetLanguageCodes: [""] + sourceLanguageCode: "en-us", + targetLanguageCodes: ["es-es", "fr-fr"] }); -console.log(translation.translations); +console.log(translation.translations["es-es"]); +console.log(translation.translations["fr-fr"]); ``` ## Generating Speech Assets diff --git a/src/resources/universes.ts b/src/resources/universes.ts index 4093936..dd67e52 100644 --- a/src/resources/universes.ts +++ b/src/resources/universes.ts @@ -65,7 +65,7 @@ export class Universes { * voiceChatEnabled: true, * desktopEnabled: true * }); - * console.log(updatedUniverse.voiceChatEnabled); // "true" + * console.log(updatedUniverse.voiceChatEnabled); // true * ``` * * @see https://create.roblox.com/docs/cloud/reference/universe#Cloud_UpdateUniverse @@ -137,7 +137,7 @@ export class Universes { * console.log(userRestriction); * ``` * - * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_GetUserRestriction__Using_Universes */ async getUserRestriction( universeId: string, @@ -218,14 +218,14 @@ export class Universes { * * @example * ```typescript - * const userRestriction = await client.universes.listUserRestrictions('123456789', { + * const userRestrictionLogs = await client.universes.listUserRestrictionLogs('123456789', { * maxPageSize: 50, * filter: `"user == 'users/123'" && "place == 'places/456'"` * }); - * console.log(userRestriction); + * console.log(userRestrictionLogs); * ``` * - * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_ListUserRestrictionLogs */ async listUserRestrictionLogs( universeId: string, From ffa7f014aa111a95353f12eb1e4b3c88b8332fdd Mon Sep 17 00:00:00 2001 From: marinofranz Date: Thu, 13 Nov 2025 17:55:30 +0000 Subject: [PATCH 16/16] fix(universes): update translation to use LanguageCode and adjust test cases --- src/types/universes.ts | 2 +- test/universes.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/types/universes.ts b/src/types/universes.ts index b27614e..173fc22 100644 --- a/src/types/universes.ts +++ b/src/types/universes.ts @@ -129,7 +129,7 @@ export interface TranslateTextBody { } export interface TranslateTextResponse { - sourceLanguageCode: string; + sourceLanguageCode: LanguageCode; translations: Partial>; } diff --git a/test/universes.test.ts b/test/universes.test.ts index 85dc70c..9ded4c1 100644 --- a/test/universes.test.ts +++ b/test/universes.test.ts @@ -516,14 +516,14 @@ describe("Universes", () => { const body: TranslateTextBody = { text: "Hello world", - sourceLanguageCode: "", - targetLanguageCodes: [""], + sourceLanguageCode: "en-us", + targetLanguageCodes: ["es-es"], }; const result = await openCloud.universes.translateText("123456789", body); - expect(result.sourceLanguageCode).toBe("en"); - expect(result.translations[""]).toBe("Hola mundo"); + expect(result.sourceLanguageCode).toBe("en-us"); + expect(result.translations["es-es"]).toBe("Hola mundo"); expect(calls[0]?.url.toString()).toBe( `${baseUrl}/cloud/v2/universes/123456789:translateText`, );