[studio] avatar authoring, managed prefab builds (#140)

This commit is contained in:
dukedotdev
2026-10-23 15:25:31 -04:00
committed by GitHub
parent 16e5f61160
commit 3da35a8692
22 changed files with 2059 additions and 81 deletions
+148
View File
@@ -0,0 +1,148 @@
/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */
export const AVATAR_STUDIO_SCHEMA = [
`CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
`CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id)`,
`CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL)`,
]
export interface AvatarStudioItem {
CustomAvatarItemId: string
CreatorAccountId: number
OutfitType: number
Accessibility: number
CurrentSaves: Array<Record<string, unknown>>
[key: string]: unknown
}
export interface AvatarStudioSave {
id: number
item_id: string
body_type: number
unity_asset_id: string
source_filename: string
source_hash: string
thumbnail: string
moderation_image: string | null
configuration: string
description: string | null
update_current: number
created_at: string
}
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
const row = await db
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
.bind(accountId)
.first<{ flags: number }>()
return row?.flags ?? 0
}
export const AVATAR_STUDIO_SLOTS = [
0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506,
507, 508, 509, 510, 511, 512, 1000,
]
export function avatarItemAccessBit(outfitType: number): number {
return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1
}
export async function avatarStudioItem(
db: D1Database,
id: string
): Promise<AvatarStudioItem | null> {
const row = await db
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(id)
.first<{ data: string }>()
return row ? (JSON.parse(row.data) as AvatarStudioItem) : null
}
export async function avatarStudioSave(
db: D1Database,
itemId: string,
id: number
): Promise<AvatarStudioSave | null> {
return db
.prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?')
.bind(itemId, id)
.first<AvatarStudioSave>()
}
export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) {
const { results } = await db
.prepare(
'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?'
)
.bind(row.unity_asset_id)
.all<{
UnityAssetId: string
Target: number
Version: number
Filename: string
Hash: string
kind: string
}>()
const main = results.find((a) => a.Target === target && a.kind === 'main')
const material = results.find((a) => a.Target === target && a.kind === 'material')
return {
CustomAvatarItemSaveId: row.id,
CustomAvatarItemId: row.item_id,
UnityAssetId: row.unity_asset_id,
BodyType: row.body_type,
CreatedAt: row.created_at,
ModifiedAt: row.created_at,
Description: row.description,
ThumbnailFileName: row.thumbnail,
AdditionalConfiguration: row.configuration,
SourceUnityAssetFilename: row.source_filename,
SourceUnityAssetHash: row.source_hash,
ModerationImageFileName: row.moderation_image,
UnityAsset: main?.Filename ?? null,
UnityAssetHash: main?.Hash ?? null,
UnityAsset2: material?.Filename ?? null,
UnityAsset2Hash: material?.Hash ?? null,
BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a),
}
}
/** Publish exactly this built revision for its body type, preserving other body types. */
export async function publishAvatarSave(
db: D1Database,
itemId: string,
saveId: number,
automatic = false,
attempt = 0
): Promise<boolean> {
const item = await avatarStudioItem(db, itemId)
const save = await avatarStudioSave(db, itemId, saveId)
if (!item || !save) return false
const dto = await avatarSaveDto(db, save)
// Badge images are uploaded and published directly by the SDK, without a Unity build.
if (!dto.UnityAsset && item.OutfitType !== 1000) return false
const quest = await avatarSaveDto(db, save, 2)
const {
SourceUnityAssetFilename: _source,
SourceUnityAssetHash: _hash,
BakedAssets: _baked,
...published
} = dto
// JSON mutation is conditional on the complete prior snapshot: concurrent body-type
// publications cannot overwrite each other or resurrect a deleted item.
const prior = JSON.stringify(item)
item.CurrentSaves = [
...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type),
{
...published,
OutfitType: item.OutfitType,
QuestUnityAssetHash: quest.UnityAssetHash,
QuestUnityAsset2Hash: quest.UnityAsset2Hash,
},
]
item.ModifiedAt = new Date().toISOString()
item.ThumbnailImageFilename = save.thumbnail
if (item.OutfitType === 1000)
item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 }
const result = await db
.prepare(
`UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)
AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))`
)
.bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type)
.run()
if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId)))
return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1)
return result.meta.changes > 0
}
+1
View File
@@ -31,3 +31,4 @@ export * from './outfits-db'
export * from './progression-db'
export * from './relationships-db'
export * from './validation'
export * from './avatar-studio'
+25 -2
View File
@@ -2450,7 +2450,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
* it.
*/
const serializeRoom = (room: Room): string => {
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
}
@@ -2842,10 +2842,33 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
if (room.PublishedAt === '') room.PublishedAt = null
}
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)])
return rooms
}
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
if (!rooms.length) return
// API owns this table's migration; room deployments can precede that migration.
const exists = await db
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='custom_avatar_item'")
.first()
if (!exists) {
for (const room of rooms) room.Badges = []
return
}
const rows = await db
.prepare(
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
)
.bind(JSON.stringify(rooms.map(roomIdOf)))
.all<{ data: string; room_id: number }>()
for (const room of rooms)
room.Badges = rows.results
.filter((r) => r.room_id === roomIdOf(room))
.map((r) => JSON.parse(r.data))
}
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
export async function getSubRoom(
db: D1Database,
+1
View File
@@ -7,6 +7,7 @@ export {
generateToken,
generateStudioBuildValidationToken,
validateStudioBuildValidationToken,
studioBuildValidationId,
generatePhotonAuthToken,
TOKEN_TTL_SECONDS,
} from './jwt'
+14 -6
View File
@@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
// last a day so the client doesn't have to refresh it.
export const TOKEN_TTL_SECONDS = 86400
/** A build host can check component text, but cannot act as an account. */
/** A build host can validate content and its job's grants, but cannot act as an account. */
export async function generateStudioBuildValidationToken(
buildId: string,
secret: string
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
request: Request,
secret: string
): Promise<boolean> {
return (await studioBuildValidationId(request, secret)) !== null
}
/** Signed job identity for read-only, job-scoped validation routes. */
export async function studioBuildValidationId(
request: Request,
secret: string
): Promise<string | null> {
const header = request.headers.get('Authorization') ?? ''
if (!header.startsWith('Bearer ')) return false
if (!header.startsWith('Bearer ')) return null
try {
const payload = await verify(header.slice(7), secret, 'HS256')
return (
payload.sub === undefined &&
return payload.sub === undefined &&
payload.aud === 'studio-build-validation' &&
payload.scope === 'studio.build.validate' &&
typeof payload.buildId === 'string' &&
/^[1-9]\d*$/.test(payload.buildId)
)
? (payload.buildId as string)
: null
} catch {
return false
return null
}
}