feat(s4): add web package builder

This commit is contained in:
zizi 2026-06-05 11:09:52 +08:00
parent 9b3d13c43a
commit 574ebc3fa1
3 changed files with 1182 additions and 0 deletions

View File

@ -0,0 +1,415 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import type { GameIRArtifact } from "@huijing/shared-contracts/game-ir";
import { validateAssetManifest, validateGamePackage } from "../../../../../packages/shared-contracts/src/game-package.js";
import { GameConfigCompilerService, type GameConfig, type GameIRReadBoundary } from "../game-config/index.js";
import { FileSystemGameLogicArtifactRepository, checksumText, type ValidationReport } from "../game-logic/artifact-repository.js";
import { GameLogicCompilerService, type GameLogicCompileResult, type GameLogicModuleDescriptor } from "../game-logic/index.js";
import { validateAndWriteGameLogicModuleArtifact } from "../game-logic/game-logic-validator.js";
import {
FileSystemGamePackageArtifactRepository,
GamePackageBuilderService,
type GamePackageAssetInput,
type InternalWebProfileManifest
} from "./index.js";
const fixturesRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "../simulation/fixtures");
const buildInputPath = "apps/api/src/modules/game-package/fixtures/simulation-fixture-v1.game-config.json";
const ownerActor = {
id: "seed-creator",
email: "creator@example.test",
displayName: "Seed Creator",
roles: ["creator"]
};
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("S4 Web Package Builder", () => {
it("builds web profile from validated GameConfig and GameLogicModule", async () => {
const context = await createBuilderContext();
const result = await context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: createAssets()
});
expect(validateGamePackage(result.gamePackage)).toEqual({ ok: true });
expect(validateAssetManifest(result.assetManifest)).toEqual({ ok: true });
expect(result.gamePackage).toMatchObject({
id: `game-package-${context.config.versionId}`,
gameVersionId: context.config.versionId,
assetManifestId: `asset-manifest-${context.config.versionId}`,
profiles: {
web: {
status: "built",
artifactPath: `dist/packages/${context.config.versionId}/web`
}
}
});
expect(result.internalManifest.logicModuleId).toBe(context.logic.descriptor.id);
expect(result.internalManifest.sourceArtifactPath).toBe(context.logic.descriptor.artifactPath);
expect(result.internalManifest.logicArtifactPath).toBe("logic/logic.mjs");
expect(result.internalManifest.logicArtifactPath).toBe(result.internalManifest.packageLogicPath);
expect(result.internalManifest.logicArtifactPath).not.toBe(context.logic.descriptor.artifactPath);
const packagedLogic = await context.packageRepository.readTextFile(`${result.packagePath}/logic/logic.mjs`);
expect(packagedLogic).toBe(context.logic.source);
expect(checksumText(packagedLogic)).toBe(context.logic.descriptor.checksum);
});
it("generates GamePackage.web.manifest internal file", async () => {
const context = await createBuilderContext();
const result = await context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: createAssets()
});
const manifest = JSON.parse(await context.packageRepository.readTextFile(`${result.packagePath}/manifest.json`)) as InternalWebProfileManifest;
expect(manifest).toMatchObject({
entry: "runtime/index.js",
configPath: "config.json",
logicArtifactPath: "logic/logic.mjs",
assetManifestPath: "asset-manifest.json",
runtimeVersion: "web-runtime-mvp-v1",
logicModuleId: context.logic.descriptor.id,
sourceArtifactPath: context.logic.descriptor.artifactPath,
sourceChecksum: context.logic.descriptor.checksum,
validationReportId: context.logic.descriptor.validationReportId,
packageLogicPath: "logic/logic.mjs",
packageLogicChecksum: context.logic.descriptor.checksum
});
expect(manifest.checksum).toBe(result.gamePackage.profiles.web.manifest?.checksum);
expect(result.checksumSummary.files["manifest.json"]).toBe(checksumText(await context.packageRepository.readTextFile(`${result.packagePath}/manifest.json`)));
expect(result.checksumSummary.declaredManifestChecksum).toBe(manifest.checksum);
});
it("generates AssetManifest with hashes", async () => {
const context = await createBuilderContext();
const assets = createAssets();
const result = await context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets
});
const assetManifest = JSON.parse(await context.packageRepository.readTextFile(`${result.packagePath}/asset-manifest.json`));
expect(validateAssetManifest(assetManifest)).toEqual({ ok: true });
expect(assetManifest.entries).toEqual([
{
path: "assets/facility.png",
hash: checksumBytes(assets[0]!.content),
sizeBytes: Buffer.byteLength(assets[0]!.content),
type: "image"
},
{
path: "assets/balance.json",
hash: checksumBytes(assets[1]!.content),
sizeBytes: Buffer.byteLength(assets[1]!.content),
type: "json"
}
]);
await expect(context.packageRepository.readTextFile(`${result.packagePath}/assets/facility.png`)).resolves.toBe(assets[0]!.content);
});
it("initializes wechat/douyin/kuaishou profiles as pending_conversion without artifactPath", async () => {
const context = await createBuilderContext();
const result = await context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: createAssets()
});
expect(result.gamePackage.profiles.wechat_minigame).toEqual({ status: "pending_conversion" });
expect(result.gamePackage.profiles.douyin_minigame).toEqual({ status: "pending_conversion" });
expect(result.gamePackage.profiles.kuaishou_minigame).toEqual({ status: "pending_conversion" });
});
it("rejects external script asset", async () => {
const context = await createBuilderContext();
await expect(
context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: [{ path: "https://cdn.example.com/runtime.js", type: "script", content: "export {};" }]
})
).rejects.toMatchObject({
response: {
code: "GAME_PACKAGE_BUILD_FAILED",
details: {
reasonCode: "ASSET_MANIFEST_ENTRY_PATH_INVALID",
path: "assets.0.path"
}
}
});
});
it("rejects checksum mismatch", async () => {
const context = await createBuilderContext();
await expect(
context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: [
{
path: "assets/facility.png",
type: "image",
content: "actual-bytes",
expectedChecksum: checksumText("different-bytes")
}
]
})
).rejects.toMatchObject({
response: {
code: "GAME_PACKAGE_BUILD_FAILED",
details: {
reasonCode: "GAME_PACKAGE_ASSET_CHECKSUM_MISMATCH",
path: "assets.0.hash"
}
}
});
});
it("rejects GameLogicModule whose ValidationReport is missing or checksum-mismatched", async () => {
const missingReport = await createBuilderContext({ writeValidationReport: false });
await expect(
missingReport.builder.buildWebPackage({
config: missingReport.config,
logicModule: missingReport.logic.descriptor,
assets: createAssets()
})
).rejects.toMatchObject({
response: {
code: "GAME_PACKAGE_BUILD_FAILED",
details: {
reasonCode: "GAME_LOGIC_VALIDATION_REPORT_MISSING",
path: "validationReportId"
}
}
});
const mismatchedReport = await createBuilderContext({ reportOverrides: { checksum: checksumText("tampered-report") } });
await expect(
mismatchedReport.builder.buildWebPackage({
config: mismatchedReport.config,
logicModule: mismatchedReport.logic.descriptor,
assets: createAssets()
})
).rejects.toMatchObject({
response: {
code: "GAME_PACKAGE_BUILD_FAILED",
details: {
reasonCode: "GAME_LOGIC_CONSUMABLE_CHECKSUM_MISMATCH",
path: "validationReportId"
}
}
});
});
it("rejects unsupported RuntimeSdkContract version before packaging", async () => {
const context = await createBuilderContext({
descriptorOverrides: { runtimeSdkContractVersion: "unsupported-runtime-sdk-contract-v0" }
});
await expectBuildFailure(context, "S4_RUNTIME_SDK_CONTRACT_VERSION_INVALID", "runtimeSdkContractVersion");
});
it("rejects ValidationReport sourceHash mismatch with sourceRef build input", async () => {
const context = await createBuilderContext({
reportOverrides: { sourceHash: checksumText("tampered-build-input") }
});
await expectBuildFailure(context, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath");
});
it("rejects ValidationReport.result that is not passed", async () => {
const context = await createBuilderContext({
reportOverrides: { result: "failed", reasonCode: "FORBIDDEN_DOM_BOM_API", path: "source" }
});
await expectBuildFailure(context, "GAME_LOGIC_VALIDATION_REPORT_FAILED", "validationReportId");
});
it("rejects repository artifact tampering after validation", async () => {
const context = await createBuilderContext();
await context.logicRepository.writeLogicArtifact({
artifactPath: context.logic.descriptor.artifactPath,
source: `${context.logic.source}\nexport const tamperedAfterValidation = true;\n`
});
await expectBuildFailure(context, "GAME_LOGIC_CONSUMABLE_CHECKSUM_MISMATCH", "validationReportId");
});
it.each([
["phaser", "FULL_ENGINE_DEPENDENCY_FORBIDDEN"],
["./dist/game.bundle.js", "BUNDLE_IMPORT_FORBIDDEN"]
])("rejects full engine dependencies or bundle imports in P0 package: %s", async (importName, reasonCode) => {
const context = await createBuilderContext({
reportOverrides: {
importGraph: { "logic.mjs": [importName] }
}
});
await expect(
context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: createAssets()
})
).rejects.toMatchObject({
response: {
code: "GAME_PACKAGE_BUILD_FAILED",
details: {
reasonCode,
path: "logic.importGraph.logic.mjs.0"
}
}
});
});
it.each([
["phaser", "FULL_ENGINE_DEPENDENCY_FORBIDDEN"],
["./dist/game.bundle.js", "BUNDLE_IMPORT_FORBIDDEN"]
])("rejects static source import even when ValidationReport importGraph is clean: %s", async (importName, reasonCode) => {
const context = await createBuilderContext({
sourceOverride: renderBlockedStaticImportSource(importName),
reportOverrides: {
importGraph: { "logic.mjs": ["@huijing/runtime-sdk"] }
}
});
await expectBuildFailure(context, reasonCode, "logic.sourceImports.0");
});
});
type BuilderContextOptions = {
readonly writeValidationReport?: boolean;
readonly reportOverrides?: Partial<ValidationReport>;
readonly descriptorOverrides?: Partial<GameLogicModuleDescriptor>;
readonly sourceOverride?: string;
};
type BuilderContext = {
readonly config: GameConfig;
readonly logic: GameLogicCompileResult;
readonly logicRepository: FileSystemGameLogicArtifactRepository;
readonly packageRepository: FileSystemGamePackageArtifactRepository;
readonly builder: GamePackageBuilderService;
};
async function createBuilderContext(options: BuilderContextOptions = {}): Promise<BuilderContext> {
const artifact = await readArtifactFixture("simulation-game-ir-artifact-valid.json");
const config = await compileFixtureConfig(artifact);
const logicRepository = await createLogicRepository();
const packageRepository = await createPackageRepository();
let logic = await new GameLogicCompilerService(logicRepository).compileGameLogicModule(config, { buildInputPath });
if (options.writeValidationReport !== false) {
await expect(validateAndWriteGameLogicModuleArtifact(logicRepository, logic.descriptor)).resolves.toEqual({ status: "valid" });
let report = await logicRepository.readValidationReport(logic.descriptor.validationReportId);
expect(report).not.toBeNull();
if (options.sourceOverride !== undefined) {
const writeResult = await logicRepository.writeLogicArtifact({ artifactPath: logic.descriptor.artifactPath, source: options.sourceOverride });
logic = {
...logic,
source: options.sourceOverride,
descriptor: { ...logic.descriptor, checksum: writeResult.checksum }
};
report = { ...(report as ValidationReport), checksum: writeResult.checksum };
}
await logicRepository.writeValidationReport({ ...(report as ValidationReport), ...options.reportOverrides });
} else if (options.sourceOverride !== undefined) {
const writeResult = await logicRepository.writeLogicArtifact({ artifactPath: logic.descriptor.artifactPath, source: options.sourceOverride });
logic = {
...logic,
source: options.sourceOverride,
descriptor: { ...logic.descriptor, checksum: writeResult.checksum }
};
}
if (options.descriptorOverrides !== undefined) {
logic = { ...logic, descriptor: { ...logic.descriptor, ...options.descriptorOverrides } };
}
return {
config,
logic,
logicRepository,
packageRepository,
builder: new GamePackageBuilderService(logicRepository, packageRepository)
};
}
async function readArtifactFixture(name: string): Promise<GameIRArtifact> {
const fixture = JSON.parse(await readFile(path.join(fixturesRoot, name), "utf8")) as GameIRArtifact;
return { ...fixture, checksum: stableJsonChecksum(fixture.payload) };
}
async function compileFixtureConfig(artifact: GameIRArtifact): Promise<GameConfig> {
const boundary: GameIRReadBoundary = {
async getCurrentValidatedArtifact() {
return artifact;
}
};
return new GameConfigCompilerService(boundary).compileCurrentGameConfig(ownerActor, artifact.projectId, artifact.versionId);
}
async function createLogicRepository(): Promise<FileSystemGameLogicArtifactRepository> {
const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s4-task2-logic-"));
tempRoots.push(root);
return new FileSystemGameLogicArtifactRepository({ root });
}
async function createPackageRepository(): Promise<FileSystemGamePackageArtifactRepository> {
const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s4-task2-package-"));
tempRoots.push(root);
return new FileSystemGamePackageArtifactRepository({ root });
}
function createAssets(): GamePackageAssetInput[] {
return [
{ path: "assets/facility.png", type: "image", content: "facility-png-bytes" },
{ path: "assets/balance.json", type: "json", content: JSON.stringify({ coffee: 1, coin: 8 }) }
];
}
async function expectBuildFailure(context: BuilderContext, reasonCode: string, failurePath: string): Promise<void> {
await expect(
context.builder.buildWebPackage({
config: context.config,
logicModule: context.logic.descriptor,
assets: createAssets()
})
).rejects.toMatchObject({
response: {
code: "GAME_PACKAGE_BUILD_FAILED",
details: {
reasonCode,
path: failurePath
}
}
});
}
function renderBlockedStaticImportSource(importName: string): string {
return [`import blockedDependency from "${importName}";`, "void blockedDependency;", "export function init() { return {}; }", ""].join("\n");
}
function stableJsonChecksum(value: unknown): string {
return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
}
function checksumBytes(value: string | Uint8Array): string {
return `sha256:${createHash("sha256").update(typeof value === "string" ? Buffer.from(value, "utf8") : value).digest("hex")}`;
}

