mirror of
https://github.com/VickScarlet/lifeRestart.git
synced 2026-08-19 18:43:53 +08:00
update: bili-toy
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"dev": "bunx --bun vite",
|
||||
"dev:prod": "bunx --bun vite --mode production",
|
||||
"build": "bunx --bun vite build",
|
||||
"build:bili": "bunx --bun vite build --config vite.config.bili.ts",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"lint": "bunx --bun eslint",
|
||||
"test": "bunx --bun vitest"
|
||||
@@ -15,6 +16,7 @@
|
||||
"@remake/data": "workspace:*",
|
||||
"@remake/hooks": "workspace:*",
|
||||
"@remake/vitex": "workspace:*",
|
||||
"@remake/thirdparty-bili-toy": "workspace:*",
|
||||
"jotai": "^2.20.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useCallback, useTransition } from 'react'
|
||||
import { atom, useAtom } from 'jotai'
|
||||
import { useConfigInject, useProfileInject, useRawProfile } from '@remake/hooks'
|
||||
import { useUniqueInject, useUnique } from '@remake/hooks'
|
||||
import { get, set } from '@/storage'
|
||||
import { init, get, set } from '@/storage'
|
||||
import { config } from '@/config'
|
||||
|
||||
const initedAtom = atom(false)
|
||||
@@ -15,7 +15,8 @@ export const useInit = () => {
|
||||
const loader = useCallback(async () => {
|
||||
if (inited) return
|
||||
configInject(config)
|
||||
const { profile, unique } = await get(['profile', 'unique'])
|
||||
await init()
|
||||
const profile = await get('profile')
|
||||
const parsed = profile ? JSON.parse(profile) || {} : {}
|
||||
profileInject({
|
||||
...parsed,
|
||||
@@ -24,6 +25,7 @@ export const useInit = () => {
|
||||
events: new Set(parsed.events || []),
|
||||
talents: new Set(parsed.talents || []),
|
||||
})
|
||||
const unique = await get('unique')
|
||||
if (unique) uniqueInject(JSON.parse(unique))
|
||||
setInited(true)
|
||||
}, [inited, configInject, profileInject, uniqueInject, setInited])
|
||||
@@ -46,14 +48,14 @@ export const useWatcher = () => {
|
||||
talents: Array.from(profile.talents),
|
||||
})
|
||||
saveProfile(async () => {
|
||||
await set({ profile: str })
|
||||
await set('profile', str)
|
||||
})
|
||||
}, [inited, profile, saveProfile])
|
||||
useEffect(() => {
|
||||
if (!inited || !unique) return
|
||||
const str = JSON.stringify(unique)
|
||||
saveUnique(async () => {
|
||||
await set({ unique: str })
|
||||
await set('unique', str)
|
||||
})
|
||||
}, [inited, unique, saveUnique])
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test, expect, describe } from 'vitest'
|
||||
import { talents, achievements, events } from '@remake/data'
|
||||
|
||||
describe('Bili Toy Cloud Save Core', () => {
|
||||
test('maxSizeProfile should pass ToyCloudSaveCore constraints', () => {
|
||||
const json = JSON.stringify({
|
||||
times: 999999,
|
||||
locked: Array.from(talents.keys()),
|
||||
talents: Array.from(talents.keys()),
|
||||
achievements: Array.from(achievements.keys()),
|
||||
events: Array.from(events.keys()),
|
||||
})
|
||||
expect(json.length).toBeLessThan((48 * 960 * 3) / 4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as biliToy from '@remake/thirdparty-bili-toy'
|
||||
import * as local from './local'
|
||||
|
||||
interface Storage {
|
||||
init(): Promise<void>
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string): Promise<boolean>
|
||||
}
|
||||
const storage = {} as Storage
|
||||
|
||||
if (import.meta.env.VITE_CHANNEL === 'bili') {
|
||||
storage.init = biliToy.init
|
||||
storage.get = biliToy.get
|
||||
storage.set = biliToy.set
|
||||
} else {
|
||||
storage.init = async () => {}
|
||||
storage.get = local.get
|
||||
storage.set = local.set
|
||||
}
|
||||
|
||||
export const init = storage.init
|
||||
export const get = storage.get
|
||||
export const set = storage.set
|
||||
@@ -28,15 +28,12 @@ if (localStorage.getItem('version') !== '3.0.0') {
|
||||
}
|
||||
localStorage.setItem('version', '3.0.0')
|
||||
}
|
||||
export async function get<Key extends string>(keys: Key[]) {
|
||||
return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as {
|
||||
[K in Key]: string | null
|
||||
}
|
||||
|
||||
export async function get(key: string) {
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
|
||||
export async function set<Key extends string>(data: Record<Key, string>) {
|
||||
for (const key in data) {
|
||||
localStorage.setItem(key, data[key])
|
||||
}
|
||||
export async function set(key: string, value: string) {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
}
|
||||
@@ -115,6 +115,15 @@ input {
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0; /* 移除某些浏览器下的默认留白 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) { :root { font-size: 2.88vw; } }
|
||||
@media screen and (orientation: portrait) and (pointer: coarse) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const dataSplitRule = {
|
||||
test: /[\\/]packages[\\/]data[\\/]|@remake\/data/,
|
||||
priority: 40,
|
||||
name(id: string) {
|
||||
const name = id.split(/[\/\\]/).pop()!
|
||||
const baseName = name.substring(0, name.lastIndexOf('.'))
|
||||
if (baseName && baseName !== 'index') return `data-${baseName}`
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { tsconfigPaths: true },
|
||||
define: { 'import.meta.env.VITE_CHANNEL': '"bili"' },
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist/bili/remake',
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
minSize: 1024,
|
||||
groups: [dataSplitRule],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -16,6 +16,7 @@ const dataSplitRule = {
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { tsconfigPaths: true },
|
||||
base: './',
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rolldownOptions: {
|
||||
|
||||
Generated
+3
@@ -44,6 +44,9 @@ importers:
|
||||
'@remake/hooks':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/hooks
|
||||
'@remake/thirdparty-bili-toy':
|
||||
specifier: workspace:*
|
||||
version: link:../../thirdparty/bili-toy
|
||||
'@remake/vitex':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/vitex
|
||||
|
||||
Vendored
+1
-166
@@ -1,166 +1 @@
|
||||
const SDK = '//s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js'
|
||||
const CHUNK_SIZE = 1024
|
||||
const PRELOAD_CHUNKS_LIMIT = 20
|
||||
|
||||
let inited = false
|
||||
|
||||
export async function loadSDK() {
|
||||
const script = document.createElement('script')
|
||||
script.src = SDK
|
||||
script.async = true
|
||||
document.body.appendChild(script)
|
||||
return new Promise((resolve, reject) => {
|
||||
script.onload = () => {
|
||||
resolve(window.toy)
|
||||
}
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load SDK'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
try {
|
||||
await loadSDK()
|
||||
inited = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load Bili Toy SDK:', error)
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
checkAbility('getCloudStorage'),
|
||||
checkAbility('setCloudStorage'),
|
||||
checkAbility('removeCloudStorage'),
|
||||
])
|
||||
}
|
||||
|
||||
async function checkAbility(ability: toy.Ability) {
|
||||
if (!(await toy.isSupport(ability)))
|
||||
throw new Error(
|
||||
`Bili Toy SDK does not support ${ability}. Please check the SDK version.`,
|
||||
)
|
||||
}
|
||||
|
||||
interface ChunkMeta {
|
||||
total: number
|
||||
size: number
|
||||
}
|
||||
|
||||
function splitIntoChunks(
|
||||
str: string,
|
||||
chunkSize: number = CHUNK_SIZE,
|
||||
): string[] {
|
||||
const base64Str = new TextEncoder().encode(str).toBase64()
|
||||
const chunks: string[] = []
|
||||
let offset = 0
|
||||
while (offset < base64Str.length) {
|
||||
chunks.push(base64Str.substring(offset, offset + chunkSize))
|
||||
offset += chunkSize
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function mergeChunks(chunks: string[]): string {
|
||||
const combinedBase64 = chunks.join('')
|
||||
return new TextDecoder().decode(Uint8Array.from(combinedBase64))
|
||||
}
|
||||
|
||||
export async function getCloudStore<K extends string[]>(
|
||||
keys: [...K],
|
||||
): Promise<Record<K[number], string | undefined>> {
|
||||
if (!inited)
|
||||
throw new Error('Bili Toy SDK not initialized. Call init() first.')
|
||||
if (!keys.length) return {} as any
|
||||
const firstBatchKeys: string[] = []
|
||||
keys.forEach(key => {
|
||||
firstBatchKeys.push(`${key}.meta`)
|
||||
for (let i = 0; i < PRELOAD_CHUNKS_LIMIT; i++) {
|
||||
firstBatchKeys.push(`${key}.chunk.${i}`)
|
||||
}
|
||||
})
|
||||
const rawKV = await toy.getCloudStorage(firstBatchKeys)
|
||||
const result = {} as Record<K[number], string | undefined>
|
||||
await Promise.all(
|
||||
keys.map(async key => {
|
||||
const metaStr = rawKV[`${key}.meta`]
|
||||
if (!metaStr) {
|
||||
result[key as K[number]] = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const meta: ChunkMeta = JSON.parse(metaStr)
|
||||
const collectedChunks: string[] = []
|
||||
for (let i = 0; i < meta.total; i++) {
|
||||
const chunkVal = rawKV[`${key}.chunk.${i}`]
|
||||
if (chunkVal !== undefined && chunkVal !== null) {
|
||||
collectedChunks[i] = chunkVal
|
||||
}
|
||||
}
|
||||
if (meta.total > PRELOAD_CHUNKS_LIMIT) {
|
||||
const missingChunkKeys: string[] = []
|
||||
for (let i = PRELOAD_CHUNKS_LIMIT; i < meta.total; i++) {
|
||||
missingChunkKeys.push(`${key}.chunk.${i}`)
|
||||
}
|
||||
const secondaryKV = await toy.getCloudStorage(missingChunkKeys)
|
||||
for (let i = PRELOAD_CHUNKS_LIMIT; i < meta.total; i++) {
|
||||
collectedChunks[i] = secondaryKV[`${key}.chunk.${i}`]!
|
||||
}
|
||||
}
|
||||
if (collectedChunks.filter(Boolean).length !== meta.total) {
|
||||
console.warn(
|
||||
`⚠️ [vTransform Storage] 检测到数据键 [${key}] 的云端切片发生残缺丢失,已自动放弃合并。`,
|
||||
)
|
||||
result[key as K[number]] = undefined
|
||||
return
|
||||
}
|
||||
result[key as K[number]] = mergeChunks(collectedChunks)
|
||||
}),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function setCloudStore(
|
||||
items: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (!inited)
|
||||
throw new Error('Bili Toy SDK not initialized. Call init() first.')
|
||||
const payloadKV: Record<string, string> = {}
|
||||
for (const key in items) {
|
||||
const originalValue = items[key]
|
||||
if (originalValue === null || originalValue === undefined) continue
|
||||
const chunkList = splitIntoChunks(originalValue, CHUNK_SIZE)
|
||||
const meta: ChunkMeta = {
|
||||
total: chunkList.length,
|
||||
size: originalValue.length,
|
||||
}
|
||||
payloadKV[`${key}.meta`] = JSON.stringify(meta)
|
||||
chunkList.forEach((chunkContent, index) => {
|
||||
payloadKV[`${key}.chunk.${index}`] = chunkContent
|
||||
})
|
||||
}
|
||||
return await toy.setCloudStorage(payloadKV)
|
||||
}
|
||||
|
||||
export async function removeCloudStore(keys: string[]): Promise<void> {
|
||||
if (!inited)
|
||||
throw new Error('Bili Toy SDK not initialized. Call init() first.')
|
||||
if (!keys.length) return
|
||||
|
||||
const metaKeys = keys.map(k => `${k}.meta`)
|
||||
const rawMetaKV = await toy.getCloudStorage(metaKeys)
|
||||
const deleteKeysList: string[] = []
|
||||
|
||||
keys.forEach(key => {
|
||||
deleteKeysList.push(`${key}.meta`)
|
||||
const metaStr = rawMetaKV[`${key}.meta`]
|
||||
|
||||
if (metaStr) {
|
||||
const meta: ChunkMeta = JSON.parse(metaStr)
|
||||
for (let i = 0; i < meta.total; i++) {
|
||||
deleteKeysList.push(`${key}.chunk.${i}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
return await toy.removeCloudStorage(deleteKeysList)
|
||||
}
|
||||
export * from './wrapper'
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 哔哩哔哩官方 ToyCloudSaveCore 核心分片存储算法组件环境类型声明文件 (Ambient Declaration)
|
||||
*
|
||||
* 说明:本文件专为官方无类型的 ToyCloudSaveCore.js 设计。
|
||||
* 放入项目后,TypeScript 会自动在全局识别该组件,红线报错将彻底消除。
|
||||
*/
|
||||
|
||||
declare namespace ToyCloudSaveCoreSDK {
|
||||
/** 外部必须提供给该核心组件的底层持久化存储驱动接口契约 */
|
||||
interface IToyStorageDriver {
|
||||
/** 异步获取指定键值对记录 */
|
||||
get(keys: string[]): Promise<Record<string, any> | null | undefined>
|
||||
/** 异步设置并持久化键值对记录,附带当前的存储上下文 */
|
||||
set(
|
||||
record: Record<string, string>,
|
||||
context: IToyStorageContext,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
/** 存储操作的阶段上下文 */
|
||||
interface IToyStorageContext {
|
||||
/** 当前操作的存储槽区 */
|
||||
bank: 'a' | 'b'
|
||||
/** 递增的存储世代号 */
|
||||
generation: number
|
||||
/** 当前落盘的具体物理阶段 */
|
||||
phase: 'chunks' | 'meta' | 'head'
|
||||
}
|
||||
|
||||
/** 存档操作成功后返回的物理数据摘要 */
|
||||
interface IToySaveSummary {
|
||||
/** 写入的存储槽区 */
|
||||
bank: 'a' | 'b'
|
||||
/** 递增的世代代数 */
|
||||
generation: number
|
||||
/** 本次存盘的实际 UTF-8 字节数 */
|
||||
bytes: number
|
||||
/** 分片总块数 */
|
||||
chunks: number
|
||||
/** 数据流的 CRC32 校验和十六进制字符串 */
|
||||
checksum: string
|
||||
}
|
||||
|
||||
/** 状态机向外部观察者派发的所有生命周期事件 */
|
||||
interface IToySaveEvent {
|
||||
type:
|
||||
| 'save:start'
|
||||
| 'save:phase'
|
||||
| 'save:verified'
|
||||
| 'save:success'
|
||||
| 'save:error'
|
||||
| 'save:rejected'
|
||||
| 'save:queued'
|
||||
| 'load:start'
|
||||
| 'load:success'
|
||||
| 'load:empty'
|
||||
at: number
|
||||
phase?: 'chunks' | 'meta' | 'head' | 'verify'
|
||||
bank?: 'a' | 'b'
|
||||
generation?: number
|
||||
bytes?: number
|
||||
chunks?: number
|
||||
checksum?: string
|
||||
fallback?: boolean
|
||||
recovered?: boolean
|
||||
source?: 'head' | 'scan' | 'fallback'
|
||||
pending?: number
|
||||
code?:
|
||||
| 'STORAGE_ERROR'
|
||||
| 'VERIFICATION_FAILED'
|
||||
| 'PAYLOAD_TOO_LARGE'
|
||||
| 'INVALID_PAYLOAD'
|
||||
| string
|
||||
found?: boolean
|
||||
}
|
||||
|
||||
/** 观察者事件回调函数 */
|
||||
type IToySaveEventHandler = (
|
||||
event: Readonly<IToySaveEvent>,
|
||||
) => void | Promise<void>
|
||||
|
||||
/** 创建云存储实例所需的入参配置项 */
|
||||
interface IToyCloudSaveOptions {
|
||||
/** 必填:提供底层读写能力的本地适配驱动 */
|
||||
storage: IToyStorageDriver
|
||||
/** 必填:用于防冲突的唯一键值前缀 */
|
||||
prefix: string
|
||||
/** 选填:核心生命周期事件的全量观察者钩子 */
|
||||
onEvent?: IToySaveEventHandler
|
||||
}
|
||||
|
||||
/** 分片明细列表项 */
|
||||
interface IToyChunkInspect {
|
||||
index: number
|
||||
chars: number
|
||||
preview: string
|
||||
}
|
||||
|
||||
/** 全盘负载深度审阅快照返回值 */
|
||||
interface IToyPayloadInspection {
|
||||
json: string
|
||||
bytes: number
|
||||
base64Chars: number
|
||||
chunkCount: number
|
||||
checksum: string
|
||||
exceedsLimit: boolean
|
||||
chunks: IToyChunkInspect[]
|
||||
}
|
||||
|
||||
/** 调试状态快照 */
|
||||
interface IToyDebugState {
|
||||
prefix: string
|
||||
pendingSaves: number
|
||||
activeSave: boolean
|
||||
head: {
|
||||
bank?: 'a' | 'b'
|
||||
generation?: number
|
||||
valid: boolean
|
||||
status?: string
|
||||
} | null
|
||||
banks: {
|
||||
a: {
|
||||
bank?: 'a' | 'b'
|
||||
generation?: number
|
||||
valid: boolean
|
||||
status?: string
|
||||
} | null
|
||||
b: {
|
||||
bank?: 'a' | 'b'
|
||||
generation?: number
|
||||
valid: boolean
|
||||
status?: string
|
||||
} | null
|
||||
}
|
||||
lastSave: IToySaveSummary | null
|
||||
lastLoad: any
|
||||
events: IToySaveEvent[]
|
||||
}
|
||||
|
||||
/** 经由 createCloudSave 创建的物理隔离处理服务实例 API */
|
||||
interface IToyCloudSaveInstance {
|
||||
/** 写入保存云存档 */
|
||||
save(json: string): Promise<IToySaveSummary>
|
||||
/** 读取云存档 */
|
||||
load(): Promise<string | null>
|
||||
/** 导出内存调试快照 */
|
||||
getDebugState(): IToyDebugState
|
||||
}
|
||||
|
||||
/** 对应官方核心 JS 脚本导出的顶级大对象契约 */
|
||||
interface CoreObject {
|
||||
/** 单个分卷分片的最大安全 Base64 字符容量边界(960) */
|
||||
readonly CHUNK_CHARS: 960
|
||||
/** 双槽双缓冲设计允许承载的单次数据最大分片切片物理上限(48 块) */
|
||||
readonly MAX_CHUNKS: 48
|
||||
/** 计算指定数据的标准 CRC32 校验和 */
|
||||
crc32Hex(input: string | ArrayBuffer | ArrayBufferView): string
|
||||
/** 前置透视并审阅即将进行云存的文件负载 */
|
||||
inspectPayload(json: string): IToyPayloadInspection
|
||||
/** 核心工厂构建器:构建具备高可靠双槽原子性保护的云存档处理服务 */
|
||||
createCloudSave(options: IToyCloudSaveOptions): IToyCloudSaveInstance
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- 全局注入声明 -----------------
|
||||
interface Window {
|
||||
/** 哔哩哔哩官方无类型的云存档核心分片存储组件。由本地或网络脚本执行后自动挂载。 */
|
||||
ToyCloudSaveCore: ToyCloudSaveCoreSDK.CoreObject
|
||||
}
|
||||
|
||||
/** 哔哩哔哩官方无类型的云存档核心分片存储组件顶级全局变量。 */
|
||||
declare const ToyCloudSaveCore: ToyCloudSaveCoreSDK.CoreObject
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
(function attachToyCloudSaveCore(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = factory();
|
||||
} else {
|
||||
debugger
|
||||
root.ToyCloudSaveCore = factory();
|
||||
}
|
||||
}(typeof globalThis !== 'undefined' ? globalThis : this, function buildToyCloudSaveCore() {
|
||||
'use strict';
|
||||
|
||||
const CHUNK_CHARS = 960;
|
||||
const MAX_CHUNKS = 48;
|
||||
const EVENT_LIMIT = 60;
|
||||
const BANKS = ['a', 'b'];
|
||||
const CRC32_TABLE = buildCrc32Table();
|
||||
|
||||
function buildCrc32Table() {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < table.length; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function utf8Encode(value) {
|
||||
if (typeof TextEncoder !== 'undefined') {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Uint8Array.from(Buffer.from(value, 'utf8'));
|
||||
}
|
||||
|
||||
const binary = unescape(encodeURIComponent(value));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function utf8Decode(bytes) {
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
const value = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8');
|
||||
if (!sameBytes(utf8Encode(value), bytes)) {
|
||||
throw new Error('Invalid UTF-8');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return decodeURIComponent(escape(binary));
|
||||
}
|
||||
|
||||
function sameBytes(left, right) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toBytes(input) {
|
||||
if (typeof input === 'string') return utf8Encode(input);
|
||||
if (typeof ArrayBuffer !== 'undefined' && input instanceof ArrayBuffer) {
|
||||
return new Uint8Array(input);
|
||||
}
|
||||
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(input)) {
|
||||
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
||||
}
|
||||
throw new TypeError('crc32Hex expects a string, ArrayBuffer, or typed array');
|
||||
}
|
||||
|
||||
function crc32Hex(input) {
|
||||
const bytes = toBytes(input);
|
||||
let crc = 0xffffffff;
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
||||
}
|
||||
if (typeof btoa !== 'function') {
|
||||
throw new Error('Base64 encoding is unavailable');
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
const batch = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += batch) {
|
||||
const slice = bytes.subarray(offset, offset + batch);
|
||||
for (let index = 0; index < slice.length; index += 1) {
|
||||
binary += String.fromCharCode(slice[index]);
|
||||
}
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
if (!isCanonicalBase64(value)) throw new Error('Invalid Base64');
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Uint8Array.from(Buffer.from(value, 'base64'));
|
||||
}
|
||||
if (typeof atob !== 'function') {
|
||||
throw new Error('Base64 decoding is unavailable');
|
||||
}
|
||||
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isCanonicalBase64(value) {
|
||||
if (value === '') return true;
|
||||
if (value.length % 4 !== 0) return false;
|
||||
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
|
||||
}
|
||||
|
||||
function preparePayload(json) {
|
||||
if (typeof json !== 'string') {
|
||||
throw new TypeError('Cloud save payload must be a JSON string');
|
||||
}
|
||||
try {
|
||||
JSON.parse(json);
|
||||
} catch (parseError) {
|
||||
const error = new SyntaxError('Cloud save payload must contain valid JSON');
|
||||
error.code = 'INVALID_JSON';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bytes = utf8Encode(json);
|
||||
const base64 = bytesToBase64(bytes);
|
||||
const chunks = [];
|
||||
if (base64.length === 0) {
|
||||
chunks.push('');
|
||||
} else {
|
||||
for (let offset = 0; offset < base64.length; offset += CHUNK_CHARS) {
|
||||
chunks.push(base64.slice(offset, offset + CHUNK_CHARS));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
json,
|
||||
bytes: bytes.length,
|
||||
base64Chars: base64.length,
|
||||
chunks,
|
||||
checksum: crc32Hex(bytes),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPayload(json) {
|
||||
const payload = preparePayload(json);
|
||||
return {
|
||||
json: payload.json,
|
||||
bytes: payload.bytes,
|
||||
base64Chars: payload.base64Chars,
|
||||
chunkCount: payload.chunks.length,
|
||||
checksum: payload.checksum,
|
||||
exceedsLimit: payload.chunks.length > MAX_CHUNKS,
|
||||
chunks: payload.chunks.map((value, index) => ({
|
||||
index,
|
||||
chars: value.length,
|
||||
preview: value.length > 24 ? `${value.slice(0, 24)}…` : value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createCloudSave(options) {
|
||||
const config = options || {};
|
||||
const storage = config.storage;
|
||||
const prefix = config.prefix;
|
||||
const onEvent = config.onEvent;
|
||||
|
||||
if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function') {
|
||||
throw new TypeError('storage must provide async get(keys) and set(record, context) methods');
|
||||
}
|
||||
if (typeof prefix !== 'string' || prefix.length === 0) {
|
||||
throw new TypeError('prefix must be a non-empty string');
|
||||
}
|
||||
if (onEvent !== undefined && typeof onEvent !== 'function') {
|
||||
throw new TypeError('onEvent must be a function');
|
||||
}
|
||||
|
||||
const keys = {
|
||||
head: `${prefix}_head`,
|
||||
meta(bank) {
|
||||
return `${prefix}_${bank}_meta`;
|
||||
},
|
||||
chunk(bank, index) {
|
||||
return `${prefix}_${bank}_${String(index).padStart(3, '0')}`;
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
pendingSaves: 0,
|
||||
activeSave: false,
|
||||
head: null,
|
||||
banks: { a: null, b: null },
|
||||
lastSave: null,
|
||||
lastLoad: null,
|
||||
events: [],
|
||||
};
|
||||
let saveTail = Promise.resolve();
|
||||
|
||||
function emit(type, details) {
|
||||
const allowed = [
|
||||
'bank', 'generation', 'bytes', 'chunks', 'checksum', 'phase',
|
||||
'source', 'fallback', 'recovered', 'pending', 'code', 'found',
|
||||
];
|
||||
const event = { type, at: Date.now() };
|
||||
const source = details || {};
|
||||
for (const key of allowed) {
|
||||
if (source[key] !== undefined) event[key] = source[key];
|
||||
}
|
||||
const frozen = Object.freeze(event);
|
||||
state.events.push({ ...event });
|
||||
if (state.events.length > EVENT_LIMIT) state.events.shift();
|
||||
if (onEvent) {
|
||||
try {
|
||||
const result = onEvent(frozen);
|
||||
if (result && typeof result.catch === 'function') result.catch(function ignoreEventError() {});
|
||||
} catch (error) {
|
||||
// Observers cannot affect persistence.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getRecord(requestedKeys) {
|
||||
const record = await storage.get(requestedKeys);
|
||||
return record && typeof record === 'object' ? record : {};
|
||||
}
|
||||
|
||||
function own(record, key) {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function parseObject(value) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHead(value) {
|
||||
const head = parseObject(value);
|
||||
if (!head || head.v !== 1 || !BANKS.includes(head.bank)) return null;
|
||||
if (!Number.isSafeInteger(head.generation) || head.generation < 1) return null;
|
||||
return { v: 1, bank: head.bank, generation: head.generation };
|
||||
}
|
||||
|
||||
function parseMeta(value) {
|
||||
const meta = parseObject(value);
|
||||
if (!meta || meta.v !== 1) return null;
|
||||
if (!Number.isSafeInteger(meta.generation) || meta.generation < 1) return null;
|
||||
if (!Number.isSafeInteger(meta.chunks) || meta.chunks < 1 || meta.chunks > MAX_CHUNKS) return null;
|
||||
if (!Number.isSafeInteger(meta.bytes) || meta.bytes < 0) return null;
|
||||
if (typeof meta.checksum !== 'string' || !/^[0-9a-f]{8}$/.test(meta.checksum)) return null;
|
||||
return {
|
||||
v: 1,
|
||||
generation: meta.generation,
|
||||
chunks: meta.chunks,
|
||||
bytes: meta.bytes,
|
||||
checksum: meta.checksum,
|
||||
};
|
||||
}
|
||||
|
||||
function summary(bank, meta) {
|
||||
return {
|
||||
bank,
|
||||
generation: meta.generation,
|
||||
bytes: meta.bytes,
|
||||
chunks: meta.chunks,
|
||||
checksum: meta.checksum,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidBank(bank, status) {
|
||||
state.banks[bank] = { bank, valid: false, status };
|
||||
return { valid: false, bank, status };
|
||||
}
|
||||
|
||||
function validateBankRecord(bank, record, expectedGeneration) {
|
||||
const metaKey = keys.meta(bank);
|
||||
const meta = own(record, metaKey) ? parseMeta(record[metaKey]) : null;
|
||||
if (!meta) return invalidBank(bank, own(record, metaKey) ? 'invalid' : 'missing');
|
||||
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
|
||||
return invalidBank(bank, 'generation-mismatch');
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (let index = 0; index < meta.chunks; index += 1) {
|
||||
const key = keys.chunk(bank, index);
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string' || value.length > CHUNK_CHARS) {
|
||||
return invalidBank(bank, 'invalid-chunk');
|
||||
}
|
||||
if (index < meta.chunks - 1 && value.length !== CHUNK_CHARS) {
|
||||
return invalidBank(bank, 'invalid-chunk');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = base64ToBytes(chunks.join(''));
|
||||
if (bytes.length !== meta.bytes || crc32Hex(bytes) !== meta.checksum) {
|
||||
return invalidBank(bank, 'checksum-mismatch');
|
||||
}
|
||||
const json = utf8Decode(bytes);
|
||||
try {
|
||||
JSON.parse(json);
|
||||
} catch (parseError) {
|
||||
return invalidBank(bank, 'invalid-json');
|
||||
}
|
||||
const result = { valid: true, bank, json, meta, summary: summary(bank, meta) };
|
||||
state.banks[bank] = { ...result.summary, valid: true };
|
||||
return result;
|
||||
} catch (error) {
|
||||
return invalidBank(bank, 'decode-failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function readHead() {
|
||||
const record = await getRecord([keys.head]);
|
||||
const head = own(record, keys.head) ? parseHead(record[keys.head]) : null;
|
||||
state.head = head
|
||||
? { bank: head.bank, generation: head.generation, valid: true }
|
||||
: { valid: false, status: own(record, keys.head) ? 'invalid' : 'missing' };
|
||||
return head;
|
||||
}
|
||||
|
||||
async function readBank(bank, expectedGeneration) {
|
||||
const metaKey = keys.meta(bank);
|
||||
const metaRecord = await getRecord([metaKey]);
|
||||
const meta = own(metaRecord, metaKey) ? parseMeta(metaRecord[metaKey]) : null;
|
||||
if (!meta) return invalidBank(bank, own(metaRecord, metaKey) ? 'invalid' : 'missing');
|
||||
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
|
||||
return invalidBank(bank, 'generation-mismatch');
|
||||
}
|
||||
|
||||
const chunkKeys = [];
|
||||
for (let index = 0; index < meta.chunks; index += 1) {
|
||||
chunkKeys.push(keys.chunk(bank, index));
|
||||
}
|
||||
const chunkRecord = await getRecord(chunkKeys);
|
||||
return validateBankRecord(bank, { ...metaRecord, ...chunkRecord }, expectedGeneration);
|
||||
}
|
||||
|
||||
function otherBank(bank) {
|
||||
return bank === 'a' ? 'b' : 'a';
|
||||
}
|
||||
|
||||
function newestValid(results) {
|
||||
return results
|
||||
.filter((result) => result.valid)
|
||||
.sort((left, right) => right.meta.generation - left.meta.generation)[0] || null;
|
||||
}
|
||||
|
||||
async function chooseSaveTarget() {
|
||||
const head = await readHead();
|
||||
let first;
|
||||
let second;
|
||||
let base = null;
|
||||
|
||||
if (head) {
|
||||
first = await readBank(head.bank, head.generation);
|
||||
second = await readBank(otherBank(head.bank));
|
||||
base = first.valid ? first : (second.valid ? second : null);
|
||||
} else {
|
||||
first = await readBank('a');
|
||||
second = await readBank('b');
|
||||
base = newestValid([first, second]);
|
||||
}
|
||||
|
||||
const highestGeneration = Math.max(
|
||||
head ? head.generation : 0,
|
||||
first.valid ? first.meta.generation : 0,
|
||||
second.valid ? second.meta.generation : 0,
|
||||
);
|
||||
return {
|
||||
bank: base ? otherBank(base.bank) : (head ? otherBank(head.bank) : 'a'),
|
||||
generation: highestGeneration + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeError(message, code, ErrorType) {
|
||||
const error = new (ErrorType || Error)(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function savePrepared(payload) {
|
||||
state.activeSave = true;
|
||||
let transaction;
|
||||
try {
|
||||
transaction = await chooseSaveTarget();
|
||||
const bank = transaction.bank;
|
||||
const generation = transaction.generation;
|
||||
const meta = {
|
||||
v: 1,
|
||||
generation,
|
||||
chunks: payload.chunks.length,
|
||||
bytes: payload.bytes,
|
||||
checksum: payload.checksum,
|
||||
};
|
||||
const savedSummary = summary(bank, meta);
|
||||
const context = { bank, generation };
|
||||
emit('save:start', savedSummary);
|
||||
|
||||
const chunkRecord = {};
|
||||
for (let index = 0; index < payload.chunks.length; index += 1) {
|
||||
chunkRecord[keys.chunk(bank, index)] = payload.chunks[index];
|
||||
}
|
||||
await storage.set(chunkRecord, { ...context, phase: 'chunks' });
|
||||
emit('save:phase', { ...savedSummary, phase: 'chunks' });
|
||||
|
||||
await storage.set({ [keys.meta(bank)]: JSON.stringify(meta) }, { ...context, phase: 'meta' });
|
||||
emit('save:phase', { ...savedSummary, phase: 'meta' });
|
||||
|
||||
const verifyKeys = [keys.meta(bank), ...Object.keys(chunkRecord)];
|
||||
const readback = await getRecord(verifyKeys);
|
||||
const verified = validateBankRecord(bank, readback, generation);
|
||||
if (
|
||||
!verified.valid
|
||||
|| verified.json !== payload.json
|
||||
|| verified.meta.bytes !== meta.bytes
|
||||
|| verified.meta.chunks !== meta.chunks
|
||||
|| verified.meta.checksum !== meta.checksum
|
||||
) {
|
||||
throw makeError('Cloud save readback verification failed', 'VERIFICATION_FAILED');
|
||||
}
|
||||
emit('save:verified', { ...savedSummary, phase: 'verify' });
|
||||
|
||||
const head = { v: 1, bank, generation };
|
||||
await storage.set({ [keys.head]: JSON.stringify(head) }, { ...context, phase: 'head' });
|
||||
state.head = { bank, generation, valid: true };
|
||||
state.lastSave = { ...savedSummary };
|
||||
emit('save:success', savedSummary);
|
||||
return savedSummary;
|
||||
} catch (error) {
|
||||
emit('save:error', {
|
||||
bank: transaction && transaction.bank,
|
||||
generation: transaction && transaction.generation,
|
||||
code: error && error.code ? error.code : 'STORAGE_ERROR',
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
state.activeSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
function save(json) {
|
||||
let payload;
|
||||
try {
|
||||
payload = preparePayload(json);
|
||||
if (payload.chunks.length > MAX_CHUNKS) {
|
||||
throw makeError(
|
||||
`Cloud save payload needs ${payload.chunks.length} chunks; maximum is ${MAX_CHUNKS}`,
|
||||
'PAYLOAD_TOO_LARGE',
|
||||
RangeError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
emit('save:rejected', {
|
||||
bytes: payload && payload.bytes,
|
||||
chunks: payload && payload.chunks.length,
|
||||
code: error && error.code ? error.code : 'INVALID_PAYLOAD',
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
state.pendingSaves += 1;
|
||||
emit('save:queued', {
|
||||
bytes: payload.bytes,
|
||||
chunks: payload.chunks.length,
|
||||
checksum: payload.checksum,
|
||||
pending: state.pendingSaves,
|
||||
});
|
||||
const operation = saveTail.then(function runQueuedSave() {
|
||||
return savePrepared(payload);
|
||||
});
|
||||
const completed = operation.finally(function finishQueuedSave() {
|
||||
state.pendingSaves -= 1;
|
||||
});
|
||||
saveTail = completed.catch(function keepQueueAlive() {});
|
||||
return completed;
|
||||
}
|
||||
|
||||
function finishLoad(result, flags) {
|
||||
const loadSummary = {
|
||||
...result.summary,
|
||||
fallback: Boolean(flags.fallback),
|
||||
recovered: Boolean(flags.recovered),
|
||||
source: flags.recovered ? 'scan' : (flags.fallback ? 'fallback' : 'head'),
|
||||
};
|
||||
state.lastLoad = loadSummary;
|
||||
emit('load:success', loadSummary);
|
||||
return result.json;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const savesQueuedBeforeLoad = saveTail;
|
||||
emit('load:start');
|
||||
await savesQueuedBeforeLoad;
|
||||
const head = await readHead();
|
||||
if (head) {
|
||||
const active = await readBank(head.bank, head.generation);
|
||||
if (active.valid) return finishLoad(active, { fallback: false, recovered: false });
|
||||
|
||||
const fallback = await readBank(otherBank(head.bank));
|
||||
if (fallback.valid && fallback.meta.generation < head.generation) {
|
||||
return finishLoad(fallback, { fallback: true, recovered: false });
|
||||
}
|
||||
if (fallback.valid) {
|
||||
state.banks[fallback.bank] = {
|
||||
...fallback.summary,
|
||||
valid: false,
|
||||
status: 'uncommitted',
|
||||
};
|
||||
}
|
||||
|
||||
state.lastLoad = { found: false, fallback: true, recovered: false };
|
||||
emit('load:empty', state.lastLoad);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bankA = await readBank('a');
|
||||
const bankB = await readBank('b');
|
||||
const recovered = newestValid([bankA, bankB]);
|
||||
if (recovered) return finishLoad(recovered, { fallback: false, recovered: true });
|
||||
|
||||
state.lastLoad = { found: false, fallback: false, recovered: true };
|
||||
emit('load:empty', state.lastLoad);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDebugState() {
|
||||
return JSON.parse(JSON.stringify({
|
||||
prefix,
|
||||
pendingSaves: state.pendingSaves,
|
||||
activeSave: state.activeSave,
|
||||
head: state.head,
|
||||
banks: state.banks,
|
||||
lastSave: state.lastSave,
|
||||
lastLoad: state.lastLoad,
|
||||
events: state.events,
|
||||
}));
|
||||
}
|
||||
|
||||
return Object.freeze({ save, load, getDebugState });
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
createCloudSave,
|
||||
inspectPayload,
|
||||
crc32Hex,
|
||||
CHUNK_CHARS,
|
||||
MAX_CHUNKS,
|
||||
});
|
||||
}));
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Toy 分片云存档接入示例</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: system-ui, sans-serif; }
|
||||
body { max-width: 760px; margin: 0 auto; padding: 24px; color: #18191c; background: #f6f7f8; }
|
||||
main { display: grid; gap: 16px; padding: 24px; border: 1px solid #e3e5e7; border-radius: 12px; background: #fff; }
|
||||
textarea { width: 100%; min-height: 220px; box-sizing: border-box; padding: 12px; font: 14px/1.6 ui-monospace, monospace; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
button { min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; color: #fff; background: #00aeec; cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .55; }
|
||||
#status { min-height: 24px; margin: 0; color: #61666d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Toy 分片云存档接入示例</h1>
|
||||
<p>示例始终先保存本地档;Toy SDK 可用且玩家已登录时,再提交云端。</p>
|
||||
<label for="save-json">存档 JSON</label>
|
||||
<textarea id="save-json" spellcheck="false"></textarea>
|
||||
<div class="actions">
|
||||
<button id="make-example" type="button">生成示例</button>
|
||||
<button id="save" type="button">保存</button>
|
||||
<button id="load" type="button">读取</button>
|
||||
</div>
|
||||
<p id="status" role="status" aria-live="polite">正在初始化…</p>
|
||||
</main>
|
||||
|
||||
<!-- 顺序不可颠倒:Toy SDK → 通用核心 → 业务脚本。 -->
|
||||
<script src="https://s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js"></script>
|
||||
<script src="./cloud-save-core.js"></script>
|
||||
<script src="./example.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+168
@@ -0,0 +1,168 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PREFIX = 'your_game_save_v1';
|
||||
var LOCAL_KEY = PREFIX + '_local';
|
||||
|
||||
function ToyStorageAdapter(toySdk) {
|
||||
if (
|
||||
!toySdk ||
|
||||
typeof toySdk.getCloudStorage !== 'function' ||
|
||||
typeof toySdk.setCloudStorage !== 'function'
|
||||
) {
|
||||
throw new Error('Toy 云存储 SDK 不可用');
|
||||
}
|
||||
this.toy = toySdk;
|
||||
}
|
||||
|
||||
ToyStorageAdapter.prototype.get = async function (keys) {
|
||||
var result = await this.toy.getCloudStorage(keys);
|
||||
if (result && result.data && typeof result.data === 'object') {
|
||||
return result.data;
|
||||
}
|
||||
return result || {};
|
||||
};
|
||||
|
||||
ToyStorageAdapter.prototype.set = async function (record) {
|
||||
await this.toy.setCloudStorage(record);
|
||||
};
|
||||
|
||||
function createExampleState() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
updatedAt: Date.now(),
|
||||
progress: { stage: 1, score: 120 },
|
||||
settings: { music: true, language: 'zh-CN' },
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeError(error) {
|
||||
var message = error && error.message ? error.message : String(error || '未知错误');
|
||||
return message.slice(0, 160);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var editor = document.getElementById('save-json');
|
||||
var status = document.getElementById('status');
|
||||
var saveButton = document.getElementById('save');
|
||||
var loadButton = document.getElementById('load');
|
||||
var exampleButton = document.getElementById('make-example');
|
||||
var cloudSave = null;
|
||||
|
||||
function setStatus(message) {
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
function applyLoadedState(json) {
|
||||
// 替换点:把 JSON 交给项目原有的存档应用逻辑。
|
||||
JSON.parse(json);
|
||||
editor.value = json;
|
||||
}
|
||||
|
||||
function loadLocalFirst() {
|
||||
var localJson = localStorage.getItem(LOCAL_KEY);
|
||||
if (!localJson) {
|
||||
editor.value = JSON.stringify(createExampleState(), null, 2);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
applyLoadedState(localJson);
|
||||
return true;
|
||||
} catch (error) {
|
||||
localStorage.removeItem(LOCAL_KEY);
|
||||
editor.value = JSON.stringify(createExampleState(), null, 2);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseEditor() {
|
||||
var value = editor.value.trim();
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function setBusy(isBusy) {
|
||||
saveButton.disabled = isBusy;
|
||||
loadButton.disabled = isBusy;
|
||||
}
|
||||
|
||||
var hadLocalSave = loadLocalFirst();
|
||||
|
||||
try {
|
||||
if (!window.ToyCloudSaveCore) {
|
||||
throw new Error('cloud-save-core.js 未加载');
|
||||
}
|
||||
var storage = new ToyStorageAdapter(window.toy || window.Toy);
|
||||
cloudSave = window.ToyCloudSaveCore.createCloudSave({
|
||||
storage: storage,
|
||||
prefix: 'your_game_save_v1',
|
||||
onEvent: function (event) {
|
||||
// 只记录阶段摘要,禁止在这里打印完整 JSON。
|
||||
if (event && event.phase) {
|
||||
status.dataset.phase = event.phase;
|
||||
}
|
||||
},
|
||||
});
|
||||
setStatus(hadLocalSave ? '已加载本地档;可读取云端进行校验。' : '云存档已就绪。');
|
||||
} catch (error) {
|
||||
setStatus('当前仅使用本地存档:' + summarizeError(error));
|
||||
}
|
||||
|
||||
exampleButton.addEventListener('click', function () {
|
||||
editor.value = JSON.stringify(createExampleState(), null, 2);
|
||||
setStatus('已生成小型示例,尚未保存。');
|
||||
});
|
||||
|
||||
saveButton.addEventListener('click', async function () {
|
||||
var json;
|
||||
try {
|
||||
json = parseEditor();
|
||||
} catch (error) {
|
||||
setStatus('JSON 无效,请修正后再保存:' + summarizeError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(LOCAL_KEY, json);
|
||||
if (!cloudSave) {
|
||||
setStatus('本地保存成功;当前环境无法写入 Toy 云端。');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus('本地保存成功,正在提交云端…');
|
||||
try {
|
||||
var result = await cloudSave.save(json);
|
||||
setStatus('云保存成功:Bank ' + result.bank.toUpperCase() + ',' + result.chunks + ' 片。');
|
||||
} catch (error) {
|
||||
setStatus('本地已保存,但云保存失败:' + summarizeError(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
loadButton.addEventListener('click', async function () {
|
||||
var localFound = loadLocalFirst();
|
||||
if (!cloudSave) {
|
||||
setStatus(localFound ? '已读取本地档;当前环境无法读取 Toy 云端。' : '本地和云端均无可用存档。');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus(localFound ? '已应用本地档,正在校验云端…' : '正在读取云端…');
|
||||
try {
|
||||
var cloudJson = await cloudSave.load();
|
||||
if (cloudJson === null) {
|
||||
setStatus(localFound ? '云端为空,继续使用本地档。' : '云端和本地均为空。');
|
||||
} else {
|
||||
applyLoadedState(cloudJson);
|
||||
localStorage.setItem(LOCAL_KEY, cloudJson);
|
||||
setStatus('云档校验通过并已同步到本地。');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus((localFound ? '继续使用本地档;' : '') + '云读取失败:' + summarizeError(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
+117
-23
@@ -59,9 +59,38 @@ declare namespace ToySDK {
|
||||
localPath: string
|
||||
}
|
||||
|
||||
interface ReportActionReq {
|
||||
/** UP 主自定义的动作标识。 */
|
||||
userEventId: string
|
||||
// ---------------------------------------------------------------------------
|
||||
// 分享与二维码
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ShareReq {
|
||||
/**
|
||||
* 相对当前 Toy 页面根(`/toy/<slug>/`)的路径,可带 query,如 `result.html?score=100`。
|
||||
*
|
||||
* 完整分享链接由平台生成,Toy 不能自行指定完整 URL。路径只能落在当前 Toy 内:
|
||||
* 传绝对 URL 或用 `../` 越界到其他 Toy / 外域会抛 `invalid_param`。
|
||||
*/
|
||||
path: string
|
||||
}
|
||||
|
||||
interface QrCodeReq {
|
||||
/**
|
||||
* 相对当前 Toy 页面根(`/toy/<slug>/`)的路径,可带 query。
|
||||
* 不传(或传空串)时指向当前 Toy 首页 `index.html`。
|
||||
*
|
||||
* 传了则与 `ShareReq.path` 同一约定:二维码内容由平台生成,
|
||||
* Toy 不能自行指定完整 URL;传绝对 URL 或用 `../` 越界到其他 Toy / 外域会抛 `invalid_param`。
|
||||
*/
|
||||
path?: string
|
||||
/** 二维码边长(像素),取值区间 `[80, 1024]` 的整数。不传默认 `320`,越界或非整数抛 `invalid_param`。 */
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface QrCodeResp {
|
||||
/** PNG 图片的完整 data URL(`data:image/png;base64,...`),可直接赋给 `img.src`。 */
|
||||
base64: string
|
||||
/** 二维码实际编码的完整链接,由平台生成。 */
|
||||
url: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,6 +102,8 @@ declare namespace ToySDK {
|
||||
avatar: string
|
||||
/** 昵称。 */
|
||||
nickname: string
|
||||
/** 当前登录用户在当前 Toy 内的稳定假名标识,不是鉴权凭证;功能未启用时可能不返回。 */
|
||||
toyOpenId?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,7 +118,7 @@ declare namespace ToySDK {
|
||||
* - `denied`: 用户拒绝了数据使用确认
|
||||
* - `unsupported`: 当前环境不支持(如外部手机浏览器,SDK 会引导打开 B站 App)
|
||||
* - `toy_context_unavailable`: 拿不到当前 toy 上下文
|
||||
* - `author_mismatch`: 请求的资源不属于当前 Toy 作者
|
||||
* - `author_mismatch`: 请求的资源不属于当前 Toy 作者(视频既非其投稿,也未以联合投稿身份参与创作)
|
||||
* - `video_not_found`: 视频不存在
|
||||
* - `video_invisible`: 视频对当前用户不可见
|
||||
* - `unavailable`: 依赖服务不可用
|
||||
@@ -235,7 +266,7 @@ declare namespace ToySDK {
|
||||
/** 回显本次请求传入的引用,用于与请求项对应。 */
|
||||
ref: AuthorVideoRef
|
||||
status: ToyItemStatus
|
||||
/** `status` 非 `ok` 时缺省(如非当前作者、视频不可见)。 */
|
||||
/** `status` 非 `ok` 时缺省(如作者未参与该视频创作、视频不可见)。 */
|
||||
data?: AuthorVideo
|
||||
}
|
||||
|
||||
@@ -359,11 +390,17 @@ declare namespace ToySDK {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 媒体能力
|
||||
// 媒体能力(摄像头 / 麦克风)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 申请摄像头的可选项,仅 `requestCamera` 使用。
|
||||
*
|
||||
* 只接受 `facingMode` 一个字段:传入其他字段、或传非普通对象(数组 / 字符串 / null 等)
|
||||
* 时 SDK 本地抛错。`requestMicrophone` 不接受任何参数。
|
||||
*/
|
||||
interface MediaRelayOptions {
|
||||
/** 摄像头朝向,不传默认使用前置摄像头。 */
|
||||
/** 摄像头朝向:`'user'` 前置、`'environment'` 后置。不传默认前置。 */
|
||||
facingMode?: 'user' | 'environment'
|
||||
}
|
||||
|
||||
@@ -376,7 +413,8 @@ declare namespace ToySDK {
|
||||
* 判断当前环境是否支持指定能力。传能力名(如 `'saveImageToAlbum'`)即可,
|
||||
* 带不带 `toy.` 前缀都能匹配。
|
||||
*
|
||||
* 端外 Web 不支持 `saveImageToAlbum` / `closeBrowser`,其余能力两端一致。
|
||||
* 端外 Web 不支持 `saveImageToAlbum` / `share` / `closeBrowser`,其余能力两端一致
|
||||
* (二维码 `getQrCode` 两端均可用)。
|
||||
*/
|
||||
isSupport(ability: string): Promise<boolean>
|
||||
|
||||
@@ -384,7 +422,7 @@ declare namespace ToySDK {
|
||||
* 跳转到指定页面。
|
||||
*
|
||||
* 必须在用户手势事件(如 click)中调用:SDK 会检查 `navigator.userActivation`,
|
||||
* 无有效用户激活时抛错。端内走 JSB 原生跳转,端外用 `window.open` 新开标签页。
|
||||
* 无有效用户激活时抛错。端内为原生跳转,端外新开标签页。
|
||||
*/
|
||||
navigate(req: NavigateReq): Promise<void>
|
||||
|
||||
@@ -395,20 +433,41 @@ declare namespace ToySDK {
|
||||
*/
|
||||
saveImageToAlbum(req: SaveImageReq): Promise<SaveImageResp>
|
||||
|
||||
/**
|
||||
* 拉起 B站 App 的分享面板。**仅 B站 App 内可用**,Web 端调用直接抛错。
|
||||
*
|
||||
* 只传相对当前 Toy 的 `path`,完整分享链接由平台生成,
|
||||
* 避免分享链接被伪造指向其他 Toy 或外部站点。`path` 越界抛 `invalid_param`,
|
||||
* 页面不在 `/toy/<slug>/` 路径下时抛 `unsupported`。
|
||||
*/
|
||||
share(req: ShareReq): Promise<void>
|
||||
|
||||
/**
|
||||
* 生成指向当前 Toy 内某个页面的二维码,返回可直接用作 `img.src` 的 PNG base64 图片。
|
||||
* **App 端和 Web 端都可用**。
|
||||
*
|
||||
* 用途不限于分享:跨设备接力(PC 上扫码到手机继续玩)、结算页海报、线下展示都适用。
|
||||
* 区别于 `share`,本方法不拉起分享面板,而是把链接编码成二维码交给 Toy 自行展示。
|
||||
* 两个入参都可省略:`toy.getQrCode()` 即当前 Toy 首页的二维码。
|
||||
*
|
||||
* 只能编码当前 Toy 内的页面链接,不能编码任意文本:二维码内容同样由平台生成,
|
||||
* Toy 不能自行指定完整 URL。如需为任意字符串生成二维码,请自行在 Toy 内
|
||||
* 打包二维码库。`path` 越界或 `size` 非法抛 `invalid_param`,
|
||||
* 页面不在 `/toy/<slug>/` 路径下时抛 `unsupported`。
|
||||
*/
|
||||
getQrCode(req?: QrCodeReq): Promise<QrCodeResp>
|
||||
|
||||
/** 关闭当前 WebView 容器。**仅 B站 App 内可用**,Web 端调用直接抛错。 */
|
||||
closeBrowser(): Promise<void>
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的头像与昵称。
|
||||
* 获取当前登录用户的头像、昵称与当前 Toy 内的稳定假名标识。
|
||||
*
|
||||
* 首次调用需由用户手势触发,并由平台展示固定的用户数据确认弹窗(Toy 不能自定义弹窗内容);
|
||||
* 用户拒绝、未登录或在外部手机浏览器中调用时 Promise reject(外部浏览器会先引导打开 B站 App)。
|
||||
* OpenID 模式启用后,已有未过期的 profile v1/v2 授权会直接复用,不重复弹窗;没有有效授权时,首次调用需由用户手势触发,并由平台展示“获取你的昵称、头像和当前 Toy 内用户标识”固定的用户数据确认弹窗(Toy 不能自定义弹窗内容),接受后写入 v2 授权。模式关闭时省略 toyOpenId。
|
||||
* 用户拒绝、未登录或在外部手机浏览器中调用时 Promise reject(外部浏览器会先引导打开 B站 App)。用户资料确认统一使用正文“你的 B站昵称、头像和仅用于当前 Toy 的用户标识,将用于当前 Toy 内展示和关联数据;不会向 Toy 提供你的 UID,也不能用于跨 Toy 识别。”,不区分 v1/v2 文案配置。`toyOpenId` 仅用于当前 Toy 内关联用户,不得写入埋点或公开日志。
|
||||
*/
|
||||
getUserProfile(): Promise<UserProfileResp>
|
||||
|
||||
/** 上报 UP 主自定义的用户动作。 */
|
||||
reportAction(req: ReportActionReq): Promise<void>
|
||||
|
||||
/**
|
||||
* 获取当前 Toy 作者的公开资料、账号统计、稿件数、充电聚合与粉丝勋章配置。
|
||||
* 不能指定作者,固定取当前 Toy 的作者。
|
||||
@@ -416,8 +475,8 @@ declare namespace ToySDK {
|
||||
getAuthorProfile(): Promise<AuthorProfileResp>
|
||||
|
||||
/**
|
||||
* 批量获取当前 Toy 作者的视频公开信息。
|
||||
* 非当前作者或对当前用户不可见的视频,对应 item 只返回 `status`,不含 `data`。
|
||||
* 批量获取当前 Toy 作者的视频公开信息,含作者以联合投稿(共同创作)身份参与的视频。
|
||||
* 作者未参与创作、或对当前用户不可见的视频,对应 item 只返回 `status`,不含 `data`。
|
||||
*/
|
||||
getAuthorVideos(req: AuthorVideosReq): Promise<AuthorVideosResp>
|
||||
|
||||
@@ -431,7 +490,7 @@ declare namespace ToySDK {
|
||||
getAuthorRelation(): Promise<AuthorRelationResp>
|
||||
|
||||
/**
|
||||
* 获取当前访问用户对当前作者视频的点赞、投币、收藏状态。
|
||||
* 获取当前访问用户对当前作者视频(含作者以联合投稿身份参与的视频)的点赞、投币、收藏状态。
|
||||
*
|
||||
* 只校验登录态,不触发用户数据确认弹窗。外部手机浏览器返回
|
||||
* `status: 'unsupported'` 且 `items` 为空数组。
|
||||
@@ -476,18 +535,53 @@ declare namespace ToySDK {
|
||||
getMyRank(req?: MyRankReq): Promise<MyRankResp>
|
||||
|
||||
/**
|
||||
* 申请摄像头业务授权和系统权限,并返回中继的媒体流。
|
||||
* 必须在用户手势事件中调用。
|
||||
* 申请摄像头,返回浏览器原生的实时 `MediaStream`(视频轨),可直接赋给
|
||||
* `<video>` 的 `srcObject` 渲染,或交给 canvas / WebGL 逐帧处理。
|
||||
*
|
||||
* **必须由用户点击等手势事件直接触发**:SDK 会检查 `navigator.userActivation`,
|
||||
* 无有效用户激活时抛错。首次申请由平台展示摄像头业务授权弹窗,同意后按
|
||||
* 「登录用户 + Toy + 设备」记录,后续不重复展示;随后仍会由 App 或浏览器
|
||||
* 展示系统权限框。
|
||||
*
|
||||
* `options` 只接受 `facingMode`,多传字段或传非普通对象会本地抛错。
|
||||
* 用户拒绝业务授权、系统拒绝、设备缺失或被占用时 Promise reject,
|
||||
* `error.name` 为 `BusinessDenied` / `NotAllowedError` / `NotFoundError` /
|
||||
* `NotReadableError` / `AbortError` 等标准错误名。
|
||||
*
|
||||
* 使用结束后必须调用 `toy.stopMedia(stream)` 释放设备,否则摄像头保持占用。
|
||||
*/
|
||||
requestCamera(options?: MediaRelayOptions): Promise<MediaStream>
|
||||
|
||||
/**
|
||||
* 申请麦克风业务授权和系统权限,并返回中继的媒体流。
|
||||
* 不接受参数,且必须在用户手势事件中调用。
|
||||
* 申请麦克风,返回浏览器原生的**实时** `MediaStream`(音频轨)。
|
||||
*
|
||||
* 注意返回值的性质:它是可持续读取的实时音频流,**不是录音文件**
|
||||
* (没有 Blob / 本地路径),**也不是音量数值**。要得到录音文件或音量,
|
||||
* 用浏览器原生 API 在这个流上自行处理,SDK 不提供这两项能力:
|
||||
* - **录音**:用原生 `MediaRecorder` 接收本流并收集数据,自行编码成文件。
|
||||
* - **获取声音大小**:用原生 `AudioContext` 创建 `AnalyserNode`,把本流接入后
|
||||
* 读取频域 / 时域数据自行换算音量。
|
||||
*
|
||||
* **必须由用户点击等手势事件直接触发**:SDK 会检查 `navigator.userActivation`,
|
||||
* 无有效用户激活时抛错。首次申请由平台展示麦克风业务授权弹窗(与摄像头分别
|
||||
* 独立授权),同意后按「登录用户 + Toy + 设备」记录;随后仍会由 App 或浏览器
|
||||
* 展示系统权限框。
|
||||
*
|
||||
* 不接受任何参数,传参会本地抛错。被拒 / 采集失败时的 `error.name` 同
|
||||
* `requestCamera`。使用结束后必须调用 `toy.stopMedia(stream)` 释放设备。
|
||||
*/
|
||||
requestMicrophone(): Promise<MediaStream>
|
||||
|
||||
/** 停止媒体中继并释放摄像头或麦克风设备。 */
|
||||
/**
|
||||
* 释放摄像头 / 麦克风:停止该流对应的会话,并关闭采集设备
|
||||
* (摄像头指示灯熄灭)。
|
||||
*
|
||||
* 参数必须是 `requestCamera` / `requestMicrophone` 返回的那个 `MediaStream`
|
||||
* 实例(SDK 以流对象为句柄定位会话),传入其他值本地抛错;传入已释放或非本
|
||||
* SDK 产生的流不会报错,直接静默返回。
|
||||
*
|
||||
* 摄像头与麦克风各自的流需要分别调用释放。
|
||||
*/
|
||||
stopMedia(stream: MediaStream): Promise<void>
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
},
|
||||
"include": ["**/*"]
|
||||
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
const storage = {
|
||||
get: async (keys: string[]) => {
|
||||
var result = await toy.getCloudStorage(keys)
|
||||
if (result && result.data && typeof result.data === 'object') {
|
||||
return result.data
|
||||
}
|
||||
return result || {}
|
||||
},
|
||||
set: async (record: Record<string, string>) => {
|
||||
await toy.setCloudStorage(record)
|
||||
},
|
||||
}
|
||||
const cloudMap = new Map<string, ToyCloudSaveCoreSDK.IToyCloudSaveInstance>()
|
||||
export async function init() {
|
||||
try {
|
||||
/* @ts-ignore @vite-ignore */
|
||||
await import('//s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js')
|
||||
/* @ts-ignore */
|
||||
const coreModule = await import('./lib/cloud-save-core.js')
|
||||
window.ToyCloudSaveCore =
|
||||
coreModule.ToyCloudSaveCore || coreModule.default || coreModule
|
||||
} catch (error) {
|
||||
console.error('Failed to load Bili Toy SDK:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(key: string) {
|
||||
if (!cloudMap.has(key)) {
|
||||
const cloud = ToyCloudSaveCore.createCloudSave({ storage, prefix: key })
|
||||
cloudMap.set(key, cloud)
|
||||
}
|
||||
return await cloudMap.get(key)!.load()
|
||||
}
|
||||
|
||||
export async function set(key: string, value: string) {
|
||||
if (!cloudMap.has(key)) {
|
||||
const cloud = ToyCloudSaveCore.createCloudSave({ storage, prefix: key })
|
||||
cloudMap.set(key, cloud)
|
||||
}
|
||||
await cloudMap.get(key)!.save(value)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user