mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[studio] avatar authoring, managed prefab builds (#140)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -31,3 +31,4 @@ export * from './outfits-db'
|
||||
export * from './progression-db'
|
||||
export * from './relationships-db'
|
||||
export * from './validation'
|
||||
export * from './avatar-studio'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
generateToken,
|
||||
generateStudioBuildValidationToken,
|
||||
validateStudioBuildValidationToken,
|
||||
studioBuildValidationId,
|
||||
generatePhotonAuthToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
|
||||
+14
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user