View File

@ -0,0 +1,622 @@
import { Inject, Injectable, Module, UnprocessableEntityException } from "@nestjs/common";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ts from "typescript";
import type { GameConfig } from "../game-config/index.js";
import {
GAME_LOGIC_ARTIFACT_REPOSITORY,
GameLogicCompilerModule,
readConsumableGameLogicModule,
type ConsumableGameLogicModule,
type GameLogicModuleDescriptor
} from "../game-logic/index.js";
import {
FileSystemGameLogicArtifactRepository,
GameLogicArtifactRepositoryError,
checksumText,
isRepositoryRelativePath,
type GameLogicArtifactRepository,
type ValidationReport
} from "../game-logic/artifact-repository.js";
import { apiError } from "../projects/api-runtime.js";
export type GamePackageValidationResult = { ok: true } | { ok: false; reasonCode: string; path?: string };
export type AssetManifestEntry = {
readonly path: string;
readonly hash: string;
readonly sizeBytes: number;
readonly type: "image" | "audio" | "json" | "script";
};
export type AssetManifest = {
readonly id: string;
readonly entries: readonly AssetManifestEntry[];
};
export type WebProfileManifest = {
readonly entry: string;
readonly configPath: string;
readonly logicArtifactPath: string;
readonly assetManifestPath: string;
readonly runtimeVersion: string;
readonly checksum: string;
};
export type GamePackage = {
readonly id: string;
readonly gameVersionId: string;
readonly profiles: {
readonly web: {
readonly status: "built";
readonly artifactPath: string;
readonly manifest: WebProfileManifest;
};
readonly wechat_minigame: { readonly status: "pending_conversion" };
readonly douyin_minigame: { readonly status: "pending_conversion" };
readonly kuaishou_minigame: { readonly status: "pending_conversion" };
};
readonly assetManifestId: string;
readonly artifactChecksums: Record<string, string>;
};
type SharedContractsRuntime = {
readonly RuntimeSdkContractVersion: string;
readonly validateAssetManifest: (value: unknown) => GamePackageValidationResult;
readonly validateAssetManifestEntry: (value: unknown) => GamePackageValidationResult;
readonly validateGamePackage: (value: unknown) => GamePackageValidationResult;
};
export type GamePackageAssetInput = {
readonly path: string;
readonly type: AssetManifestEntry["type"];
readonly content: string | Uint8Array;
readonly expectedChecksum?: string;
};
export type InternalWebProfileManifest = WebProfileManifest & {
readonly logicModuleId: string;
readonly sourceArtifactPath: string;
readonly sourceChecksum: string;
readonly validationReportId: string;
readonly packageLogicPath: string;
readonly packageLogicChecksum: string;
};
export type GamePackageChecksumSummary = {
readonly id: string;
readonly packagePath: string;
readonly files: Record<string, string>;
readonly sourceLogic: {
readonly artifactPath: string;
readonly checksum: string;
readonly validationReportId: string;
};
readonly packageLogic: {
readonly path: string;
readonly checksum: string;
};
readonly declaredManifestChecksum: string;
};
export type GamePackageBuildInput = {
readonly config: GameConfig;
readonly logicModule: GameLogicModuleDescriptor;
readonly assets: readonly GamePackageAssetInput[];
};
export type GamePackageBuildResult = {
readonly gamePackage: GamePackage;
readonly assetManifest: AssetManifest;
readonly internalManifest: InternalWebProfileManifest;
readonly checksumSummary: GamePackageChecksumSummary;
readonly packagePath: string;
};
export const GAME_PACKAGE_ARTIFACT_REPOSITORY = Symbol("GAME_PACKAGE_ARTIFACT_REPOSITORY");
const DEFAULT_PACKAGE_ROOT = path.join(os.tmpdir(), "huijing-s4-game-packages");
const WEB_RUNTIME_VERSION = "web-runtime-mvp-v1";
const WEB_ENTRY_PATH = "runtime/index.js";
const CONFIG_PATH = "config.json";
const LOGIC_PACKAGE_PATH = "logic/logic.mjs";
const ASSET_MANIFEST_PATH = "asset-manifest.json";
const MANIFEST_PATH = "manifest.json";
const CHECKSUM_SUMMARY_PATH = "checksum-summary.json";
const SHARED_CONTRACTS_MODULE: string = "@huijing/shared-contracts";
const FULL_ENGINE_IMPORT_PATTERNS = [
/^phaser(?:\/|$)/i,
/^gdevelop(?:\/|$)/i,
/^ct-js(?:\/|$)/i,
/^cocos(?:\/|$)/i,
/^@cocos\//i,
/^laya(?:\/|$)/i,
/^layaair(?:\/|$)/i,
/^pixi\.js(?:\/|$)/i,
/^@pixi\//i
] as const;
type PackageFileWriteResult = {
readonly checksum: string;
readonly sizeBytes: number;
};
type P0ImportCandidate = {
readonly importName: string;
readonly path: string;
};
export class FileSystemGamePackageArtifactRepository {
readonly root: string;
constructor(options: { readonly root?: string } = {}) {
this.root = path.resolve(options.root ?? DEFAULT_PACKAGE_ROOT);
}
async writeTextFile(repositoryPath: string, content: string): Promise<PackageFileWriteResult> {
return this.writeFile(repositoryPath, Buffer.from(content, "utf8"));
}
async writeBinaryFile(repositoryPath: string, content: string | Uint8Array): Promise<PackageFileWriteResult> {
return this.writeFile(repositoryPath, bytesFrom(content));
}
async readTextFile(repositoryPath: string): Promise<string> {
return readFile(this.resolveRepositoryPath(repositoryPath), "utf8");
}
private async writeFile(repositoryPath: string, content: Uint8Array): Promise<PackageFileWriteResult> {
const targetPath = this.resolveRepositoryPath(repositoryPath);
await atomicWriteFile(targetPath, content);
return { checksum: checksumBytes(content), sizeBytes: content.byteLength };
}
private resolveRepositoryPath(repositoryPath: string): string {
// Web 包仓储同样只接受 repository-relative path防止 package builder 被资产路径诱导写出工作区。
if (!isRepositoryRelativePath(repositoryPath)) {
throwPackageBuildFailure("GAME_PACKAGE_ARTIFACT_PATH_UNSAFE", "Game package artifact path is outside repository boundary", {
path: repositoryPath
});
}
const resolved = path.resolve(this.root, repositoryPath);
if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) {
throwPackageBuildFailure("GAME_PACKAGE_ARTIFACT_PATH_UNSAFE", "Resolved package artifact path escaped repository root", {
path: repositoryPath
});
}
return resolved;
}
}
@Injectable()
export class GamePackageBuilderService {
constructor(
@Inject(GAME_LOGIC_ARTIFACT_REPOSITORY)
private readonly logicRepository: GameLogicArtifactRepository = new FileSystemGameLogicArtifactRepository(),
@Inject(GAME_PACKAGE_ARTIFACT_REPOSITORY)
private readonly packageRepository: FileSystemGamePackageArtifactRepository = new FileSystemGamePackageArtifactRepository()
) {}
async buildWebPackage(input: GamePackageBuildInput): Promise<GamePackageBuildResult> {
const sharedContracts = await loadSharedContracts();
assertConfigAndLogicBinding(input.config, input.logicModule);
const packageSegment = safeSegment(input.config.versionId, "versionId");
const packagePath = `dist/packages/${packageSegment}/web`;
const consumable = await readPackageConsumableLogic(this.logicRepository, input.logicModule);
const validationReport = requireConsumableValidationReport(consumable);
assertRuntimeContract(sharedContracts, consumable.descriptor);
await assertValidationReportSourceHash(this.logicRepository, consumable.descriptor, validationReport);
assertP0PackageImportGate(consumable.descriptor, validationReport, consumable.source);
const fileChecksums: Record<string, string> = {};
fileChecksums[CONFIG_PATH] = (await this.packageRepository.writeTextFile(`${packagePath}/${CONFIG_PATH}`, stableJsonText(input.config))).checksum;
fileChecksums[LOGIC_PACKAGE_PATH] = (await this.packageRepository.writeTextFile(`${packagePath}/${LOGIC_PACKAGE_PATH}`, consumable.source)).checksum;
if (fileChecksums[LOGIC_PACKAGE_PATH] !== consumable.checksum) {
throwPackageBuildFailure("GAME_PACKAGE_LOGIC_COPY_CHECKSUM_MISMATCH", "Copied package logic checksum does not match validated S3 source", {
path: LOGIC_PACKAGE_PATH,
sourceChecksum: consumable.checksum,
packageChecksum: fileChecksums[LOGIC_PACKAGE_PATH]
});
}
const assetManifest = await this.writeAssets(sharedContracts, packagePath, input.config.versionId, input.assets, fileChecksums);
const assetManifestText = stableJsonText(assetManifest);
fileChecksums[ASSET_MANIFEST_PATH] = (await this.packageRepository.writeTextFile(`${packagePath}/${ASSET_MANIFEST_PATH}`, assetManifestText)).checksum;
const manifest = createInternalManifest(input.logicModule, consumable, fileChecksums[LOGIC_PACKAGE_PATH]);
const manifestText = stableJsonText(manifest);
fileChecksums[MANIFEST_PATH] = (await this.packageRepository.writeTextFile(`${packagePath}/${MANIFEST_PATH}`, manifestText)).checksum;
const checksumSummary = createChecksumSummary(input.config.versionId, packagePath, fileChecksums, input.logicModule, manifest);
await this.packageRepository.writeTextFile(`${packagePath}/${CHECKSUM_SUMMARY_PATH}`, stableJsonText(checksumSummary));
const gamePackage = createGamePackage(input.config.versionId, packagePath, assetManifest.id, manifest, checksumSummary.files);
const packageValidation = sharedContracts.validateGamePackage(gamePackage);
if (!packageValidation.ok) throwValidationFailure(packageValidation, "GamePackage contract validation failed", "gamePackage");
return {
gamePackage,
assetManifest,
internalManifest: manifest,
checksumSummary,
packagePath
};
}
private async writeAssets(
sharedContracts: SharedContractsRuntime,
packagePath: string,
versionId: string,
assets: readonly GamePackageAssetInput[],
fileChecksums: Record<string, string>
): Promise<AssetManifest> {
if (assets.length === 0) {
throwPackageBuildFailure("GAME_PACKAGE_ASSETS_EMPTY", "GamePackage builder requires at least one package asset", { path: "assets" });
}
const entries: AssetManifestEntry[] = [];
for (const [index, asset] of assets.entries()) {
const content = bytesFrom(asset.content);
const actualChecksum = checksumBytes(content);
if (asset.expectedChecksum !== undefined && asset.expectedChecksum !== actualChecksum) {
throwPackageBuildFailure("GAME_PACKAGE_ASSET_CHECKSUM_MISMATCH", "Asset checksum does not match expected checksum", {
path: `assets.${index}.hash`,
assetPath: asset.path,
expectedChecksum: asset.expectedChecksum,
actualChecksum
});
}
const entry = {
path: asset.path,
hash: actualChecksum,
sizeBytes: content.byteLength,
type: asset.type
} satisfies AssetManifestEntry;
const validation = sharedContracts.validateAssetManifestEntry(entry);
if (!validation.ok) throwValidationFailure(validation, "AssetManifest entry validation failed", `assets.${index}`);
assertAssetPath(asset, index);
const writeResult = await this.packageRepository.writeBinaryFile(`${packagePath}/${asset.path}`, content);
if (writeResult.checksum !== actualChecksum) {
throwPackageBuildFailure("GAME_PACKAGE_ASSET_WRITE_CHECKSUM_MISMATCH", "Written asset checksum does not match precomputed checksum", {
path: `assets.${index}.hash`,
assetPath: asset.path,
expectedChecksum: actualChecksum,
actualChecksum: writeResult.checksum
});
}
fileChecksums[asset.path] = writeResult.checksum;
entries.push(entry);
}
const assetManifest = {
id: `asset-manifest-${versionId}`,
entries
} satisfies AssetManifest;
const validation = sharedContracts.validateAssetManifest(assetManifest);
if (!validation.ok) throwValidationFailure(validation, "AssetManifest validation failed", "assetManifest");
return assetManifest;
}
}
@Module({
imports: [GameLogicCompilerModule],
providers: [
{
provide: GAME_PACKAGE_ARTIFACT_REPOSITORY,
useFactory: () => new FileSystemGamePackageArtifactRepository()
},
GamePackageBuilderService
],
exports: [GAME_PACKAGE_ARTIFACT_REPOSITORY, GamePackageBuilderService]
})
export class GamePackageBuilderModule {}
function requireConsumableValidationReport(consumable: ConsumableGameLogicModule): ValidationReport {
if (consumable.report === undefined) {
throwPackageBuildFailure("GAME_LOGIC_VALIDATION_REPORT_MISSING", "GameLogicModule consumable report is missing", { path: "validationReportId" });
}
return consumable.report;
}
async function loadSharedContracts(): Promise<SharedContractsRuntime> {
// shared-contracts 目前只提供 root export用计算 specifier 避免 API 的 NodeNext typecheck 把 shared 源码纳入 rootDir。
return (await import(SHARED_CONTRACTS_MODULE)) as SharedContractsRuntime;
}
function createInternalManifest(
logicModule: GameLogicModuleDescriptor,
consumable: ConsumableGameLogicModule,
packageLogicChecksum: string | undefined
): InternalWebProfileManifest {
if (packageLogicChecksum === undefined) {
throwPackageBuildFailure("GAME_PACKAGE_LOGIC_CHECKSUM_MISSING", "Package logic checksum is missing", { path: LOGIC_PACKAGE_PATH });
}
const withoutChecksum = {
entry: WEB_ENTRY_PATH,
configPath: CONFIG_PATH,
logicArtifactPath: LOGIC_PACKAGE_PATH,
assetManifestPath: ASSET_MANIFEST_PATH,
runtimeVersion: WEB_RUNTIME_VERSION,
logicModuleId: logicModule.id,
sourceArtifactPath: logicModule.artifactPath,
sourceChecksum: consumable.checksum,
validationReportId: logicModule.validationReportId,
packageLogicPath: LOGIC_PACKAGE_PATH,
packageLogicChecksum
};
// manifest.checksum 指向去除 checksum 字段后的受控 manifest payload避免自引用 hash 造成不可验证循环。
return { ...withoutChecksum, checksum: checksumText(stableJsonText(withoutChecksum)) };
}
function createChecksumSummary(
versionId: string,
packagePath: string,
files: Record<string, string>,
logicModule: GameLogicModuleDescriptor,
manifest: InternalWebProfileManifest
): GamePackageChecksumSummary {
return {
id: `checksum-summary-${versionId}`,
packagePath,
files: {
...files,
"manifest.payload": manifest.checksum
},
sourceLogic: {
artifactPath: logicModule.artifactPath,
checksum: logicModule.checksum,
validationReportId: logicModule.validationReportId
},
packageLogic: {
path: LOGIC_PACKAGE_PATH,
checksum: manifest.packageLogicChecksum
},
declaredManifestChecksum: manifest.checksum
};
}
function createGamePackage(
versionId: string,
packagePath: string,
assetManifestId: string,
manifest: InternalWebProfileManifest,
files: Record<string, string>
): GamePackage {
return {
id: `game-package-${versionId}`,
gameVersionId: versionId,
profiles: {
web: {
status: "built",
artifactPath: packagePath,
manifest
},
wechat_minigame: { status: "pending_conversion" },
douyin_minigame: { status: "pending_conversion" },
kuaishou_minigame: { status: "pending_conversion" }
},
assetManifestId,
artifactChecksums: {
...files,
[assetManifestId]: requireFileChecksum(files, ASSET_MANIFEST_PATH)
}
};
}
async function readPackageConsumableLogic(
repository: GameLogicArtifactRepository,
descriptor: GameLogicModuleDescriptor
): Promise<ConsumableGameLogicModule> {
try {
return await readConsumableGameLogicModule(repository, descriptor);
} catch (error) {
if (error instanceof UnprocessableEntityException) {
const failure = extractApiFailure(error);
throwPackageBuildFailure(failure.reasonCode, "GameLogicModule is not consumable for S4 package build", {
path: mapGameLogicFailurePath(failure.reasonCode, failure.path),
...failure.details
});
}
if (error instanceof GameLogicArtifactRepositoryError) {
throwPackageBuildFailure(error.reasonCode, "GameLogicModule repository read failed", {
path: error.path ?? "artifactPath"
});
}
throw error;
}
}
async function assertValidationReportSourceHash(
repository: GameLogicArtifactRepository,
descriptor: GameLogicModuleDescriptor,
report: ValidationReport
): Promise<void> {
if (typeof report.validatorVersion !== "string" || report.validatorVersion.length === 0) {
throwPackageBuildFailure("VALIDATION_REPORT_VALIDATOR_VERSION_MISSING", "ValidationReport validatorVersion is missing", { path: "validatorVersion" });
}
try {
const sourceInput = await repository.resolveLogicArtifact(descriptor.sourceRef.buildInputPath);
if (report.sourceHash !== sourceInput.checksum) {
throwPackageBuildFailure("VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "ValidationReport sourceHash does not match sourceRef build input", {
path: "sourceRef.buildInputPath",
expectedSourceHash: sourceInput.checksum,
reportSourceHash: report.sourceHash
});
}
} catch (error) {
if (error instanceof GameLogicArtifactRepositoryError) {
throwPackageBuildFailure(error.reasonCode, "GameLogicModule sourceRef build input read failed", {
path: error.path ?? "sourceRef.buildInputPath"
});
}
throw error;
}
}
function assertConfigAndLogicBinding(config: GameConfig, logicModule: GameLogicModuleDescriptor): void {
if (logicModule.gameConfigId !== config.id || logicModule.sourceRef.gameConfigId !== config.id) {
throwPackageBuildFailure("GAME_PACKAGE_LOGIC_CONFIG_MISMATCH", "GameLogicModule does not belong to the supplied GameConfig", {
path: "logicModule.gameConfigId",
gameConfigId: config.id,
logicGameConfigId: logicModule.gameConfigId,
sourceRefGameConfigId: logicModule.sourceRef.gameConfigId
});
}
if (logicModule.moduleType !== config.templateType) {
throwPackageBuildFailure("GAME_PACKAGE_LOGIC_TEMPLATE_MISMATCH", "GameLogicModule template type does not match GameConfig", {
path: "logicModule.moduleType",
configTemplateType: config.templateType,
logicModuleType: logicModule.moduleType
});
}
}
function assertRuntimeContract(sharedContracts: SharedContractsRuntime, logicModule: GameLogicModuleDescriptor): void {
if (logicModule.runtimeSdkContractVersion !== sharedContracts.RuntimeSdkContractVersion) {
throwPackageBuildFailure("S4_RUNTIME_SDK_CONTRACT_VERSION_INVALID", "GameLogicModule runtime SDK contract version is not supported by S4 builder", {
path: "runtimeSdkContractVersion",
expected: sharedContracts.RuntimeSdkContractVersion,
actual: logicModule.runtimeSdkContractVersion
});
}
}
function assertP0PackageImportGate(descriptor: GameLogicModuleDescriptor, report: ValidationReport, source: string): void {
for (const candidate of collectImportCandidates(descriptor, report, source)) {
const reasonCode = classifyP0Import(candidate.importName);
if (reasonCode !== null) {
throwPackageBuildFailure(reasonCode, "P0 Web package cannot include full engine dependencies or bundle imports", {
path: candidate.path,
importName: candidate.importName
});
}
}
}
function collectImportCandidates(descriptor: GameLogicModuleDescriptor, report: ValidationReport, source: string): P0ImportCandidate[] {
const candidates: P0ImportCandidate[] = descriptor.imports.map((importName, index) => ({ importName, path: `logic.imports.${index}` }));
for (const [filePath, imports] of Object.entries(report.importGraph)) {
imports.forEach((importName, index) => candidates.push({ importName, path: `logic.importGraph.${filePath}.${index}` }));
}
extractStaticImports(source).forEach((importName, index) => candidates.push({ importName, path: `logic.sourceImports.${index}` }));
return candidates;
}
function classifyP0Import(importName: string): "FULL_ENGINE_DEPENDENCY_FORBIDDEN" | "BUNDLE_IMPORT_FORBIDDEN" | null {
const normalized = importName.toLowerCase();
if (FULL_ENGINE_IMPORT_PATTERNS.some((pattern) => pattern.test(normalized))) return "FULL_ENGINE_DEPENDENCY_FORBIDDEN";
if (isBundleImport(normalized)) return "BUNDLE_IMPORT_FORBIDDEN";
return null;
}
function isBundleImport(importName: string): boolean {
const fileName = importName.split("/").at(-1) ?? importName;
return importName.includes("/dist/") || /\.bundle\.(?:mjs|js)$/.test(fileName) || /(?:^|[-_.])bundle(?:[-_.]|$)/.test(fileName);
}
function extractStaticImports(source: string): string[] {
const sourceFile = ts.createSourceFile("logic.mjs", source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS);
const imports: string[] = [];
const visit = (node: ts.Node): void => {
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined && ts.isStringLiteralLike(node.moduleSpecifier)) {
imports.push(node.moduleSpecifier.text);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return imports;
}
function assertAssetPath(asset: GamePackageAssetInput, index: number): void {
// Task2 只允许包内 assets/ 文件;外链脚本、绝对路径、../ 穿越不会进入后续写文件阶段。
if (!asset.path.startsWith("assets/")) {
throwPackageBuildFailure("GAME_PACKAGE_ASSET_PATH_SCOPE_INVALID", "Package assets must be written under assets/", {
path: `assets.${index}.path`,
assetPath: asset.path
});
}
}
function throwValidationFailure(result: Exclude<GamePackageValidationResult, { ok: true }>, message: string, pathPrefix: string): never {
throwPackageBuildFailure(result.reasonCode, message, {
path: result.path ? `${pathPrefix}.${result.path}` : pathPrefix
});
}
function extractApiFailure(error: UnprocessableEntityException): {
readonly reasonCode: string;
readonly path?: string;
readonly details: Record<string, unknown>;
} {
const response = error.getResponse();
const details = isRecord(response) && isRecord(response.details) ? response.details : {};
const reasonCode = typeof details.reasonCode === "string" ? details.reasonCode : isRecord(response) && typeof response.code === "string" ? response.code : "GAME_LOGIC_CONSUMABLE_INVALID";
const failurePath = typeof details.path === "string" ? details.path : undefined;
return { reasonCode, ...(failurePath === undefined ? {} : { path: failurePath }), details };
}
function mapGameLogicFailurePath(reasonCode: string, failurePath: string | undefined): string {
if (failurePath !== undefined) return failurePath;
if (reasonCode === "GAME_LOGIC_VALIDATION_REPORT_MISSING" || reasonCode === "GAME_LOGIC_VALIDATION_REPORT_FAILED") return "validationReportId";
if (reasonCode === "GAME_LOGIC_CONSUMABLE_CHECKSUM_MISMATCH") return "validationReportId";
if (reasonCode === "GAME_LOGIC_CHECKED_FILES_MISSING_ARTIFACT") return "checkedFiles";
if (reasonCode === "GAME_LOGIC_DESCRIPTOR_ARTIFACT_PATH_UNSAFE") return "artifactPath";
if (reasonCode === "GAME_LOGIC_DESCRIPTOR_BUILD_INPUT_PATH_UNSAFE") return "sourceRef.buildInputPath";
if (reasonCode === "GAME_LOGIC_DESCRIPTOR_ENTRYPOINT_MISSING") return "entrypoints";
if (reasonCode === "GAME_LOGIC_DESCRIPTOR_IMPORT_DENIED") return "imports";
return "logicModule";
}
function requireFileChecksum(files: Record<string, string>, filePath: string): string {
const checksum = files[filePath];
if (checksum === undefined) {
throwPackageBuildFailure("GAME_PACKAGE_CHECKSUM_MISSING", "Expected package file checksum is missing", { path: filePath });
}
return checksum;
}
function stableJsonText(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
function bytesFrom(value: string | Uint8Array): Uint8Array {
return typeof value === "string" ? Buffer.from(value, "utf8") : value;
}
function checksumBytes(value: Uint8Array): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function safeSegment(value: string, field: string): string {
const segment = value.replace(/[^A-Za-z0-9._-]+/g, "-");
if (!isRepositoryRelativePath(segment) || segment.length === 0) {
throwPackageBuildFailure("GAME_PACKAGE_ID_SEGMENT_UNSAFE", "Package id cannot be mapped to a repository segment", { path: field, value });
}
return segment;
}
function throwPackageBuildFailure(reasonCode: string, message: string, details: Record<string, unknown>): never {
throw new UnprocessableEntityException(apiError("GAME_PACKAGE_BUILD_FAILED", message, { reasonCode, ...details }));
}
async function atomicWriteFile(targetPath: string, content: Uint8Array): Promise<void> {
await mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`);
// 同目录临时文件 + rename保证失败时不会留下半截 package artifact。
await writeFile(tempPath, content);
await rename(tempPath, targetPath);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,145 @@
# 2026-06-05 S4 Task2 包构建器
## 结论
S4 Task2 `Implement Web Package Builder` 已完成实现、controller 自测,并在一轮 quality finding 修复后通过 fresh review gate
- spec compliance review首轮 PASS二轮 PASS。
- quality review首轮 FAIL二轮 PASS。
可以提交 Task2。提交后 S4 可进入 Task3 `Implement Runtime SDK, Web Runtime Host, And Web Adapter`。S5 可从 Task2 提交点创建独立 worktree 并行启动 Task1但 S5 不得修改已冻结的 `GamePackage``AssetManifest``RuntimeSdkContract``GameLogicModule` artifact 合同。
## 变更范围
- `apps/api/src/modules/game-package/index.ts`
- `apps/api/src/modules/game-package/game-package.builder.spec.ts`
未实现 S4 Task3+ 的 runtime SDK、Web runtime host、preview UI、runtime smoke也未实现 S5 mini-game conversion、S6 feed、S7 telemetry 或 deploy readiness。
## 子代理与 Review Gate
| Role | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementer | `019e959a-8527-71e2-8884-f4f02f4e7de2` | DONE | 无 |
| spec compliance review | `019e95ad-af45-7a83-a461-be723d0e92c4` | PASS | 无 Critical / Important |
| quality review | `019e95ad-f2c1-7033-822d-2a3a4ec51a99` | FAIL | P1: 测试缺少 runtime SDK version mismatch、sourceHash mismatch、failed report、artifact tamper、source static import full engine/bundle 的 fail-closed 覆盖 |
| fix implementer | `019e95b0-bd7b-7b22-979c-c1ddb1c1fc79` | DONE | 仅补测试,未改实现 |
| spec compliance re-review | `019e95ba-ffec-7993-aece-5d00d18cd1f3` | PASS | 无 Critical / Importantreviewer 复跑时遇到一次非 Task2 API spec 404controller 随后复跑通过 |
| quality re-review | `019e95bb-47f8-7d61-94dc-0a3f286ee5db` | PASS | 无 Critical / Important |
子代理没有写 `docs/memorys`,本文件为主代理留痕。
## 实现摘要
- 新增 `GamePackageBuilderService``GamePackageBuilderModule`
- 从 S3 `GameLogicArtifactRepository` 读取可消费的 `GameLogicModule`,不接受内存自报的 validation 状态。
- builder 输出 Web package
- `manifest.json`
- `config.json`
- `logic/logic.mjs`
- `assets/`
- `asset-manifest.json`
- `checksum-summary.json`
- `GamePackage.profiles.web.status` 初始化为 `built`
- `wechat_minigame``douyin_minigame``kuaishou_minigame` 初始化为 `{ status: "pending_conversion" }`,不写 `artifactPath``reportPath`
- package logic 只复制 S3 校验过的 source artifact并校验 source checksum 与 packaged checksum。
- Web manifest 记录 S3 source artifact、source checksum、validation report id、package relative logic path 和 package logic checksum。
- package artifact repository 只接受 repository-relative path并用同目录临时文件加 `rename` 写入,避免半截 artifact。
- P0 package gate 拒绝完整引擎依赖和 bundle import包括 descriptor imports、ValidationReport importGraph、源码静态 import/export。
## Controller 自测
已运行并通过:
```bash
pnpm --filter @huijing/api test -- game-package
pnpm --filter @huijing/api typecheck
pnpm --filter @huijing/api lint
pnpm check:s4-scope
git diff --check
node --check scripts/check-scope.mjs
```
关键输出:
```text
Test Files 22 passed (22)
Tests 228 passed (228)
S4 scope check passed.
```
`pnpm check:s3-scope` 已运行,结果为 EXPECTED_FAIL当前分支包含 S4 `game-package` / `GamePackage` / `AssetManifest` 内容S3 phase 对这些路径和术语继续 fail-closed。
## Review Evidence
首轮 quality finding
```text
Task2 builder 测试缺少关键 fail-closed 覆盖:
- unsupported runtimeSdkContractVersion
- ValidationReport.sourceHash mismatch
- ValidationReport.result != passed
- artifact tamper after validation
- source static import full engine/bundle
```
修复后新增测试覆盖上述路径。二轮 quality reviewer 确认:
```text
新增 tests 覆盖 runtime SDK version mismatch、sourceHash mismatch、failed report、artifact tamper、source static import full engine/bundle。
实现仍通过 S3 repository boundary 读取 artifact/report没有扩 scope。
API-local type mirror 的 drift 风险由 shared validators 和测试绑定缓解,非 blocker。
```
二轮 spec reviewer 确认:
```text
Task2 required files exist.
Builder outputs manifest/config/logic/assets/asset-manifest/checksum-summary.
Web profile is built and mini-game profiles remain pending_conversion without artifactPath/reportPath.
Checksum, ValidationReport, P0 engine/bundle gates are covered.
No S4 Task3+ runtime/preview/smoke or S5 conversion implementation found.
```
## S5 并行决策
S5 可在 S4 Task2 提交后从同一稳定提交点创建独立 worktree/branch 并行启动:
- S4 当前分支继续 Task3+runtime SDK、Web runtime host、Web adapter、preview、smoke。
- S5 独立分支先做 Task1-4mini-game contracts、platform adapters、converter/project builder、static gate。
- S5 Task5 `DevToolImportEvidence.result=passed` 和 S5 final P0 acceptance 必须等待 S4 runtime/preview/smoke 证据,不得用 blocker/note 伪装 PASS。
冻结合同:
- `packages/shared-contracts/src/game-package.ts`
- `packages/shared-contracts/src/runtime-sdk-contract.ts`
- `packages/shared-contracts/src/game-logic-module.ts`
- `apps/api/src/modules/game-logic/artifact-repository.ts`
如 S5 需要修改上述合同,必须先走 change-control不得为了转换器便利弱化 S4 validator 或 S3 artifact boundary。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/src/modules/game-package/index.ts` | `a2a939d273db650ac96be0f96e9818199f5164b9a7f235dac8fd959432b61218` |
| `apps/api/src/modules/game-package/game-package.builder.spec.ts` | `fc6768243559bc5dbf7491f16b34732d56f28adcf0d233dd0cbbe63bbf242a1a` |
| `docs/superpowers/plans/2026-05-31-mvp-S4-web-runtime.md` | `d7153b464f764aa5dd5f500f74a1a3dcc0e1df1fdc2a94435923409158d87ffe` |
| `docs/superpowers/specs/2026-05-31-mvp-S4-web-runtime-design.md` | `693c806836322eed8df7d6deb71dc379c519d2687b6f4f3b60626186b55a4711` |
## Git Status Snapshot
写入本文档前 `git status --short --branch --untracked-files=all`
```text
## codex/s1-task9-s2-prep...origin/codex/s1-task9-s2-prep [ahead 13]
?? apps/api/src/modules/game-package/game-package.builder.spec.ts
?? apps/api/src/modules/game-package/index.ts
```
## Residual Notes
- `pnpm --filter @huijing/api test -- game-package` 当前会匹配多组 `game-*` specs不只 Task2 单文件;这是当前 repo test filter 行为。
- API-local shared contract type mirror 仍有 drift 风险;当前用 shared validators 和测试绑定输出,不在 Task2 内扩大 rootDir/exports 改造。
- controller 复跑通过后spec reviewer 曾遇到的一次 `creation-agent-ai-design-workbench.api.spec.ts` 201/404 不再复现,记录为非 Task2 flake/environment 风险。
- 进入 S4 Task3 前不得修改 S5 conversion进入 S5 并行时必须使用独立 worktree 并保持冻结合同只读。