feat(s2): wire game ir compile action

This commit is contained in:
zizi 2026-06-04 14:32:06 +08:00
parent 23186fdbb8
commit f91df69be8
8 changed files with 219 additions and 36 deletions

View File

@ -153,7 +153,9 @@
],
"targetRuntimes": [
"web",
"wechat_minigame"
"wechat_minigame",
"douyin_minigame",
"kuaishou_minigame"
],
"provenance": {
"projectId": "project-s2-task3-fixture",

View File

@ -96,7 +96,7 @@
"sectionKey": "metadata_targets",
"payload": {
"schemaVersion": "s2.game-ir.v1",
"targetRuntimes": ["web", "wechat_minigame"],
"targetRuntimes": ["web", "wechat_minigame", "douyin_minigame", "kuaishou_minigame"],
"tags": ["模拟经营", "猫咖", "轻量策略"],
"coverPrompt": "温暖猫咖店铺主视觉"
}

View File

@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AppModule } from "../../app.module.js";
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
import { GameIRService, type GameIRCliRunner } from "./index.js";
import type { GameIRProvenance } from "@huijing/shared-contracts";
import type { GameIRProvenance } from "@huijing/shared-contracts/game-ir";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
@ -430,4 +430,60 @@ describe("S2 Task3 GameIR compiler", () => {
expect(missing.response.status).toBe(404);
});
it("POST compile-game-ir compiles the current design sections and returns the S2 compile summary", async () => {
const seeded = await seedDesignSections("post-compile");
testApp = await createTestApp();
const ownerToken = await login(testApp, "creator@example.test");
const compiled = await requestJson(testApp, `/projects/${seeded.project.id}/versions/${seeded.version.id}/compile-game-ir`, {
method: "POST",
headers: { authorization: `Bearer ${ownerToken}` }
});
expect(compiled.response.status).toBe(201);
expect(compiled.body).toMatchObject({
gameIrId: expect.any(String),
versionId: seeded.version.id,
status: "validated",
checksum: expect.stringMatching(/^sha256:/),
targetRuntimes: ["web", "wechat_minigame", "douyin_minigame", "kuaishou_minigame"]
});
await expect(prisma.gameIRArtifact.count({ where: { versionId: seeded.version.id } })).resolves.toBe(1);
});
it("POST compile-game-ir keeps the owner boundary and hides foreign projects", async () => {
const seeded = await seedDesignSections("post-foreign");
testApp = await createTestApp();
const playerToken = await login(testApp, "player@example.test");
const hidden = await requestJson(testApp, `/projects/${seeded.project.id}/versions/${seeded.version.id}/compile-game-ir`, {
method: "POST",
headers: { authorization: `Bearer ${playerToken}` }
});
expect(hidden.response.status).toBe(403);
await expect(prisma.gameIRArtifact.count({ where: { versionId: seeded.version.id } })).resolves.toBe(0);
});
it("POST compile-game-ir returns reasonCode and field path when compile validation fails", async () => {
const seeded = await seedDesignSections("post-invalid", "simulation-missing-economy-invalid.json");
testApp = await createTestApp();
const ownerToken = await login(testApp, "creator@example.test");
const invalid = await requestJson(testApp, `/projects/${seeded.project.id}/versions/${seeded.version.id}/compile-game-ir`, {
method: "POST",
headers: { authorization: `Bearer ${ownerToken}` }
});
expect(invalid.response.status).toBe(422);
expect(invalid.body).toMatchObject({
code: "GAME_IR_COMPILE_FAILED",
details: {
reasonCode: "GAME_IR_ECONOMY_RULES_MISSING",
path: "economy.rules"
}
});
await expect(prisma.gameIRArtifact.count({ where: { versionId: seeded.version.id } })).resolves.toBe(0);
});
});

View File

