diff --git a/apps/api/src/modules/game-package/game-package.builder.spec.ts b/apps/api/src/modules/game-package/game-package.builder.spec.ts new file mode 100644 index 00000000..29e9de48 --- /dev/null +++ b/apps/api/src/modules/game-package/game-package.builder.spec.ts @@ -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; + readonly descriptorOverrides?: Partial; + 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 { + 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 { + 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 { + const boundary: GameIRReadBoundary = { + async getCurrentValidatedArtifact() { + return artifact; + } + }; + return new GameConfigCompilerService(boundary).compileCurrentGameConfig(ownerActor, artifact.projectId, artifact.versionId); +} + +async function createLogicRepository(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s4-task2-logic-")); + tempRoots.push(root); + return new FileSystemGameLogicArtifactRepository({ root }); +} + +async function createPackageRepository(): Promise { + 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 { + 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")}`; +} diff --git a/apps/api/src/modules/game-package/index.ts b/apps/api/src/modules/game-package/index.ts new file mode 100644 index 00000000..0d210828 --- /dev/null +++ b/apps/api/src/modules/game-package/index.ts @@ -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; +}; + +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; + 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 { + return this.writeFile(repositoryPath, Buffer.from(content, "utf8")); + } + + async writeBinaryFile(repositoryPath: string, content: string | Uint8Array): Promise { + return this.writeFile(repositoryPath, bytesFrom(content)); + } + + async readTextFile(repositoryPath: string): Promise { + return readFile(this.resolveRepositoryPath(repositoryPath), "utf8"); + } + + private async writeFile(repositoryPath: string, content: Uint8Array): Promise { + 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 { + 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 = {}; + 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 + ): Promise { + 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 { + // 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, + 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 +): 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 { + 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 { + 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, 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; +} { + 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, 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): never { + throw new UnprocessableEntityException(apiError("GAME_PACKAGE_BUILD_FAILED", message, { reasonCode, ...details })); +} + +async function atomicWriteFile(targetPath: string, content: Uint8Array): Promise { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/docs/memorys/2026-06-05-S4Task2包构建器.md b/docs/memorys/2026-06-05-S4Task2包构建器.md new file mode 100644 index 00000000..d3c73e5d --- /dev/null +++ b/docs/memorys/2026-06-05-S4Task2包构建器.md @@ -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 / Important;reviewer 复跑时遇到一次非 Task2 API spec 404,controller 随后复跑通过 | +| 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-4:mini-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 并保持冻结合同只读。