323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
import { Buffer } from 'node:buffer';
|
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins';
|
|
import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets';
|
|
import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store';
|
|
import {
|
|
type BundledHostedRelease,
|
|
MarketplaceHostedAdmissionError,
|
|
MarketplaceHostedAdmissionResolver,
|
|
} from '../hosted-admission';
|
|
import {
|
|
GameResourceClient,
|
|
GameResourceClientError,
|
|
type GameResourceGeneration,
|
|
} from '../../services/game-resource-client';
|
|
import type {
|
|
AdapterInvocationResult,
|
|
CodingPluginAdapter,
|
|
PluginBackendProjection,
|
|
TrustedCodingCapabilityContext,
|
|
} from '../registry';
|
|
|
|
const PLUGIN_ID = 'makelore.game-resource';
|
|
const MAX_REFERENCE_FILE_BYTES = 8 * 1024 * 1024;
|
|
const MAX_REFERENCE_BYTES = 16 * 1024 * 1024;
|
|
const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
|
|
type Input = Record<string, unknown>;
|
|
|
|
export interface GameResourcePluginAdapterOptions {
|
|
readonly client: GameResourceClient;
|
|
readonly marketplace: MarketplacePackageClientPort;
|
|
readonly packageStore: Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>;
|
|
readonly makeloreVersion: string;
|
|
readonly bundledReleases?: Readonly<Record<string, BundledHostedRelease>>;
|
|
readonly admissionResolver?: MarketplaceHostedAdmissionResolver;
|
|
readonly gameAssets?: PiGameAssetTools;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Input {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function success<T>(
|
|
data: T,
|
|
status = 200,
|
|
billing?: Extract<NonNullable<AdapterInvocationResult['billing']>, { mode: 'platform_metered' }>,
|
|
): AdapterInvocationResult<T> {
|
|
return {
|
|
success: true,
|
|
status,
|
|
code: null,
|
|
error: null,
|
|
retryable: false,
|
|
payload_schema: 'game-resource.v1',
|
|
data,
|
|
...(billing ? { billing } : {}),
|
|
};
|
|
}
|
|
|
|
function failure(
|
|
code: string,
|
|
error: string,
|
|
status: number,
|
|
retryable: boolean,
|
|
billing?: Extract<NonNullable<AdapterInvocationResult['billing']>, { mode: 'platform_metered' }>,
|
|
): AdapterInvocationResult {
|
|
return {
|
|
success: false,
|
|
status,
|
|
code,
|
|
error,
|
|
retryable,
|
|
payload_schema: 'game-resource.v1',
|
|
data: null,
|
|
...(billing ? { billing } : {}),
|
|
};
|
|
}
|
|
|
|
function generationData(value: GameResourceGeneration, includeBilling: boolean) {
|
|
return {
|
|
executionId: value.executionId,
|
|
status: value.status,
|
|
outputCount: value.outputCount,
|
|
...(value.pollIntervalSeconds === undefined ? {} : { pollIntervalSeconds: value.pollIntervalSeconds }),
|
|
...(value.errorCode === undefined ? {} : { errorCode: value.errorCode }),
|
|
...(includeBilling ? { generationBilling: value.billing } : {}),
|
|
};
|
|
}
|
|
|
|
function clientFailure(error: unknown): AdapterInvocationResult {
|
|
if (error instanceof GameResourceClientError) {
|
|
return failure(error.code, error.message, error.status, error.retryable);
|
|
}
|
|
if (error instanceof MarketplaceHostedAdmissionError) {
|
|
return failure(error.code, error.message, error.status, error.retryable);
|
|
}
|
|
return failure(
|
|
'plugin_backend_unavailable',
|
|
'Hosted game-resource service is temporarily unavailable',
|
|
503,
|
|
true,
|
|
);
|
|
}
|
|
|
|
function relativeProjectPath(projectPath: string, candidate: unknown): { absolute: string; relative: string } {
|
|
if (typeof candidate !== 'string' || !candidate.trim() || candidate.length > 1_024) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Project-relative path is invalid');
|
|
}
|
|
const root = path.resolve(projectPath);
|
|
const absolute = path.resolve(root, candidate);
|
|
const relative = path.relative(root, absolute);
|
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Project-relative path is invalid');
|
|
}
|
|
return { absolute, relative: relative.split(path.sep).join('/') };
|
|
}
|
|
|
|
function mimeType(filePath: string): string {
|
|
switch (path.extname(filePath).toLowerCase()) {
|
|
case '.jpg':
|
|
case '.jpeg': return 'image/jpeg';
|
|
case '.webp': return 'image/webp';
|
|
case '.gif': return 'image/gif';
|
|
default: return 'image/png';
|
|
}
|
|
}
|
|
|
|
async function references(projectPath: string, value: unknown) {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value) || value.length > 8) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference paths are invalid');
|
|
}
|
|
let total = 0;
|
|
return await Promise.all(value.map(async (candidate) => {
|
|
const target = relativeProjectPath(projectPath, candidate);
|
|
const metadata = await stat(target.absolute).catch(() => null);
|
|
if (!metadata?.isFile() || metadata.size <= 0 || metadata.size > MAX_REFERENCE_FILE_BYTES) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference file is unavailable or too large');
|
|
}
|
|
total += metadata.size;
|
|
if (total > MAX_REFERENCE_BYTES) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference files exceed their total bound');
|
|
}
|
|
return {
|
|
name: path.basename(target.absolute).slice(0, 160),
|
|
mimeType: mimeType(target.absolute),
|
|
dataBase64: (await readFile(target.absolute)).toString('base64'),
|
|
};
|
|
}));
|
|
}
|
|
|
|
function templateConfig(value: unknown): Readonly<Record<string, unknown>> {
|
|
if (value === undefined || value === '') return {};
|
|
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 16_384) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON is invalid');
|
|
}
|
|
let parsed: unknown;
|
|
try { parsed = JSON.parse(value) as unknown; } catch {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON is invalid');
|
|
}
|
|
if (!isRecord(parsed)) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON must be an object');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export class GameResourcePluginAdapter implements CodingPluginAdapter {
|
|
readonly pluginId = PLUGIN_ID;
|
|
private readonly gameAssets: PiGameAssetTools;
|
|
private readonly admissionResolver: MarketplaceHostedAdmissionResolver;
|
|
|
|
constructor(private readonly options: GameResourcePluginAdapterOptions) {
|
|
this.gameAssets = options.gameAssets ?? new PiGameAssetTools();
|
|
this.admissionResolver = options.admissionResolver ?? new MarketplaceHostedAdmissionResolver({
|
|
marketplace: options.marketplace,
|
|
packageStore: options.packageStore,
|
|
makeloreVersion: options.makeloreVersion,
|
|
bundledReleases: options.bundledReleases,
|
|
});
|
|
}
|
|
|
|
async inspect(): Promise<PluginBackendProjection> {
|
|
if (this.options.bundledReleases?.[PLUGIN_ID]) return { status: 'ready' };
|
|
const installed = await this.options.packageStore.getInstalled(PLUGIN_ID).catch(() => null);
|
|
return installed ? { status: 'ready' } : { status: 'unconfigured' };
|
|
}
|
|
|
|
async invoke(
|
|
context: TrustedCodingCapabilityContext,
|
|
tool: CodingPluginToolDefinition,
|
|
input: unknown,
|
|
): Promise<AdapterInvocationResult> {
|
|
if (!isRecord(input)) return failure('plugin_input_invalid', 'Game-resource tool input is invalid', 422, false);
|
|
try {
|
|
switch (tool.name) {
|
|
case 'game_resource_templates': {
|
|
const kind = input.kind === 'pixel' || input.kind === 'hd' ? input.kind : null;
|
|
if (!kind) return failure('plugin_input_invalid', 'Game-resource kind is invalid', 422, false);
|
|
const admission = await this.admission(context);
|
|
return success({ kind, templates: await this.options.client.templates({ ...admission, kind }) });
|
|
}
|
|
case 'game_resource_generate': {
|
|
if (input.confirmed !== true) {
|
|
return failure(
|
|
'confirmation_required',
|
|
'Explicit Token Point generation confirmation is required',
|
|
400,
|
|
false,
|
|
);
|
|
}
|
|
const kind = input.kind === 'pixel' || input.kind === 'hd' ? input.kind : null;
|
|
if (!kind || typeof input.templateName !== 'string' || typeof input.requirement !== 'string') {
|
|
return failure('plugin_input_invalid', 'Game-resource generation input is invalid', 422, false);
|
|
}
|
|
const admission = await this.admission(context);
|
|
const generated = await this.options.client.generate({
|
|
...admission,
|
|
projectId: context.durableProjectId,
|
|
logicalOperationId: context.requestId,
|
|
kind,
|
|
templateName: input.templateName,
|
|
templateConfig: templateConfig(input.templateConfigJson),
|
|
requirement: input.requirement,
|
|
...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}),
|
|
...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}),
|
|
...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}),
|
|
...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}),
|
|
...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}),
|
|
...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}),
|
|
...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}),
|
|
referenceFiles: await references(context.projectPath, input.referencePaths),
|
|
});
|
|
if (generated.status === 'failed' || generated.status === 'cancelled') {
|
|
return failure(
|
|
generated.errorCode ?? 'game_resource_generation_failed',
|
|
'Hosted game-resource generation was rejected',
|
|
422,
|
|
false,
|
|
generated.billing,
|
|
);
|
|
}
|
|
return success(
|
|
generationData(generated, false),
|
|
generated.status === 'succeeded' ? 200 : 202,
|
|
generated.billing,
|
|
);
|
|
}
|
|
case 'game_resource_status': {
|
|
const executionId = this.executionId(input.executionId);
|
|
const generated = await this.options.client.get(executionId);
|
|
return success(generationData(generated, true));
|
|
}
|
|
case 'game_resource_cancel': {
|
|
const executionId = this.executionId(input.executionId);
|
|
const generated = await this.options.client.cancel(executionId);
|
|
return success(generationData(generated, true));
|
|
}
|
|
case 'game_resource_save_output': {
|
|
if (input.confirmed !== true) return failure('confirmation_required', 'Explicit save confirmation is required', 400, false);
|
|
const executionId = this.executionId(input.executionId);
|
|
const outputIndex = input.outputIndex === undefined ? undefined : input.outputIndex;
|
|
if (outputIndex !== undefined && (!Number.isSafeInteger(outputIndex) || (outputIndex as number) < 0 || (outputIndex as number) > 99)) {
|
|
return failure('plugin_input_invalid', 'Game-resource output index is invalid', 422, false);
|
|
}
|
|
const target = relativeProjectPath(context.projectPath, input.relativePath);
|
|
const content = await this.options.client.download(executionId, outputIndex as number | undefined);
|
|
await mkdir(path.dirname(target.absolute), { recursive: true });
|
|
try {
|
|
await writeFile(target.absolute, content.bytes, { flag: 'wx' });
|
|
} catch (error) {
|
|
if (isRecord(error) && error.code === 'EEXIST') {
|
|
return failure('game_resource_destination_exists', 'Destination file already exists', 409, false);
|
|
}
|
|
throw error;
|
|
}
|
|
return success({ savedPath: target.relative, bytes: content.bytes.byteLength });
|
|
}
|
|
case 'game_asset_browser': {
|
|
const result = await this.gameAssets.browse(context.projectPath, input, context.requestId);
|
|
const parsed = JSON.parse(result.content[0]?.text ?? '{}') as unknown;
|
|
return success(parsed);
|
|
}
|
|
case 'game_asset_review': {
|
|
const result = await this.gameAssets.review(context.projectPath, input, context.requestId);
|
|
return success(result.details);
|
|
}
|
|
default:
|
|
return failure('plugin_contract_unsupported', 'Game-resource operation is unavailable', 503, true);
|
|
}
|
|
} catch (error) {
|
|
return clientFailure(error);
|
|
}
|
|
}
|
|
|
|
private executionId(value: unknown): string {
|
|
if (typeof value !== 'string' || !EXECUTION_ID.test(value)) {
|
|
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Execution ID is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private async admission(context: TrustedCodingCapabilityContext): Promise<{
|
|
releaseId: string;
|
|
releaseAdmissionId: string;
|
|
}> {
|
|
return this.admissionResolver.resolve({
|
|
pluginId: PLUGIN_ID,
|
|
workerSnapshot: {
|
|
requestId: context.requestId,
|
|
...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export function createGameResourcePluginAdapter(
|
|
options: GameResourcePluginAdapterOptions,
|
|
): GameResourcePluginAdapter {
|
|
return new GameResourcePluginAdapter(options);
|
|
}
|