feat: implement PI-090 product tools
This commit is contained in:
@@ -1,202 +0,0 @@
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']);
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac']);
|
||||
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_AUDIO_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_MANIFEST_ITEMS = 100;
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
||||
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
|
||||
'.m4a': 'audio/mp4', '.aac': 'audio/aac',
|
||||
};
|
||||
|
||||
type PlanAsset = {
|
||||
id?: unknown; name?: unknown; category?: unknown; status?: unknown; purpose?: unknown;
|
||||
source?: unknown; license?: unknown; localPath?: unknown; previewPath?: unknown;
|
||||
coverPath?: unknown; manifestPaths?: unknown;
|
||||
};
|
||||
|
||||
export type GameAssetCandidate = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
purpose: string;
|
||||
source: string;
|
||||
license: string;
|
||||
localPath?: string;
|
||||
mediaKind: 'image' | 'audio' | 'bundle' | 'unavailable';
|
||||
previewDataUrl?: string;
|
||||
audioDataUrl?: string;
|
||||
manifest: string[];
|
||||
unavailableReason?: string;
|
||||
};
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseMarkdownCells(line: string): string[] | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.includes('|')) return null;
|
||||
const withoutOuterPipes = trimmed.replace(/^\|/, '').replace(/\|$/, '');
|
||||
const cells = withoutOuterPipes.split('|').map((cell) => cell.trim());
|
||||
return cells.length >= 3 ? cells : null;
|
||||
}
|
||||
|
||||
function isMarkdownSeparator(cells: string[]): boolean {
|
||||
return cells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s/g, '')));
|
||||
}
|
||||
|
||||
function markdownAssetSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function inferMarkdownAssetCategory(name: string, source: string, type = ''): string {
|
||||
const combined = `${name} ${source} ${type}`.toLowerCase();
|
||||
if (/音效|音频|音乐|🔊|sound|sfx|music/.test(combined)) return 'audio';
|
||||
if (/字体|界面|ui\b|font/.test(combined)) return 'ui';
|
||||
return 'visual';
|
||||
}
|
||||
|
||||
function parseMarkdownAssetTables(content: string): PlanAsset[] {
|
||||
const assets: PlanAsset[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const headerCells = parseMarkdownCells(lines[lineIndex] ?? '');
|
||||
if (!headerCells) continue;
|
||||
const normalizedHeaders = headerCells.map((cell) => cell.replace(/[\s*_`]/g, '').toLowerCase());
|
||||
const nameIndex = normalizedHeaders.findIndex((cell) => /素材|资源|名称|asset|name/i.test(cell));
|
||||
const idIndex = normalizedHeaders.findIndex((cell) => /^id$|编号|标识|assetid/i.test(cell));
|
||||
const typeIndex = normalizedHeaders.findIndex((cell) => /类型|类别|type|category/i.test(cell));
|
||||
const sourceIndex = normalizedHeaders.findIndex((cell) => /来源|source/i.test(cell));
|
||||
const licenseIndex = normalizedHeaders.findIndex((cell) => /许可证|许可|授权|license/i.test(cell));
|
||||
const statusIndex = normalizedHeaders.findIndex((cell) => /状态|status/i.test(cell));
|
||||
const planIndex = normalizedHeaders.findIndex((cell) => /方案|计划|plan/i.test(cell));
|
||||
const hasSourceLicenseShape = nameIndex >= 0 && sourceIndex >= 0 && licenseIndex >= 0;
|
||||
const hasCandidateStatusShape = idIndex >= 0 && nameIndex >= 0 && statusIndex >= 0;
|
||||
if (!hasSourceLicenseShape && !hasCandidateStatusShape) continue;
|
||||
const lastRequiredIndex = hasSourceLicenseShape
|
||||
? Math.max(nameIndex, sourceIndex, licenseIndex)
|
||||
: Math.max(idIndex, nameIndex, statusIndex);
|
||||
|
||||
for (let rowIndex = lineIndex + 1; rowIndex < lines.length; rowIndex += 1) {
|
||||
const row = parseMarkdownCells(lines[rowIndex] ?? '');
|
||||
if (!row) break;
|
||||
if (isMarkdownSeparator(row)) continue;
|
||||
if (row.length <= lastRequiredIndex) continue;
|
||||
const rowId = idIndex >= 0 ? text(row[idIndex]) : '';
|
||||
const name = text(row[nameIndex]);
|
||||
if (!name) continue;
|
||||
const source = text(row[sourceIndex]);
|
||||
const license = text(row[licenseIndex]);
|
||||
const type = typeIndex >= 0 ? text(row[typeIndex]) : '';
|
||||
const status = statusIndex >= 0 ? text(row[statusIndex]) : '';
|
||||
const plan = planIndex >= 0 ? text(row[planIndex]) : '';
|
||||
const slug = markdownAssetSlug([name, source].filter(Boolean).join('-')) || `candidate-${assets.length + 1}`;
|
||||
assets.push({
|
||||
id: rowId || `asset-${slug}-${assets.length + 1}`,
|
||||
name,
|
||||
category: inferMarkdownAssetCategory(name, source, type),
|
||||
status: status || 'candidate',
|
||||
purpose: type || plan || name,
|
||||
source,
|
||||
license,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
function parsePlan(content: string): PlanAsset[] {
|
||||
for (const match of content.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
||||
try {
|
||||
const value = JSON.parse(match[1] ?? '') as { assets?: unknown };
|
||||
if (Array.isArray(value.assets)) return value.assets as PlanAsset[];
|
||||
} catch {
|
||||
// Continue until the authoritative JSON block is found.
|
||||
}
|
||||
}
|
||||
return parseMarkdownAssetTables(content);
|
||||
}
|
||||
|
||||
async function resolveInsideProject(projectPath: string, candidatePath: string): Promise<string | null> {
|
||||
if (!candidatePath || path.isAbsolute(candidatePath)) return null;
|
||||
const projectRealPath = await realpath(projectPath);
|
||||
const target = path.resolve(projectRealPath, candidatePath);
|
||||
const relative = path.relative(projectRealPath, target);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
try {
|
||||
const targetRealPath = await realpath(target);
|
||||
const realRelative = path.relative(projectRealPath, targetRealPath);
|
||||
return !realRelative.startsWith('..') && !path.isAbsolute(realRelative) ? targetRealPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readMediaDataUrl(projectPath: string, candidatePath: string, maxBytes: number): Promise<string | null> {
|
||||
const resolved = await resolveInsideProject(projectPath, candidatePath);
|
||||
if (!resolved) return null;
|
||||
const fileStat = await stat(resolved);
|
||||
if (!fileStat.isFile() || fileStat.size > maxBytes) return null;
|
||||
const extension = path.extname(resolved).toLowerCase();
|
||||
const mime = MIME_BY_EXTENSION[extension];
|
||||
if (!mime) return null;
|
||||
return `data:${mime};base64,${(await readFile(resolved)).toString('base64')}`;
|
||||
}
|
||||
|
||||
export async function loadGameAssetCandidates(projectPath: string): Promise<GameAssetCandidate[]> {
|
||||
const planContent = await readFile(path.join(projectPath, 'ASSET_PLAN.md'), 'utf8');
|
||||
return await Promise.all(parsePlan(planContent).map(async (asset, index) => {
|
||||
const id = text(asset.id) || `asset-${index + 1}`;
|
||||
const localPath = text(asset.localPath);
|
||||
const previewPath = text(asset.previewPath) || text(asset.coverPath) || localPath;
|
||||
const extension = path.extname(localPath || previewPath).toLowerCase();
|
||||
const category = text(asset.category) || 'other';
|
||||
const manifest = Array.isArray(asset.manifestPaths)
|
||||
? asset.manifestPaths.map(text).filter(Boolean).slice(0, MAX_MANIFEST_ITEMS)
|
||||
: [];
|
||||
const common = {
|
||||
id,
|
||||
name: text(asset.name) || id,
|
||||
category,
|
||||
status: text(asset.status) || 'candidate',
|
||||
purpose: text(asset.purpose),
|
||||
source: text(asset.source),
|
||||
license: text(asset.license),
|
||||
...(localPath ? { localPath } : {}),
|
||||
manifest,
|
||||
};
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(extension) || category === 'visual' || category === 'ui') {
|
||||
const previewDataUrl = await readMediaDataUrl(projectPath, previewPath, MAX_IMAGE_BYTES);
|
||||
return previewDataUrl
|
||||
? { ...common, mediaKind: 'image' as const, previewDataUrl }
|
||||
: { ...common, mediaKind: 'unavailable' as const, unavailableReason: '候选素材还没有可预览的项目内图片' };
|
||||
}
|
||||
if (AUDIO_EXTENSIONS.has(extension) || category === 'audio') {
|
||||
const audioDataUrl = await readMediaDataUrl(projectPath, localPath, MAX_AUDIO_BYTES);
|
||||
return audioDataUrl
|
||||
? { ...common, mediaKind: 'audio' as const, audioDataUrl }
|
||||
: { ...common, mediaKind: 'unavailable' as const, unavailableReason: '候选音频还没有保存到项目内,暂时无法试听' };
|
||||
}
|
||||
const previewDataUrl = previewPath ? await readMediaDataUrl(projectPath, previewPath, MAX_IMAGE_BYTES) : null;
|
||||
return {
|
||||
...common,
|
||||
mediaKind: 'bundle' as const,
|
||||
...(previewDataUrl ? { previewDataUrl } : {}),
|
||||
...(!previewDataUrl && manifest.length === 0 ? { unavailableReason: '请让素材规划师补充封面或文件清单' } : {}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const STATE_FILE_NAME = 'asset-review.json';
|
||||
const STATE_SCHEMA_VERSION = 1;
|
||||
|
||||
export type GameAssetReviewAction = 'approve' | 'discard' | 'replace';
|
||||
export type GameAssetReviewDecision = 'approved' | 'discarded' | 'replace-requested';
|
||||
export type GameAssetReviewStatus = 'pending' | 'resolved';
|
||||
|
||||
export type GameAssetReviewInvocation = {
|
||||
id: string;
|
||||
candidateIds: string[];
|
||||
decisions: Record<string, GameAssetReviewDecision>;
|
||||
status: GameAssetReviewStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type GameAssetReviewState = {
|
||||
schemaVersion: typeof STATE_SCHEMA_VERSION;
|
||||
updatedAt: string;
|
||||
approvedAssetIds: string[];
|
||||
discardedAssetIds: string[];
|
||||
invocations: Record<string, GameAssetReviewInvocation>;
|
||||
};
|
||||
|
||||
export type GameAssetReviewSnapshot = {
|
||||
invocationId: string;
|
||||
candidateIds: string[];
|
||||
decisions: Record<string, GameAssetReviewDecision>;
|
||||
status: GameAssetReviewStatus;
|
||||
pendingAssetIds: string[];
|
||||
approvedAssetIds: string[];
|
||||
discardedAssetIds: string[];
|
||||
};
|
||||
|
||||
export type GameAssetReviewActionInput = {
|
||||
invocationId: string;
|
||||
assetId: string;
|
||||
action: GameAssetReviewAction;
|
||||
candidateIds?: string[];
|
||||
};
|
||||
|
||||
export type GameAssetReviewActionsInput = {
|
||||
invocationId: string;
|
||||
decisions: Array<{ assetId: string; action: GameAssetReviewAction }>;
|
||||
candidateIds?: string[];
|
||||
};
|
||||
|
||||
export class GameAssetReviewConflictError extends Error {
|
||||
readonly statusCode = 409;
|
||||
}
|
||||
|
||||
const stateWriteQueues = new Map<string, Promise<void>>();
|
||||
|
||||
function assetReviewStatePath(projectPath: string): string {
|
||||
return join(projectPath, '.niancode', STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0 && item.length <= 200))];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDecision(value: unknown): GameAssetReviewDecision | null {
|
||||
return value === 'approved' || value === 'discarded' || value === 'replace-requested'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeInvocation(value: unknown): GameAssetReviewInvocation | null {
|
||||
if (!isRecord(value) || typeof value.id !== 'string' || !value.id.trim()) return null;
|
||||
const candidateIds = normalizeIds(value.candidateIds);
|
||||
const rawDecisions = isRecord(value.decisions) ? value.decisions : {};
|
||||
const decisions = Object.fromEntries(
|
||||
Object.entries(rawDecisions)
|
||||
.map(([assetId, decision]) => [assetId, normalizeDecision(decision)] as const)
|
||||
.filter((entry): entry is [string, GameAssetReviewDecision] => entry[1] !== null),
|
||||
);
|
||||
return {
|
||||
id: value.id.trim(),
|
||||
candidateIds,
|
||||
decisions,
|
||||
status: value.status === 'resolved' ? 'resolved' : 'pending',
|
||||
createdAt: typeof value.createdAt === 'string' ? value.createdAt : now(),
|
||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : now(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeState(value: unknown): GameAssetReviewState {
|
||||
if (!isRecord(value)) throw new Error('Invalid game asset review state');
|
||||
const rawInvocations = isRecord(value.invocations) ? value.invocations : {};
|
||||
const invocations = Object.fromEntries(
|
||||
Object.entries(rawInvocations)
|
||||
.map(([id, invocation]) => [id, normalizeInvocation(invocation)] as const)
|
||||
.filter((entry): entry is [string, GameAssetReviewInvocation] => entry[1] !== null),
|
||||
);
|
||||
return {
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : now(),
|
||||
approvedAssetIds: normalizeIds(value.approvedAssetIds),
|
||||
discardedAssetIds: normalizeIds(value.discardedAssetIds),
|
||||
invocations,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyState(): GameAssetReviewState {
|
||||
return {
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
updatedAt: now(),
|
||||
approvedAssetIds: [],
|
||||
discardedAssetIds: [],
|
||||
invocations: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function readState(projectPath: string): Promise<GameAssetReviewState> {
|
||||
try {
|
||||
return normalizeState(JSON.parse(await readFile(assetReviewStatePath(projectPath), 'utf8')) as unknown);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return emptyState();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(projectPath: string, state: GameAssetReviewState): Promise<void> {
|
||||
const directory = join(projectPath, '.niancode');
|
||||
await mkdir(directory, { recursive: true });
|
||||
const target = assetReviewStatePath(projectPath);
|
||||
const temporary = `${target}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify({ ...state, updatedAt: now() }, null, 2)}\n`, 'utf8');
|
||||
await rename(temporary, target);
|
||||
}
|
||||
|
||||
async function withStateLock<T>(projectPath: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = stateWriteQueues.get(projectPath) ?? Promise.resolve();
|
||||
const operation = previous.catch(() => undefined).then(task);
|
||||
const tail = operation.then(() => undefined, () => undefined);
|
||||
stateWriteQueues.set(projectPath, tail);
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
if (stateWriteQueues.get(projectPath) === tail) stateWriteQueues.delete(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
function toSnapshot(state: GameAssetReviewState, invocation: GameAssetReviewInvocation): GameAssetReviewSnapshot {
|
||||
const terminalIds = new Set([
|
||||
...state.approvedAssetIds,
|
||||
...state.discardedAssetIds,
|
||||
...Object.keys(invocation.decisions),
|
||||
]);
|
||||
return {
|
||||
invocationId: invocation.id,
|
||||
candidateIds: [...invocation.candidateIds],
|
||||
decisions: { ...invocation.decisions },
|
||||
status: invocation.status,
|
||||
pendingAssetIds: invocation.candidateIds.filter((assetId) => !terminalIds.has(assetId)),
|
||||
approvedAssetIds: [...state.approvedAssetIds],
|
||||
discardedAssetIds: [...state.discardedAssetIds],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureInvocation(
|
||||
state: GameAssetReviewState,
|
||||
invocationId: string,
|
||||
candidateIds: string[],
|
||||
): GameAssetReviewInvocation {
|
||||
const normalizedId = invocationId.trim();
|
||||
if (!normalizedId || normalizedId.length > 200) throw new Error('Invalid game asset review invocation id');
|
||||
const existing = state.invocations[normalizedId];
|
||||
if (existing) {
|
||||
// Older markers could create an empty or unresolvable invocation before
|
||||
// the plan parser or candidate-id fallback had a chance to project the
|
||||
// current plan. Repair only that untouched record from the current Agent
|
||||
// submission; never rewrite an invocation after a user decision exists.
|
||||
const normalizedCandidateIds = normalizeIds(candidateIds);
|
||||
const matchesCurrentPlan = existing.candidateIds.some((id) => normalizedCandidateIds.includes(id));
|
||||
if (normalizedCandidateIds.length > 0 && !matchesCurrentPlan && Object.keys(existing.decisions).length === 0) {
|
||||
existing.candidateIds = normalizedCandidateIds;
|
||||
existing.status = 'pending';
|
||||
existing.updatedAt = now();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const timestamp = now();
|
||||
const invocation: GameAssetReviewInvocation = {
|
||||
id: normalizedId,
|
||||
candidateIds: normalizeIds(candidateIds),
|
||||
decisions: {},
|
||||
status: 'pending',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
state.invocations[normalizedId] = invocation;
|
||||
return invocation;
|
||||
}
|
||||
|
||||
export function isGameAssetReviewTerminal(state: GameAssetReviewState, assetId: string): boolean {
|
||||
return state.approvedAssetIds.includes(assetId) || state.discardedAssetIds.includes(assetId);
|
||||
}
|
||||
|
||||
export async function loadGameAssetReview(
|
||||
projectPath: string,
|
||||
invocationId: string,
|
||||
candidateIds: string[],
|
||||
): Promise<GameAssetReviewSnapshot> {
|
||||
return await withStateLock(projectPath, async () => {
|
||||
const state = await readState(projectPath);
|
||||
const invocation = ensureInvocation(state, invocationId, candidateIds);
|
||||
await writeState(projectPath, state);
|
||||
return toSnapshot(state, invocation);
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordGameAssetReviewAction(
|
||||
projectPath: string,
|
||||
input: GameAssetReviewActionInput,
|
||||
): Promise<GameAssetReviewSnapshot> {
|
||||
if (!input || typeof input !== 'object') throw new Error('Invalid game asset review action');
|
||||
if (input.action !== 'approve' && input.action !== 'discard' && input.action !== 'replace') {
|
||||
throw new Error('Invalid game asset review action');
|
||||
}
|
||||
const invocationId = typeof input.invocationId === 'string' ? input.invocationId.trim() : '';
|
||||
const assetId = typeof input.assetId === 'string' ? input.assetId.trim() : '';
|
||||
if (!invocationId || !assetId) throw new Error('Game asset review action requires invocationId and assetId');
|
||||
|
||||
return await withStateLock(projectPath, async () => {
|
||||
const state = await readState(projectPath);
|
||||
const invocation = ensureInvocation(state, invocationId, normalizeIds(input.candidateIds));
|
||||
if (!invocation.candidateIds.includes(assetId)) {
|
||||
throw new GameAssetReviewConflictError('This asset is not part of the review invocation');
|
||||
}
|
||||
|
||||
applyGameAssetReviewDecision(state, invocation, assetId, decisionForAction(input.action));
|
||||
await writeState(projectPath, state);
|
||||
return toSnapshot(state, invocation);
|
||||
});
|
||||
}
|
||||
|
||||
function decisionForAction(action: GameAssetReviewAction): GameAssetReviewDecision {
|
||||
return action === 'approve' ? 'approved' : action === 'replace' ? 'replace-requested' : 'discarded';
|
||||
}
|
||||
|
||||
function assertDecisionCanBeApplied(
|
||||
state: GameAssetReviewState,
|
||||
invocation: GameAssetReviewInvocation,
|
||||
assetId: string,
|
||||
nextDecision: GameAssetReviewDecision,
|
||||
): void {
|
||||
if (!invocation.candidateIds.includes(assetId)) {
|
||||
throw new GameAssetReviewConflictError('This asset is not part of the review invocation');
|
||||
}
|
||||
const existingDecision = invocation.decisions[assetId];
|
||||
if (existingDecision && existingDecision !== nextDecision) {
|
||||
throw new GameAssetReviewConflictError('This asset has already been reviewed');
|
||||
}
|
||||
|
||||
const globallyApproved = state.approvedAssetIds.includes(assetId);
|
||||
const globallyDiscarded = state.discardedAssetIds.includes(assetId);
|
||||
if ((globallyApproved && nextDecision !== 'approved') || (globallyDiscarded && nextDecision === 'approved')) {
|
||||
throw new GameAssetReviewConflictError('This asset already has a final review decision');
|
||||
}
|
||||
}
|
||||
|
||||
function updateInvocationStatus(state: GameAssetReviewState, invocation: GameAssetReviewInvocation): void {
|
||||
invocation.status = invocation.candidateIds.every((candidateId) => (
|
||||
state.approvedAssetIds.includes(candidateId)
|
||||
|| state.discardedAssetIds.includes(candidateId)
|
||||
|| Boolean(invocation.decisions[candidateId])
|
||||
)) ? 'resolved' : 'pending';
|
||||
}
|
||||
|
||||
function applyGameAssetReviewDecision(
|
||||
state: GameAssetReviewState,
|
||||
invocation: GameAssetReviewInvocation,
|
||||
assetId: string,
|
||||
nextDecision: GameAssetReviewDecision,
|
||||
): void {
|
||||
assertDecisionCanBeApplied(state, invocation, assetId, nextDecision);
|
||||
const globallyApproved = state.approvedAssetIds.includes(assetId);
|
||||
const globallyDiscarded = state.discardedAssetIds.includes(assetId);
|
||||
invocation.decisions[assetId] = nextDecision;
|
||||
invocation.updatedAt = now();
|
||||
if (nextDecision === 'approved' && !globallyApproved) state.approvedAssetIds.push(assetId);
|
||||
if (nextDecision !== 'approved' && !globallyDiscarded) state.discardedAssetIds.push(assetId);
|
||||
updateInvocationStatus(state, invocation);
|
||||
}
|
||||
|
||||
export async function recordGameAssetReviewActions(
|
||||
projectPath: string,
|
||||
input: GameAssetReviewActionsInput,
|
||||
): Promise<GameAssetReviewSnapshot> {
|
||||
if (!input || typeof input !== 'object' || !Array.isArray(input.decisions) || input.decisions.length === 0) {
|
||||
throw new Error('Game asset review batch requires decisions');
|
||||
}
|
||||
const invocationId = typeof input.invocationId === 'string' ? input.invocationId.trim() : '';
|
||||
if (!invocationId) throw new Error('Game asset review batch requires invocationId');
|
||||
|
||||
return await withStateLock(projectPath, async () => {
|
||||
const state = await readState(projectPath);
|
||||
const invocation = ensureInvocation(state, invocationId, normalizeIds(input.candidateIds));
|
||||
const normalizedDecisions: Array<{ assetId: string; decision: GameAssetReviewDecision }> = [];
|
||||
const seen = new Map<string, GameAssetReviewDecision>();
|
||||
for (const item of input.decisions) {
|
||||
const assetId = typeof item?.assetId === 'string' ? item.assetId.trim() : '';
|
||||
if (!assetId || !['approve', 'discard', 'replace'].includes(item?.action)) {
|
||||
throw new Error('Invalid game asset review batch decision');
|
||||
}
|
||||
const decision = decisionForAction(item.action);
|
||||
const previous = seen.get(assetId);
|
||||
if (previous) {
|
||||
if (previous !== decision) throw new GameAssetReviewConflictError('The batch contains conflicting decisions for one asset');
|
||||
continue;
|
||||
}
|
||||
assertDecisionCanBeApplied(state, invocation, assetId, decision);
|
||||
seen.set(assetId, decision);
|
||||
normalizedDecisions.push({ assetId, decision });
|
||||
}
|
||||
|
||||
for (const { assetId, decision } of normalizedDecisions) {
|
||||
applyGameAssetReviewDecision(state, invocation, assetId, decision);
|
||||
}
|
||||
await writeState(projectPath, state);
|
||||
return toSnapshot(state, invocation);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user