@ -7,20 +7,11 @@ import {
NotFoundException,
Optional,
Param,
Post,
Req,
UnprocessableEntityException,
UseGuards
} from "@nestjs/common";
import { createHash, randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Prisma, type DesignSection, type GameIRArtifact } from "../../generated/prisma/client.js";
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
import { canReadReviewFoundationData, creatorOwnsResource } from "../rbac/index.js";
import {
validateGameIRArtifact,
validateGameIRPayload,
@ -33,9 +24,20 @@ import {
type LightPlayableLoop,
type NpcBehaviorGraph,
type NpcTypeRule,
type ObjectiveRule,
type TargetRuntime
} from "@huijing/shared-contracts";
type ObjectiveRule
} from "@huijing/shared-contracts/game-ir";
import { createHash, randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Prisma, type DesignSection, type GameIRArtifact } from "../../generated/prisma/client.js";
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
import { canReadReviewFoundationData, creatorOwnsResource } from "../rbac/index.js";
type TargetRuntime = GameIRPayload["targetRuntimes"][number];
type RequestWithActor = {
readonly actor: AuthActor;
@ -115,6 +117,14 @@ export type GameIRArtifactDto = {
readonly updatedAt: string;
};
type CompileGameIRResponseDto = {
readonly gameIrId: string;
readonly versionId: string;
readonly status: "validated";
readonly checksum: string;
readonly targetRuntimes: readonly TargetRuntime[];
};
type SectionMap = Record<"theme_goal" | "map_grid" | "facility_placement" | "economy_rules" | "npc_behavior" | "metadata_targets", Record<string, unknown>>;
const targetRuntimes = ["web", "wechat_minigame", "douyin_minigame", "kuaishou_minigame"] as const;
@ -129,7 +139,6 @@ const requiredSectionKeys = [
const defaultTimeoutMs = 5_000;
const defaultMaxOutputBytes = 64 * 1024;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const harnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
function defaultRunner(file: string, args: readonly string[], options: GameIRCliRunnerOptions): Promise<GameIRCliResult> {
return new Promise((resolve, reject) => {
@ -719,6 +728,26 @@ export class GameIRService {
export class GameIRController {
constructor(private readonly service: GameIRService) {}
@Post("projects/:projectId/versions/:versionId/compile-game-ir")
async compileGameIR(
@Req() request: RequestWithActor,
@Param("projectId") projectId: string,
@Param("versionId") versionId: string
): Promise<CompileGameIRResponseDto> {
const result = await this.service.compileAndPersistCurrentArtifact(request.actor.id, projectId, versionId);
if (!result.ok) {
// 编译和 S0 gate 失败都不提升 artifactAPI 只暴露稳定 reasonCode/path方便 UI 定位到对应 section 字段。
throw new UnprocessableEntityException(apiError("GAME_IR_COMPILE_FAILED", "GameIR compile failed", result));
}
return {
gameIrId: result.artifact.id,
versionId: result.artifact.versionId,
status: "validated",
checksum: result.artifact.checksum,
targetRuntimes: result.artifact.payload.targetRuntimes
};
}
@Get("projects/:projectId/versions/:versionId/game-ir")
getGameIR(
@Req() request: RequestWithActor,

View File

@ -7,7 +7,8 @@ import {
createDemoWorkbenchDraft,
createWorkbenchSaveRequest,
saveWorkbenchSection,
validateWorkbenchDraft
validateWorkbenchDraft,
workbenchCompileAction
} from "./MainCreationAgentChat";
describe("S2 workbench conversational UI", () => {
@ -95,17 +96,7 @@ describe("S2 workbench conversational UI", () => {
await saveWorkbenchSection({ onSaveSection: saveAdapter, projectId: "project-demo", section, versionId: "version-demo" });
expect(calls).toEqual([
{
baseChecksum: section.checksum,
intent: "config_edit",
patchJson: section.payload,
projectId: "project-demo",
versionId: "version-demo",
sectionId: section.id,
sectionKey: section.sectionKey
}
]);
expect(calls).toEqual([request]);
});
it("compile button disabled until required sections valid", () => {
@ -126,4 +117,32 @@ describe("S2 workbench conversational UI", () => {
expect(html).toContain("disabled=\"\"");
expect(html).toContain("Add at least one economy rule before compile becomes available.");
});
it("compile action posts to the S2 GameIR endpoint and renders validated result state without later-phase promises", () => {
const html = renderToStaticMarkup(
createElement(MainCreationAgentChat, {
compileResult: {
checksum: "sha256:compiled-demo",
gameIrId: "game-ir-demo",
status: "validated",
targetRuntimes: ["web", "wechat_minigame", "douyin_minigame", "kuaishou_minigame"],
versionId: "version-demo"
},
projectId: "project-demo",
versionId: "version-demo"
})
);
expect(workbenchCompileAction("project-demo", "version-demo")).toBe("/api/projects/project-demo/versions/version-demo/compile-game-ir");
expect(html).toContain("GameIR status");
expect(html).toContain("validated");
expect(html).toContain("sha256:compiled-demo");
expect(html).toContain("web, wechat_minigame, douyin_minigame, kuaishou_minigame");
expect(html).toContain("Next phase unavailable");
expect(html).toContain("disabled=\"\"");
expect(html).not.toContain("playable");
expect(html).not.toContain("published");
expect(html).not.toContain("package generated");
expect(html).not.toContain("runtime preview");
});
});

View File

@ -23,7 +23,16 @@ export type WorkbenchSaveRequest = {
export type SaveWorkbenchSectionAdapter = (request: WorkbenchSaveRequest) => Promise<{ readonly ok: boolean }>;
export type GameIRCompileResult = {
readonly gameIrId: string;
readonly versionId: string;
readonly status: "validated";
readonly checksum: string;
readonly targetRuntimes: readonly TargetRuntime[];
};
type MainCreationAgentChatProps = {
readonly compileResult?: GameIRCompileResult;
readonly draft?: AIGameDesignDraft;
readonly onSaveSection?: SaveWorkbenchSectionAdapter;
readonly projectId: string;
@ -47,6 +56,30 @@ const styles = {
minHeight: 40,
padding: "0 16px"
},
compilePanel: {
background: "#f3f7f4",
border: "1px solid #d5ded8",
borderRadius: 8,
display: "grid",
gap: 8,
padding: 12
},
compileStatus: {
color: "#1f4f3a",
fontSize: 13,
fontWeight: 700,
margin: 0
},
disabledNextButton: {
background: "#eef2ef",
border: "1px solid #d3ddd7",
borderRadius: 6,
color: "#73837b",
fontSize: 13,
fontWeight: 700,
minHeight: 38,
padding: "0 12px"
},
conversation: {
background: "#f7faf8",
border: "1px solid #d5ded8",
@ -124,7 +157,7 @@ const workbenchSectionKeys = [
"metadata_targets"
] as const satisfies readonly DesignSectionKey[];
export function MainCreationAgentChat({ draft, projectId, versionId }: MainCreationAgentChatProps) {
export function MainCreationAgentChat({ compileResult, draft, projectId, versionId }: MainCreationAgentChatProps) {
// Task4 只交付可审阅 UI默认 draft 使用确定性演示数据,真实持久化由后续后端接入替换。
const workbenchDraft = draft ?? createDemoWorkbenchDraft({ projectId, versionId });
const validation = validateWorkbenchDraft(workbenchDraft);
@ -157,8 +190,19 @@ export function MainCreationAgentChat({ draft, projectId, versionId }: MainCreat
createElement(
"aside",
{ style: styles.panel },
createElement("button", { disabled: compileDisabled, style: styles.compileButton, type: "button" }, "Compile draft"),
createElement("p", { style: styles.note }, compileDisabled ? validation.errors[0] ?? "Required sections must be valid before compile becomes available." : "All six sections are valid for the next S2 compile step.")
createElement(
"form",
{ action: workbenchCompileAction(projectId, versionId), method: "post" },
createElement("button", { disabled: compileDisabled, style: styles.compileButton, type: "submit" }, "Compile draft")
),
createElement(
"p",
{ style: styles.note },
compileDisabled ? validation.errors[0] ?? "Required sections must be valid before compile becomes available." : "All six sections are valid for the S2 GameIR compile step."
),
compileResult ? createElement(CompileResultPanel, { result: compileResult }) : null,
createElement("button", { disabled: true, style: styles.disabledNextButton, type: "button" }, "Next phase unavailable"),
createElement("p", { style: styles.note }, "S2 stops at validated GameIR; later-phase execution remains unavailable.")
)
)
);
@ -248,6 +292,10 @@ export function workbenchSaveAction(projectId: string, versionId: string): strin
return `/api/projects/${projectId}/versions/${versionId}/creation-agent/messages`;
}
export function workbenchCompileAction(projectId: string, versionId: string): string {
return `/api/projects/${projectId}/versions/${versionId}/compile-game-ir`;
}
// Task4 不接真实后端,但保存动作必须表现为服务端调用边界,而不是只修改本地 React 状态。
export async function saveWorkbenchSection({
onSaveSection,
@ -263,7 +311,18 @@ export async function saveWorkbenchSection({
return onSaveSection(createWorkbenchSaveRequest({ projectId, section, versionId }));
}
// 编译按钮只读校验结果Task4 不触发 POST compile避免越界实现 Task5。
function CompileResultPanel({ result }: { readonly result: GameIRCompileResult }) {
return createElement(
"section",
{ "aria-label": "GameIR compile result", style: styles.compilePanel },
createElement("p", { style: styles.compileStatus }, "GameIR status"),
createElement("p", { style: styles.note }, result.status),
createElement("p", { style: styles.note }, `Checksum: ${result.checksum}`),
createElement("p", { style: styles.note }, `Target runtimes: ${result.targetRuntimes.join(", ")}`)
);
}
// 编译按钮只读校验本地 section 完整性;服务端 compile endpoint 仍会再次执行权限、schema 和 S0 gate。
export function validateWorkbenchDraft(draft: AIGameDesignDraft): WorkbenchValidation {
const mutableSectionErrors: Record<DesignSectionKey, string[]> = {
economy_rules: [],

View File

@ -6,6 +6,10 @@
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./game-ir": {
"types": "./src/game-ir.ts",
"default": "./src/game-ir.ts"
}
},
"scripts": {

View File

@ -1,7 +1,21 @@
import { TARGET_RUNTIMES } from "./ai-design";
import type { DesignSectionKey, TargetRuntime, ValidationResult } from "./ai-design";
type ValidationResult = { ok: true } | { ok: false; reasonCode: string; path?: string };
export type { TargetRuntime } from "./ai-design";
type DesignSectionKey =
| "theme_goal"
| "map_grid"
| "facility_placement"
| "economy_rules"
| "npc_behavior"
| "metadata_targets";
export type TargetRuntime = "web" | "wechat_minigame" | "douyin_minigame" | "kuaishou_minigame";
const TARGET_RUNTIMES = [
"web",
"wechat_minigame",
"douyin_minigame",
"kuaishou_minigame"
] as const satisfies readonly TargetRuntime[];
export type GameTemplateType = "simulation_management" | "light_collect";