mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs
This commit is contained in:
+11
-1
@@ -2,7 +2,17 @@
|
||||
|
||||
CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams
|
||||
the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2
|
||||
bucket, plus the one bundled config file the loading screen reads.
|
||||
bucket, plus the JSON config files the client reads from `/config/`.
|
||||
|
||||
`/config/:name` serves `static/config/<name>.json` verbatim — `RRPlusConfig_v3` and
|
||||
`SkuConfig_v1` today — with or without the `.json` in the path, since the game configs that
|
||||
point at these files carry the extension and the client's older config calls don't. `{name}`
|
||||
IS the filename: that directory is uploaded as Workers static assets and read through the
|
||||
ASSETS binding, so publishing a config is dropping in a file, and `run_worker_first` keeps
|
||||
the asset server from answering ahead of the Worker (nothing is reachable at its own asset
|
||||
path). The files go out byte-for-byte, BOM included. `/config/LoadingScreenTipData` stays a
|
||||
route of its own, bundled rather than an asset, because its file is named differently from
|
||||
its path.
|
||||
|
||||
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
|
||||
|
||||
+107
-5
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
assetResponses,
|
||||
CONDITIONAL_HEADERS,
|
||||
json,
|
||||
JsonValue,
|
||||
keyParam,
|
||||
LoadingScreenTip,
|
||||
ServiceStatus,
|
||||
@@ -71,7 +72,7 @@ async function serveAsset(c: Context<App>, key: string) {
|
||||
headers.set('etag', object.httpEtag)
|
||||
headers.set('content-type', 'application/octet-stream')
|
||||
headers.set('accept-ranges', 'bytes')
|
||||
headers.set('cache-control', 'public, max-age=3600')
|
||||
headers.set('cache-control', CACHE_CONTROL)
|
||||
|
||||
// Precondition matched (If-None-Match) → R2 returns no body.
|
||||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||||
@@ -88,6 +89,64 @@ async function serveAsset(c: Context<App>, key: string) {
|
||||
return new Response(object.body, { headers })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-Control on every file this worker serves — 30 days (86400 × 30). These are big,
|
||||
* rarely-changing blobs fetched by key: a room scene, an invention, a signature blob. The
|
||||
* keys are content-addressed or date-foldered UUIDs, so a changed asset arrives under a
|
||||
* NEW key rather than replacing one that is already cached.
|
||||
*
|
||||
* Not `immutable`, unlike `img`: the same rule covers `/config/`, whose files ARE
|
||||
* republished under their existing names, and telling a browser never to revalidate those
|
||||
* would pin a stale config for the whole window.
|
||||
*/
|
||||
const CACHE_CONTROL = `public, max-age=${86400 * 30}`
|
||||
|
||||
/**
|
||||
* What may reach the ASSETS binding as a config filename: one path segment, no slashes,
|
||||
* and `..` rejected outright below. A traversal is then a 404 from this worker rather than
|
||||
* a request the asset server has to be trusted to refuse.
|
||||
*/
|
||||
const CONFIG_NAME = /^[A-Za-z0-9._-]+$/
|
||||
|
||||
/**
|
||||
* Serve a file from `static/config/` through the ASSETS binding, by its own filename —
|
||||
* whatever is in that directory, not just the JSON (a config may be an opaque binary blob
|
||||
* named by GUID). `null` when nothing is published under that name.
|
||||
*
|
||||
* A name carrying no extension also resolves against `<name>.json`, because the same file
|
||||
* is asked for both ways: the game configs that point at these carry the extension
|
||||
* (`Econ.MakerAI.DayPass.Config` is `"SkuConfig_v1.json"`) while the client's older config
|
||||
* calls leave it off. The exact name is tried first, so an extension-less FILE always wins
|
||||
* over the `.json` guess.
|
||||
*
|
||||
* The asset response is handed back whole rather than parsed and re-serialized: it already
|
||||
* carries a content type and an etag (so `If-None-Match` gets its 304 for free), and these
|
||||
* files go out BYTE-FOR-BYTE — `RRPlusConfig_v3.json` opens with a UTF-8 BOM, which is what
|
||||
* the real CDN served and what the client's parser expects.
|
||||
*/
|
||||
async function serveConfig(c: Context<App>, name: string): Promise<Response | null> {
|
||||
if (!CONFIG_NAME.test(name) || name.includes('..')) return null
|
||||
|
||||
const candidates = name.includes('.') ? [name] : [name, `${name}.json`]
|
||||
for (const candidate of candidates) {
|
||||
// Forwarding the original request keeps its conditional headers; only the URL is
|
||||
// rewritten to the asset's path.
|
||||
const res = await c.env.ASSETS.fetch(
|
||||
new Request(new URL(`/config/${candidate}`, c.req.url), c.req.raw)
|
||||
)
|
||||
// Rebuilt rather than returned as-is, only to stamp our own Cache-Control over the
|
||||
// asset server's: everything else — the body, the status, the content type, the etag
|
||||
// a conditional GET matched on — is carried across untouched. A 304 has no body to
|
||||
// carry, and `Response` refuses one for that status.
|
||||
if (res.ok || res.status === 304) {
|
||||
const headers = new Headers(res.headers)
|
||||
headers.set('cache-control', CACHE_CONTROL)
|
||||
return new Response(res.status === 304 ? null : res.body, { status: res.status, headers })
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -137,7 +196,50 @@ const app = new Hono<App>()
|
||||
].join(' '),
|
||||
responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') },
|
||||
}),
|
||||
(c) => c.json(loadingScreenTipData)
|
||||
(c) => c.json(loadingScreenTipData, 200, { 'Cache-Control': CACHE_CONTROL })
|
||||
)
|
||||
|
||||
// Everything else under `/config/`, served from `static/config/` by filename — JSON and
|
||||
// opaque blobs alike. Declared AFTER the tip-data route above, which would otherwise be
|
||||
// shadowed by this one: its file is named differently from its path, so it stays a
|
||||
// route of its own.
|
||||
.get(
|
||||
'/config/:name',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Serve a config file',
|
||||
description: [
|
||||
'Serves a file out of `static/config/` verbatim — `RRPlusConfig_v3.json` (the Rec Room',
|
||||
'Plus benefit lists), `SkuConfig_v1.json` (the Maker AI day-pass store copy) and a',
|
||||
'GUID-named binary blob today. `{name}` IS the filename, so publishing a config is',
|
||||
'dropping a file in that directory; nothing in the worker enumerates them, and not',
|
||||
'everything there is JSON.',
|
||||
'',
|
||||
'A name with no extension also resolves against `<name>.json`, because the same file',
|
||||
'is asked for both ways — the game configs that point at these carry the extension',
|
||||
'(`Econ.MakerAI.DayPass.Config` is `"SkuConfig_v1.json"`), the client’s older config',
|
||||
'calls leave it off. An extension-less file wins over the `.json` guess.',
|
||||
'',
|
||||
'These are byte-for-byte copies of what the real CDN served, BOM included, and are',
|
||||
'not rewritten or re-serialized on the way out.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
keyParam('name', 'The config’s filename. The `.json` may be left off.', false),
|
||||
...CONDITIONAL_HEADERS.filter((h) => h.name === 'If-None-Match'),
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The config file, as stored',
|
||||
content: {
|
||||
'application/json': { schema: resolver(JsonValue) },
|
||||
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
|
||||
},
|
||||
},
|
||||
304: { description: '`If-None-Match` matched the file’s etag (no body)' },
|
||||
404: { description: 'No config is published under that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => (await serveConfig(c, c.req.param('name'))) ?? c.notFound()
|
||||
)
|
||||
|
||||
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
|
||||
@@ -242,8 +344,8 @@ app.get(
|
||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||||
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
|
||||
'screen reads.',
|
||||
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
|
||||
'from `/config/`.',
|
||||
'',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
||||
|
||||
@@ -9,6 +9,11 @@ export type Env = SharedHonoEnv & {
|
||||
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
|
||||
// room build data under `room/<name>`.
|
||||
CDN_ASSETS: R2Bucket
|
||||
// Static-asset fetcher for the JSON configs in `static/config/` (see wrangler.jsonc
|
||||
// `assets`). Fetched by filename so `/config/:name` serves whatever is published;
|
||||
// the binding is the only way in, since `run_worker_first` keeps the runtime from
|
||||
// serving the files directly.
|
||||
ASSETS: Fetcher
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -86,6 +86,13 @@ export function keyParam(
|
||||
|
||||
// ---- Response schemas ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* An opaque JSON document — the config files under `static/config/` are served verbatim
|
||||
* and nothing here interprets them, so modelling their fields would be noise that goes
|
||||
* stale the moment a file is replaced.
|
||||
*/
|
||||
export const JsonValue = z.record(z.string(), z.unknown())
|
||||
|
||||
/** `GET /` — the liveness probe body. */
|
||||
export const ServiceStatus = z.object({
|
||||
service: z.literal('cdn'),
|
||||
|
||||
@@ -28,6 +28,75 @@ describe('cdn endpoints', () => {
|
||||
expect(body[0]).toHaveProperty('Title')
|
||||
})
|
||||
|
||||
// The config directory is served by filename through the ASSETS binding, so a file
|
||||
// dropped into `static/config/` is reachable without touching the worker.
|
||||
test.each(['RRPlusConfig_v3', 'SkuConfig_v1'])(
|
||||
'GET /config/%s serves the file from static/config',
|
||||
async (name) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('application/json')
|
||||
expect((await res.text()).length).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
|
||||
// Not everything in the directory is JSON: a config may be an opaque blob named by
|
||||
// GUID, which is served as-is under its own name.
|
||||
test('GET /config/:name serves an extension-less binary config', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/1b057e6e-979d-4f30-8856-a386f77c90da`)
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.arrayBuffer()).byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// The game configs name these files WITH the extension (`Econ.MakerAI.DayPass.Config`
|
||||
// is `"SkuConfig_v1.json"`), so both spellings have to land on the same file.
|
||||
test('GET /config/:name accepts the .json suffix', async () => {
|
||||
const bare = await exports.default.fetch(`${ORIGIN}/config/SkuConfig_v1`)
|
||||
const suffixed = await exports.default.fetch(`${ORIGIN}/config/SkuConfig_v1.json`)
|
||||
expect(suffixed.status).toBe(200)
|
||||
expect(await suffixed.text()).toBe(await bare.text())
|
||||
})
|
||||
|
||||
// Byte-for-byte: RRPlusConfig_v3.json opens with a UTF-8 BOM, as the real CDN served
|
||||
// it. Re-serializing the file (or parsing and re-emitting it) would strip those bytes.
|
||||
test('GET /config/RRPlusConfig_v3 keeps the file’s bytes, BOM included', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/RRPlusConfig_v3`)
|
||||
const bytes = new Uint8Array(await res.arrayBuffer())
|
||||
expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf])
|
||||
// `text()` decodes the BOM away, so the remainder still parses as the config.
|
||||
expect(JSON.parse(new TextDecoder().decode(bytes))).toHaveProperty('BenefitLists')
|
||||
})
|
||||
|
||||
test('GET /config/:name 404s a config that is not published', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/NoSuchConfig`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
// The name reaches the binding as a filename, so anything that could climb out of
|
||||
// `static/config/` is refused before it gets there. (A bare `..` never arrives: the
|
||||
// URL is normalized to `/` before routing, which is the liveness probe.)
|
||||
test.each(['%2e%2e%2floading-screen-tip-data.json', 'sub%2Fdir', '.hidden', 'Sku.Config'])(
|
||||
'GET /config/%s 404s rather than reaching the asset server',
|
||||
async (name) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/${name}`)
|
||||
expect(res.status).toBe(404)
|
||||
}
|
||||
)
|
||||
|
||||
// `run_worker_first` keeps the asset server from answering ahead of the Worker: the
|
||||
// tip data is an asset too (`static/loading-screen-tip-data.json`), and it must stay
|
||||
// unreachable at that path — its route is `/config/LoadingScreenTipData`.
|
||||
test('assets are not served at their own paths', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/loading-screen-tip-data.json`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /config/LoadingScreenTipData still wins over the wildcard', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/LoadingScreenTipData`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(Array.isArray(await res.json())).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /sigs/:sigName 404s when the blob is absent', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/sigs/does-not-exist`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -41,6 +110,40 @@ describe('cdn endpoints', () => {
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
})
|
||||
|
||||
// Every file this worker hands back carries the same 30-day Cache-Control — the R2
|
||||
// blobs, the configs served through the ASSETS binding, and the bundled tip data.
|
||||
test('every served file carries the 30-day Cache-Control', async () => {
|
||||
const CACHE_CONTROL = `public, max-age=${86400 * 30}`
|
||||
await env.CDN_ASSETS.put('room/2026-02-03/cached', new Uint8Array([1, 2, 3]))
|
||||
|
||||
for (const path of [
|
||||
'/room/2026-02-03/cached',
|
||||
'/config/RRPlusConfig_v3.json',
|
||||
'/config/LoadingScreenTipData',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
expect(res.status, path).toBe(200)
|
||||
expect(res.headers.get('cache-control'), path).toBe(CACHE_CONTROL)
|
||||
}
|
||||
|
||||
// A ranged read is still a served file, and a 304 has to carry it too — otherwise a
|
||||
// revalidation would answer "no change" with no instruction on how long that holds.
|
||||
const ranged = await exports.default.fetch(`${ORIGIN}/room/2026-02-03/cached`, {
|
||||
headers: { Range: 'bytes=0-1' },
|
||||
})
|
||||
expect(ranged.status).toBe(206)
|
||||
expect(ranged.headers.get('cache-control')).toBe(CACHE_CONTROL)
|
||||
|
||||
const etag = (await exports.default.fetch(`${ORIGIN}/room/2026-02-03/cached`)).headers.get(
|
||||
'etag'
|
||||
)
|
||||
const revalidated = await exports.default.fetch(`${ORIGIN}/room/2026-02-03/cached`, {
|
||||
headers: { 'If-None-Match': etag ?? '' },
|
||||
})
|
||||
expect(revalidated.status).toBe(304)
|
||||
expect(revalidated.headers.get('cache-control')).toBe(CACHE_CONTROL)
|
||||
})
|
||||
|
||||
test('GET /sigs/:sigName honors a Range request with 206', async () => {
|
||||
await env.CDN_ASSETS.put('sigs/ranged', new Uint8Array([10, 11, 12, 13, 14, 15]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/sigs/ranged`, {
|
||||
@@ -191,6 +294,7 @@ describe('cdn endpoints', () => {
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /config/LoadingScreenTipData',
|
||||
'GET /config/{name}',
|
||||
'GET /data/{id}',
|
||||
'GET /invention/{dataBlob}',
|
||||
'GET /room/{dataBlob}',
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"BenefitLists": {
|
||||
"0": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7
|
||||
],
|
||||
"1": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"2": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"3": [
|
||||
8,
|
||||
9
|
||||
],
|
||||
"4": [
|
||||
10,
|
||||
11,
|
||||
12
|
||||
],
|
||||
"5": [
|
||||
13,
|
||||
14,
|
||||
7
|
||||
]
|
||||
},
|
||||
"BenefitLookup": {
|
||||
"0": {
|
||||
"CustomSpriteName": "Campus_Club_Card_Token_Icon",
|
||||
"DetailedText": "You get a {{Tokens}} token box every month. Due to platform restrictions, you must log in each week in order to claim this reward. If you want to claim this reward on another platform you can, but you must login at least once a month on the platform you became a member on.",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
"3": "You get a {{Tokens}} token box every month. You must log in at least once during each 30 day period in order to claim this reward."
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "{{Tokens}} tokens per month! ($11 value)",
|
||||
"TitleText": "{{Tokens}} bonus tokens per month ($11 USD in monthly value)"
|
||||
},
|
||||
"1": {
|
||||
"CustomSpriteName": "icon_inventory_item",
|
||||
"DetailedText": "You get a box containing a random 4-star item every week. If you have all 4-star items you will get a 800 token box instead. The week resets at 12 AM UTC on Sundays. Due to platform restrictions, you must login each week in order to claim this reward. If you want to claim this reward on another platform you can, but you must login at least once a month on the platform you became a member on.",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Weekly 4-star box!",
|
||||
"TitleText": "Free weekly 4-star box"
|
||||
},
|
||||
"10": {
|
||||
"CustomSpriteName": "UI_Menu_Token_Image_50",
|
||||
"DetailedText": "/month. Cancel anytime",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Join RR+ for Endless Rewards!",
|
||||
"TitleText": "Join RR+ for Endless Rewards!"
|
||||
},
|
||||
"11": {
|
||||
"CustomSpriteName": "icon_currency_coins",
|
||||
"DetailedText": null,
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "2X token rewards",
|
||||
"TitleText": "2X token rewards"
|
||||
},
|
||||
"12": {
|
||||
"CustomSpriteName": "icon_RecToken",
|
||||
"DetailedText": null,
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "{{Tokens}} monthly tokens",
|
||||
"TitleText": "Monthly Tokens"
|
||||
},
|
||||
"13": {
|
||||
"CustomSpriteName": "icon_currency_dollars",
|
||||
"DetailedText": "Unlock the ability to turn earned tokens into real-world money",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": true,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Unlock the ability to turn earned tokens into real-world money",
|
||||
"TitleText": "Unlock the ability to turn earned tokens into real-world money"
|
||||
},
|
||||
"14": {
|
||||
"CustomSpriteName": "Campus_Club_Card_Token_Icon",
|
||||
"DetailedText": "Get {{Tokens}} every month and a {{RRODiscount}}% discount on eligible items",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Get {{Tokens}} every month and a {{RRODiscount}}% discount on eligible items",
|
||||
"TitleText": "Monthly Benefits"
|
||||
},
|
||||
"2": {
|
||||
"CustomSpriteName": "PriceTag",
|
||||
"DetailedText": "You get {{RRODiscount}}% off all purchases in Rec Room Original token stores (e.g. the watch, merch booth, mirror, cafe, paintball, etc). This does not include Rec Room Original stores that don’t use tokens (e.g. Laser Tag, Lost Skulls, and Crescendo)\r",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "{{RRODiscount}}% RRO store discount!",
|
||||
"TitleText": "{{RRODiscount}}% discount in Rec Room stores that accept tokens "
|
||||
},
|
||||
"3": {
|
||||
"CustomSpriteName": "icon_OwnedCostumePieces",
|
||||
"DetailedText": "You get early access to items for a limited time and can claim them for free by logging in while they are in early access.",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Free, early access to items for a limited time!",
|
||||
"TitleText": "Free, early access to items for a limited time"
|
||||
},
|
||||
"4": {
|
||||
"CustomSpriteName": "Shirt",
|
||||
"DetailedText": "You get {{MemberOutfitSlots}} outfit slots on top of the regular {{NonMemberOutfitSlots}}. Access them via the Saved Outfits button in the Profile section of your watch or on your Dorm room mirror.",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "{{MemberOutfitSlots}} saved outfit slots!",
|
||||
"TitleText": "More saved outfit slots"
|
||||
},
|
||||
"5": {
|
||||
"CustomSpriteName": null,
|
||||
"DetailedText": "You can sell inventions and custom clothing in the watch store and keys, currencies, and consumables in your rooms. You will be paid 70% of all tokens earned through these sales. Those tokens will be held for a week in an escrow account to mitigate fraud, then they’ll be delivered to you the same day as your other RR+ bonus tokens.\r",
|
||||
"EnabledForPlatforms": 511,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": true,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Earn tokens for your creations!",
|
||||
"TitleText": "Earn tokens for your creations"
|
||||
},
|
||||
"6": {
|
||||
"CustomSpriteName": "icon_RecToken",
|
||||
"DetailedText": "Whenever you are offered tokens from a post-activity or first activity of the day reward, you will be offered twice the normal amount.",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Double token rewards!",
|
||||
"TitleText": "Double token rewards"
|
||||
},
|
||||
"7": {
|
||||
"CustomSpriteName": "icon_Shirt",
|
||||
"DetailedText": "You can create your own custom shirts with the clothing customizer! You can access the clothing customizer in your backpack, and publish shirts which can be purchased and worn.",
|
||||
"EnabledForPlatforms": 511,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": true,
|
||||
"ShortText": "Create your own custom shirts",
|
||||
"TitleText": "Create your own custom shirts"
|
||||
},
|
||||
"8": {
|
||||
"CustomSpriteName": "Campus_Club_Card_Token_Icon",
|
||||
"DetailedText": "",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "Plus {{Tokens}} tokens NOW & monthly!",
|
||||
"TitleText": "Plus {{Tokens}} tokens NOW & monthly!"
|
||||
},
|
||||
"9": {
|
||||
"CustomSpriteName": "icon_Heart",
|
||||
"DetailedText": "Keep saving {{RRODiscount}}% on every RRO order!",
|
||||
"EnabledForPlatforms": -1,
|
||||
"PlatformSpecificDetailedTexts": {
|
||||
},
|
||||
"RequiresMonetizationEnabled": false,
|
||||
"RequiresPublishingEnabled": false,
|
||||
"ShortText": "And much more!",
|
||||
"TitleText": "And much more!"
|
||||
}
|
||||
},
|
||||
"HighlightText": "The best deal in Rec Room!",
|
||||
"MoreDetailsText": "And <u>much more!</u>",
|
||||
"NumberReplacements": {
|
||||
"{{MemberOutfitSlots}}": 200,
|
||||
"{{NonMemberOutfitSlots}}": 16,
|
||||
"{{RRODiscount}}": 10,
|
||||
"{{Tokens}}": 6500
|
||||
},
|
||||
"StringConfigs": {
|
||||
"RRPlusMessageBody": "Thanks for becoming a RR+ member. You now have access to all the member benefits!",
|
||||
"RRPlusMessageTitle": "Welcome to the Club!"
|
||||
},
|
||||
"Version": 3
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"Version": 0,
|
||||
"SkuConfigs": [
|
||||
{
|
||||
"SkuId": 183,
|
||||
"Name": "{{MakerAIDayPassName}}",
|
||||
"Description": "{{MakerAIName}} springs to life for {{MakerAIHours}} hours* of creative collaboration!\n\nJust equip your {{MakerPenName}} in any Rooms 2.0 room you own (including Dorms with Maker AI), and your holographic helper can...\n• <b>Create images and patterns</b> on canvases\n• <b>Make objects come to life</b> through AI-generated circuits\n• <b>Generate objects</b> from the {{MakerPenName}} inventory \n• <u><link=\"ID\">Learn more about upcoming features</link> </u>",
|
||||
"ThumbnailImageName": "img/makerAI_thumb_01.png",
|
||||
"DetailsImageName": "img/makerAI_storeDetailsPromo_01.png",
|
||||
"ShowSkuDetails": true,
|
||||
"Footer": {
|
||||
"Text": "* {{SubjectToDayPassUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
},
|
||||
"DialogConfigs": {
|
||||
"PurchaseSuccess": {
|
||||
"Title": "Day Pass Purchased",
|
||||
"Text": "Up to 24 hours of {{MakerAIName}} access has been added to your account. The clock starts now, so go equip your Maker Pen in one of your R2 rooms to get started!",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Explore my R2 Rooms",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToCreate"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "{{LearnMoreAboutUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"UpsellAnnouncement": {
|
||||
"Title": "Make Rooms Magical",
|
||||
"Text": "Be among the first to try Maker AI in R2 rooms! Our new Day Pass extends beyond RR+ subscribers and dorms, empowering everyone to unleash their imagination.",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Unlock This Deal",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
}
|
||||
],
|
||||
"Cooldown": 10080
|
||||
},
|
||||
"UsageLimitReached": {
|
||||
"Title": "Energy Drained",
|
||||
"Text": "{{MakerAIName}} hit our day pass usage limits and settled down for a power nap. Ready to wake it up with a fresh Day Pass?",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Wake Up Maker AI",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
},
|
||||
{
|
||||
"Text": "Continue without Maker AI",
|
||||
"Type": "Secondary",
|
||||
"OnClick": "DismissDialog"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "{{LearnMoreAboutUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"TimeLimitReached": {
|
||||
"Title": "Time's Up!",
|
||||
"Text": "{{MakerAIName}} hit the day pass time limit and settled down for a power nap. Ready to wake it up with a fresh Day Pass?",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Wake Up Maker AI",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
},
|
||||
{
|
||||
"Text": "Continue without Maker AI",
|
||||
"Type": "Secondary",
|
||||
"OnClick": "DismissDialog"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "{{LearnMoreAboutUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"UsageLimitWarning": {
|
||||
"Title": "Energy Running Low",
|
||||
"Text": "Heads up, Maker AI is nearing day pass usage limits and will need a power nap soon. Grab another day pass to keep the magic flowing uninterrupted!",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Add More Energy",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
},
|
||||
{
|
||||
"Text": "Ignore",
|
||||
"Type": "Secondary",
|
||||
"OnClick": "DismissDialog"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "{{LearnMoreAboutUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"TimeLimitWarning": {
|
||||
"Title": "Time Running Low",
|
||||
"Text": "Heads up, {{MakerAIName}} has about <b>1 hour left</b> before it needs a recharge. Grab another day pass to keep the magic flowing uninterrupted!",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Add More Time",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
},
|
||||
{
|
||||
"Text": "Ignore",
|
||||
"Type": "Secondary",
|
||||
"OnClick": "DismissDialog"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "{{LearnMoreAboutUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"ComingSoonToMakerAI": {
|
||||
"Title": "Coming Soon to {{MakerAIName}}",
|
||||
"Text": "There are some things Maker AI doesn't know how to do yet, and we're working on it!\n\nThis includes...\n•understanding circuits you've edited manually\n•generating objects out of basic shapes\n•placing objects relative to other objects\n\nSo, <b>\"put the lamp next to the couch\"</b> or <b>\"fix this bug in my circuit\"</b> will be confusing to Maker AI (for now).",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": []
|
||||
},
|
||||
"ChatError": {
|
||||
"Title": "Ready to create?",
|
||||
"Text": "Your building bestie is taking a power nap. Wake it up with a fresh {{MakerAIDayPassName}}!",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Wake up Maker AI",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PurchaseConfirmation": {
|
||||
"Title": "Up to {{MakerAIHours}} Hours of Power!",
|
||||
"Text": "Once a day pass is purchased, its timer keeps running even when you're not playing. The pass can't be paused or refunded even if you hit usage limits early. \n\n<size=75%><i>{{PlatformConfirmationMessage}}</i></size>",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Accept & Continue to Payment",
|
||||
"Type": "Primary",
|
||||
"OnClick": "PositiveEvent"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "By pressing \"Accept & Continue to Payment\", I expressly agree that access to {{MakerAIName}} will begin immediately, and I acknowledge that I will lose my right to cancel or get a refund once access starts— even if it expires early due to <u>usage limits</u>.",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"DayPassActiveBanner": {
|
||||
"Title": "Day Pass Active",
|
||||
"Text": "You currently have {{MakerAITimeLeftDynamic}} of {{MakerAIName}} access. If you buy another pass now, you'll get an additional {{MakerAIHours}} hours.",
|
||||
"SpriteName": "icon_hourglass",
|
||||
"Buttons": []
|
||||
},
|
||||
"MakerAIUsageLimits": {
|
||||
"Title": "{{MakerAIName}} Usage Limits and Conditions",
|
||||
"Text": "<b>Why does Maker AI have usage limits?</b>\nMaker AI is a powerful tool that allows everyone to create, but every action has a cost. Usage limits ensure we can continue to offer Maker AI to everyone.\n\n<b>What are the usage limits?</b>\nWe limit day passes to many hundreds of simple requests or many dozens of our most expensive requests (e.g., images and circuits). Don’t worry—this should only affect less than 1–5% of users. \n\n<b>What happens if I hit these limits?</b>\n•If it looks like you are getting close, we will provide a warning that you're approaching the limit.\n• If you exceed the limit, your day pass will end early. You will be able to buy another pass and continue working.\n\nWe appreciate your understanding as we work to make this tool available to all.\n\n<u><link=\"{{LearnMoreAboutUsageLimitsUrl}}\">Learn More</link></u>",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": []
|
||||
},
|
||||
"FreeTrialConfirmation": {
|
||||
"Title": "Try {{MakerAIName}} for free",
|
||||
"Text": "Get up to {{MakerAIFreeTrialDurationDynamic}} of {{MakerAIName}} in an R2 room you own, free of charge.\n\nOnce your free trial starts, its timer keeps running even when you're not playing. It can't be paused or reactivated even if you hit usage limits early.",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Accept & Start Free Trial",
|
||||
"Type": "Primary",
|
||||
"OnClick": "PositiveEvent"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "By pressing \"Accept & Start Free Trial\", I expressly agree that access to {{MakerAIName}} will begin immediately, and I acknowledge that I will lose my right to cancel once access starts—even if it expires early due to <u>usage limits</u>.",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"FreeTrialSuccess": {
|
||||
"Title": "Free Trial Activated",
|
||||
"Text": "Up to {{MakerAIFreeTrialDurationDynamic}} of {{MakerAIName}} access has been added to your account, free of charge. The clock starts now, so go equip your {{MakerPenName}} in one of your R2 rooms to get started.",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Explore my R2 Rooms",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToCreate"
|
||||
}
|
||||
],
|
||||
"Footer": {
|
||||
"Text": "{{LearnMoreAboutUsageLimits}}",
|
||||
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
|
||||
}
|
||||
},
|
||||
"ActivateFreeTrialError": {
|
||||
"Title": "Something Went Wrong",
|
||||
"Text": "A problem occurred while attempting to activate your free trial, try again in a few minutes. If the problem persists, you can contact us at <u>https://recroom.zendesk.com</u>.",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Ok",
|
||||
"Type": "Primary",
|
||||
"OnClick": "DismissDialog"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FreeTrialAnnouncement": {
|
||||
"Title": "Creation for Everyone",
|
||||
"Text": "Get up to {{MakerAIFreeTrialDurationDynamic}} of {{MakerAIName}} in an R2 room you own, free of charge. No strings attached—just pure creative power to generate images, circuits, and more!",
|
||||
"SpriteName": "icon_MakerAI",
|
||||
"Buttons": [
|
||||
{
|
||||
"Text": "Unlock This Deal",
|
||||
"Type": "Primary",
|
||||
"OnClick": "GoToDayPass"
|
||||
}
|
||||
],
|
||||
"Cooldown": 10080
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"StringReplacements": {
|
||||
"{{MakerAIName}}": "Maker AI",
|
||||
"{{MakerAIDayPassName}}": "Maker AI Day Pass",
|
||||
"{{DayPassNamePlural}}": "Day Passes",
|
||||
"{{MakerAIHours}}": "24",
|
||||
"{{SubjectToDayPassUsageLimits}}": "Subject to <u>usage limits</u>. Currently unavailable on Playstation and Nintendo Switch.",
|
||||
"{{LearnMoreAboutUsageLimits}}": "<u><link=\"MakerAIUsageLimits\">Learn more about day pass usage limits</link></u>",
|
||||
"{{LearnMoreAboutUsageLimitsUrl}}": "https://rec.net/creator/p/makerai-daypass",
|
||||
"{{MakerPenName}}": "Maker Pen"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||
},
|
||||
|
||||
+17
-1
@@ -14,7 +14,23 @@
|
||||
// always answers a `bytes=` request with a 206 and a truthful Content-Range.
|
||||
// The cost is that every asset read hits R2; correctness on these blobs is worth it.
|
||||
"cache": {
|
||||
"enabled": false
|
||||
"enabled": true
|
||||
},
|
||||
// The JSON config files in `static/config/`, uploaded as Workers static assets rather
|
||||
// than bundled into the script, so `/config/:name` serves whatever is published there:
|
||||
// adding a config is dropping in a file (a bundled `import` can't do that — the
|
||||
// bundler has to see every path at build time).
|
||||
//
|
||||
// `run_worker_first: true` because these are the WORKER'S data files, not a site.
|
||||
// Without it the runtime answers any request whose path matches an asset before the
|
||||
// Worker runs, which would both publish every config a second time at
|
||||
// `/config/<name>.json` and put asset routing in front of the R2 routes below. With
|
||||
// it, the Worker sees every request and the files are reachable only through
|
||||
// `/config/:name`.
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"directory": "./static",
|
||||
"run_worker_first": true
|
||||
},
|
||||
// CDN binaries (signature blobs + room build data) are stored as R2 objects
|
||||
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
|
||||
|
||||
Reference in New Issue
Block a user