feat(s2): wire game ir compile action
This commit is contained in:
parent
23186fdbb8
commit
f91df69be8
@ -153,7 +153,9 @@
|
||||
],
|
||||
"targetRuntimes": [
|
||||
"web",
|
||||
"wechat_minigame"
|
||||
"wechat_minigame",
|
||||
"douyin_minigame",
|
||||
"kuaishou_minigame"
|
||||
],
|
||||
"provenance": {
|
||||
"projectId": "project-s2-task3-fixture",
|
||||
|
||||
@ -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": "温暖猫咖店铺主视觉"
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 失败都不提升 artifact;API 只暴露稳定 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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user