feat(s3): add simulation logic compiler
This commit is contained in:
parent
490f2a3b25
commit
991fc3d6bb
170
apps/api/src/modules/game-logic/artifact-repository.ts
Normal file
170
apps/api/src/modules/game-logic/artifact-repository.ts
Normal file
@ -0,0 +1,170 @@
|
||||
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";
|
||||
|
||||
export type ValidationReport = {
|
||||
readonly id: string;
|
||||
readonly targetContract: "GameLogicModule";
|
||||
readonly artifactPath: string;
|
||||
readonly sourceHash: string;
|
||||
readonly checksum: string;
|
||||
readonly checkedFiles: readonly string[];
|
||||
readonly importGraph: Record<string, readonly string[]>;
|
||||
readonly allowedImports: readonly string[];
|
||||
readonly deniedSymbols: readonly string[];
|
||||
readonly ruleVersion: string;
|
||||
readonly validatorVersion: string;
|
||||
readonly result: "passed";
|
||||
readonly createdAt: string;
|
||||
};
|
||||
|
||||
export type ResolvedLogicArtifact = {
|
||||
readonly source: string;
|
||||
readonly checksum: string;
|
||||
};
|
||||
|
||||
export type GameLogicArtifactRepository = {
|
||||
readonly writeLogicArtifact: (input: { readonly artifactPath: string; readonly source: string }) => Promise<{ readonly checksum: string }>;
|
||||
readonly writeValidationReport: (report: ValidationReport) => Promise<void>;
|
||||
readonly readValidationReport: (id: string) => Promise<ValidationReport | null>;
|
||||
readonly resolveLogicArtifact: (artifactPath: string) => Promise<ResolvedLogicArtifact>;
|
||||
};
|
||||
|
||||
export class GameLogicArtifactRepositoryError extends Error {
|
||||
readonly reasonCode: string;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(reasonCode: string, message: string, failurePath?: string) {
|
||||
super(message);
|
||||
this.name = "GameLogicArtifactRepositoryError";
|
||||
this.reasonCode = reasonCode;
|
||||
if (failurePath !== undefined) this.path = failurePath;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_ARTIFACT_ROOT = path.join(os.tmpdir(), "huijing-s3-game-logic-artifacts");
|
||||
const ALLOWED_IMPORTS = new Set(["@huijing/runtime-sdk", "@huijing/rules", "@huijing/math"]);
|
||||
|
||||
export function checksumText(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function isRepositoryRelativePath(value: string): boolean {
|
||||
if (value.length === 0 || value === "." || value.includes("\0") || value.includes("\\")) return false;
|
||||
if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) return false;
|
||||
const parts = value.split("/");
|
||||
return !parts.some((part) => part === "" || part === "." || part === "..");
|
||||
}
|
||||
|
||||
export class FileSystemGameLogicArtifactRepository implements GameLogicArtifactRepository {
|
||||
readonly root: string;
|
||||
|
||||
constructor(options: { readonly root?: string } = {}) {
|
||||
this.root = path.resolve(options.root ?? DEFAULT_ARTIFACT_ROOT);
|
||||
}
|
||||
|
||||
async writeLogicArtifact(input: { readonly artifactPath: string; readonly source: string }): Promise<{ readonly checksum: string }> {
|
||||
const targetPath = this.resolveRepositoryPath(input.artifactPath);
|
||||
await atomicWriteText(targetPath, input.source);
|
||||
return { checksum: checksumText(input.source) };
|
||||
}
|
||||
|
||||
async writeValidationReport(report: ValidationReport): Promise<void> {
|
||||
this.assertSafeReportId(report.id);
|
||||
this.assertSafeValidationReport(report);
|
||||
|
||||
const artifact = await this.resolveLogicArtifact(report.artifactPath);
|
||||
if (artifact.checksum !== report.checksum) {
|
||||
throw new GameLogicArtifactRepositoryError(
|
||||
"GAME_LOGIC_REPORT_CHECKSUM_MISMATCH",
|
||||
"ValidationReport checksum does not match stored logic artifact",
|
||||
report.artifactPath
|
||||
);
|
||||
}
|
||||
|
||||
for (const checkedFile of report.checkedFiles) {
|
||||
await this.resolveLogicArtifact(checkedFile);
|
||||
}
|
||||
|
||||
const reportPath = this.reportPathForId(report.id);
|
||||
await atomicWriteText(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async readValidationReport(id: string): Promise<ValidationReport | null> {
|
||||
this.assertSafeReportId(id);
|
||||
try {
|
||||
const content = await readFile(this.reportPathForId(id), "utf8");
|
||||
const report = JSON.parse(content) as ValidationReport;
|
||||
this.assertSafeValidationReport(report);
|
||||
return report;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveLogicArtifact(artifactPath: string): Promise<ResolvedLogicArtifact> {
|
||||
const targetPath = this.resolveRepositoryPath(artifactPath);
|
||||
const source = await readFile(targetPath, "utf8");
|
||||
return { source, checksum: checksumText(source) };
|
||||
}
|
||||
|
||||
private resolveRepositoryPath(repositoryPath: string): string {
|
||||
// descriptor/report 只能携带 repository-relative path,防止绝对路径和 ../ 穿越读写源码或用户目录。
|
||||
if (!isRepositoryRelativePath(repositoryPath)) {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_ARTIFACT_PATH_UNSAFE", "Game logic artifact path is outside repository boundary", repositoryPath);
|
||||
}
|
||||
|
||||
const resolved = path.resolve(this.root, repositoryPath);
|
||||
if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_ARTIFACT_PATH_UNSAFE", "Resolved artifact path escaped repository root", repositoryPath);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private reportPathForId(id: string): string {
|
||||
return this.resolveRepositoryPath(`reports/${id}.json`);
|
||||
}
|
||||
|
||||
private assertSafeReportId(id: string): void {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(id) || id === "." || id === "..") {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_REPORT_ID_UNSAFE", "ValidationReport id cannot be mapped safely", id);
|
||||
}
|
||||
}
|
||||
|
||||
private assertSafeValidationReport(report: ValidationReport): void {
|
||||
if (report.targetContract !== "GameLogicModule") {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_REPORT_TARGET_INVALID", "ValidationReport targetContract must be GameLogicModule", report.id);
|
||||
}
|
||||
if (report.result !== "passed") {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_REPORT_RESULT_INVALID", "ValidationReport result is not consumable", report.id);
|
||||
}
|
||||
this.resolveRepositoryPath(report.artifactPath);
|
||||
for (const checkedFile of report.checkedFiles) {
|
||||
this.resolveRepositoryPath(checkedFile);
|
||||
}
|
||||
for (const [file, imports] of Object.entries(report.importGraph)) {
|
||||
if (!isRepositoryRelativePath(file)) {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_REPORT_IMPORT_GRAPH_PATH_UNSAFE", "Import graph file path is unsafe", file);
|
||||
}
|
||||
for (const importName of imports) {
|
||||
if (!ALLOWED_IMPORTS.has(importName)) {
|
||||
throw new GameLogicArtifactRepositoryError("GAME_LOGIC_REPORT_IMPORT_DENIED", "ValidationReport contains an import outside the S3 allowlist", importName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWriteText(targetPath: string, content: string): Promise<void> {
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`);
|
||||
// 先写同目录临时文件再 rename;同一文件系统内 rename 具备原子替换语义,失败时不会暴露半截 report/artifact。
|
||||
await writeFile(tempPath, content, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error;
|
||||
}
|
||||
278
apps/api/src/modules/game-logic/index.ts
Normal file
278
apps/api/src/modules/game-logic/index.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import { Inject, Injectable, Module, UnprocessableEntityException } from "@nestjs/common";
|
||||
import { createHash } from "node:crypto";
|
||||
import { apiError } from "../projects/api-runtime.js";
|
||||
import type { GameConfig } from "../game-config/index.js";
|
||||
import {
|
||||
FileSystemGameLogicArtifactRepository,
|
||||
checksumText,
|
||||
isRepositoryRelativePath,
|
||||
type GameLogicArtifactRepository,
|
||||
type ValidationReport
|
||||
} from "./artifact-repository.js";
|
||||
|
||||
export type GameLogicModuleDescriptor = {
|
||||
readonly id: string;
|
||||
readonly gameConfigId: string;
|
||||
readonly moduleType: GameConfig["templateType"];
|
||||
readonly sourceKind: "template_codegen";
|
||||
readonly sourceRef: {
|
||||
readonly gameConfigId: string;
|
||||
readonly templateVersion: string;
|
||||
readonly buildInputPath: string;
|
||||
};
|
||||
readonly artifactPath: string;
|
||||
readonly checksum: string;
|
||||
readonly exportSignature: Record<GameLogicEntrypoint, string>;
|
||||
readonly runtimeSdkContractVersion: string;
|
||||
readonly validationReportId: string;
|
||||
readonly buildGraphId: string;
|
||||
readonly languageSubset: string;
|
||||
readonly imports: readonly string[];
|
||||
readonly forbiddenApis: readonly string[];
|
||||
readonly entrypoints: readonly GameLogicEntrypoint[];
|
||||
};
|
||||
|
||||
export type GameLogicCompileResult = {
|
||||
readonly descriptor: GameLogicModuleDescriptor;
|
||||
readonly report: ValidationReport;
|
||||
readonly source: string;
|
||||
};
|
||||
|
||||
export type ConsumableGameLogicModule = GameLogicCompileResult & {
|
||||
readonly checksum: string;
|
||||
};
|
||||
|
||||
export type GameLogicCompileOptions = {
|
||||
readonly buildInputPath: string;
|
||||
};
|
||||
|
||||
type GameLogicEntrypoint = "init" | "update" | "render" | "handleInput" | "onPause" | "onResume";
|
||||
|
||||
const RuntimeSdkContractVersion = "mvp-s3-runtime-sdk-v1";
|
||||
const LANGUAGE_SUBSET = "s3-safe-esm-game-logic-v1";
|
||||
const ALLOWED_IMPORTS = ["@huijing/runtime-sdk", "@huijing/rules", "@huijing/math"] as const;
|
||||
const GENERATED_IMPORTS = ["@huijing/runtime-sdk"] as const;
|
||||
const ENTRYPOINTS = ["init", "update", "render", "handleInput", "onPause", "onResume"] as const satisfies readonly GameLogicEntrypoint[];
|
||||
export const GAME_LOGIC_ARTIFACT_REPOSITORY = Symbol("GAME_LOGIC_ARTIFACT_REPOSITORY");
|
||||
const EXPORT_SIGNATURE: Record<GameLogicEntrypoint, string> = {
|
||||
init: "(sdk, config) => GameState",
|
||||
update: "(state, tick, sdk) => GameState",
|
||||
render: "(state, sdk) => RenderCommand[]",
|
||||
handleInput: "(state, input, sdk) => GameState",
|
||||
onPause: "(state, sdk) => void",
|
||||
onResume: "(state, sdk) => void"
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GameLogicCompilerService {
|
||||
constructor(
|
||||
@Inject(GAME_LOGIC_ARTIFACT_REPOSITORY)
|
||||
private readonly repository: GameLogicArtifactRepository = new FileSystemGameLogicArtifactRepository()
|
||||
) {}
|
||||
|
||||
async compileGameLogicModule(config: GameConfig, options: GameLogicCompileOptions): Promise<GameLogicCompileResult> {
|
||||
assertBuildInputPath(options.buildInputPath);
|
||||
const moduleId = safeSegment(config.id);
|
||||
const artifactPath = `dist/game-logic/${moduleId}/logic.mjs`;
|
||||
const validationReportId = `validation-report-${moduleId}`;
|
||||
const source = renderLogicModuleSource(config);
|
||||
const sourceHash = stableJsonChecksum(config);
|
||||
|
||||
const writeResult = await this.repository.writeLogicArtifact({ artifactPath, source });
|
||||
const descriptor: GameLogicModuleDescriptor = {
|
||||
id: `game-logic-module-${moduleId}`,
|
||||
gameConfigId: config.id,
|
||||
moduleType: config.templateType,
|
||||
sourceKind: "template_codegen",
|
||||
sourceRef: {
|
||||
gameConfigId: config.id,
|
||||
templateVersion: templateVersionFor(config),
|
||||
buildInputPath: options.buildInputPath
|
||||
},
|
||||
artifactPath,
|
||||
checksum: writeResult.checksum,
|
||||
exportSignature: EXPORT_SIGNATURE,
|
||||
runtimeSdkContractVersion: RuntimeSdkContractVersion,
|
||||
validationReportId,
|
||||
buildGraphId: `build-graph-${moduleId}`,
|
||||
languageSubset: LANGUAGE_SUBSET,
|
||||
imports: GENERATED_IMPORTS,
|
||||
forbiddenApis: [],
|
||||
entrypoints: ENTRYPOINTS
|
||||
};
|
||||
assertDescriptorShape(descriptor);
|
||||
|
||||
const report: ValidationReport = {
|
||||
id: validationReportId,
|
||||
targetContract: "GameLogicModule",
|
||||
artifactPath,
|
||||
sourceHash,
|
||||
checksum: writeResult.checksum,
|
||||
checkedFiles: [artifactPath],
|
||||
importGraph: { "logic.mjs": GENERATED_IMPORTS },
|
||||
allowedImports: ALLOWED_IMPORTS,
|
||||
deniedSymbols: [],
|
||||
ruleVersion: "game-logic-allowlist-v1",
|
||||
validatorVersion: "mvp-s3-validator-v1",
|
||||
result: "passed",
|
||||
createdAt: "2026-06-04T00:00:00.000Z"
|
||||
};
|
||||
|
||||
await this.repository.writeValidationReport(report);
|
||||
const consumable = await readConsumableGameLogicModule(this.repository, descriptor);
|
||||
return { descriptor, report: consumable.report, source: consumable.source };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readConsumableGameLogicModule(
|
||||
repository: GameLogicArtifactRepository,
|
||||
descriptor: GameLogicModuleDescriptor
|
||||
): Promise<ConsumableGameLogicModule> {
|
||||
assertDescriptorShape(descriptor);
|
||||
const report = await repository.readValidationReport(descriptor.validationReportId);
|
||||
if (report === null) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_VALIDATION_REPORT_MISSING", "ValidationReport is missing", {
|
||||
validationReportId: descriptor.validationReportId
|
||||
});
|
||||
}
|
||||
|
||||
const artifact = await repository.resolveLogicArtifact(descriptor.artifactPath);
|
||||
// S4/S5 consumer 入口必须从 repository 重读 report/artifact 后做三方 checksum 对齐,不能相信 descriptor 自报。
|
||||
if (report.artifactPath !== descriptor.artifactPath || report.checksum !== descriptor.checksum || artifact.checksum !== descriptor.checksum) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_CONSUMABLE_CHECKSUM_MISMATCH", "Descriptor, report and artifact checksums do not match", {
|
||||
artifactPath: descriptor.artifactPath,
|
||||
descriptorChecksum: descriptor.checksum,
|
||||
reportChecksum: report.checksum,
|
||||
artifactChecksum: artifact.checksum
|
||||
});
|
||||
}
|
||||
if (!report.checkedFiles.includes(descriptor.artifactPath)) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_CHECKED_FILES_MISSING_ARTIFACT", "ValidationReport checkedFiles does not include descriptor artifactPath", {
|
||||
artifactPath: descriptor.artifactPath,
|
||||
checkedFiles: report.checkedFiles
|
||||
});
|
||||
}
|
||||
|
||||
return { descriptor, report, source: artifact.source, checksum: artifact.checksum };
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: GAME_LOGIC_ARTIFACT_REPOSITORY,
|
||||
useFactory: () => new FileSystemGameLogicArtifactRepository()
|
||||
},
|
||||
GameLogicCompilerService
|
||||
],
|
||||
exports: [GAME_LOGIC_ARTIFACT_REPOSITORY, GameLogicCompilerService]
|
||||
})
|
||||
export class GameLogicCompilerModule {}
|
||||
|
||||
function renderLogicModuleSource(config: GameConfig): string {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
gameConfigId: config.id,
|
||||
templateType: config.templateType,
|
||||
systems: config.systems.map((system) => ({ id: system.id, type: system.type })),
|
||||
objectiveIds: config.objectives.map((objective) => objective.id)
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
return [
|
||||
'import * as RuntimeSdk from "@huijing/runtime-sdk";',
|
||||
"",
|
||||
`const compiledGameConfig = ${payload};`,
|
||||
"",
|
||||
"export function init(sdk, config) {",
|
||||
" void RuntimeSdk;",
|
||||
" return { status: \"running\", gameConfigId: config?.id ?? compiledGameConfig.gameConfigId, templateType: compiledGameConfig.templateType, elapsedMs: 0 };",
|
||||
"}",
|
||||
"",
|
||||
"export function update(state, tick, sdk) {",
|
||||
" void sdk;",
|
||||
" const deltaMs = typeof tick?.deltaMs === \"number\" ? tick.deltaMs : 0;",
|
||||
" return { ...state, elapsedMs: (state?.elapsedMs ?? 0) + deltaMs };",
|
||||
"}",
|
||||
"",
|
||||
"export function render(state, sdk) {",
|
||||
" void state;",
|
||||
" void sdk;",
|
||||
" return [{ type: \"text\", text: compiledGameConfig.templateType, x: 8, y: 16, fill: \"#ffffff\", fontSize: 12 }];",
|
||||
"}",
|
||||
"",
|
||||
"export function handleInput(state, input, sdk) {",
|
||||
" void input;",
|
||||
" void sdk;",
|
||||
" return state;",
|
||||
"}",
|
||||
"",
|
||||
"export function onPause(state, sdk) {",
|
||||
" void state;",
|
||||
" void sdk;",
|
||||
"}",
|
||||
"",
|
||||
"export function onResume(state, sdk) {",
|
||||
" void state;",
|
||||
" void sdk;",
|
||||
"}",
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function templateVersionFor(config: GameConfig): string {
|
||||
return config.templateType === "simulation_management" ? "simulation-management@1" : "light-collect@1";
|
||||
}
|
||||
|
||||
function assertBuildInputPath(buildInputPath: string): void {
|
||||
if (!isRepositoryRelativePath(buildInputPath)) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_BUILD_INPUT_PATH_UNSAFE", "GameLogicModule sourceRef buildInputPath must be repository-relative", { buildInputPath });
|
||||
}
|
||||
}
|
||||
|
||||
function assertDescriptorShape(descriptor: GameLogicModuleDescriptor): void {
|
||||
if (!isRepositoryRelativePath(descriptor.artifactPath)) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_DESCRIPTOR_ARTIFACT_PATH_UNSAFE", "GameLogicModule artifactPath is unsafe", {
|
||||
artifactPath: descriptor.artifactPath
|
||||
});
|
||||
}
|
||||
if (!isRepositoryRelativePath(descriptor.sourceRef.buildInputPath)) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_DESCRIPTOR_BUILD_INPUT_PATH_UNSAFE", "GameLogicModule buildInputPath is unsafe", {
|
||||
buildInputPath: descriptor.sourceRef.buildInputPath
|
||||
});
|
||||
}
|
||||
const missingEntrypoint = ENTRYPOINTS.find((entrypoint) => !descriptor.entrypoints.includes(entrypoint) || descriptor.exportSignature[entrypoint] === undefined);
|
||||
if (missingEntrypoint !== undefined) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_DESCRIPTOR_ENTRYPOINT_MISSING", "GameLogicModule descriptor is missing a fixed entrypoint", {
|
||||
entrypoint: missingEntrypoint
|
||||
});
|
||||
}
|
||||
const deniedImport = descriptor.imports.find((importName) => !ALLOWED_IMPORTS.includes(importName as (typeof ALLOWED_IMPORTS)[number]));
|
||||
if (deniedImport !== undefined) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_DESCRIPTOR_IMPORT_DENIED", "GameLogicModule descriptor includes a non-allowlisted import", {
|
||||
importName: deniedImport
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stableJsonChecksum(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function safeSegment(value: string): string {
|
||||
const segment = value.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||
if (!isRepositoryRelativePath(segment) || segment.length === 0) {
|
||||
throwLogicCompileFailure("GAME_LOGIC_ID_UNSAFE", "GameConfig id cannot be mapped to a repository segment", { gameConfigId: value });
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
function throwLogicCompileFailure(reasonCode: string, message: string, details: Record<string, unknown>): never {
|
||||
throw new UnprocessableEntityException(apiError("GAME_LOGIC_COMPILE_FAILED", message, { reasonCode, ...details }));
|
||||
}
|
||||
|
||||
export const __gameLogicCompilerInternals = {
|
||||
checksumText,
|
||||
renderLogicModuleSource
|
||||
};
|
||||
234
apps/api/src/modules/simulation/index.ts
Normal file
234
apps/api/src/modules/simulation/index.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import type { GameConfig, GameSystemConfig } from "../game-config/index.js";
|
||||
|
||||
export type SimulationNpcState = "enter" | "queue" | "consume" | "leave";
|
||||
|
||||
export type SimulationNpcInstance = {
|
||||
readonly id: string;
|
||||
readonly typeId: string;
|
||||
readonly state: SimulationNpcState;
|
||||
readonly position: { readonly x: number; readonly y: number };
|
||||
readonly targetFacilityId: string;
|
||||
readonly consumedAtMs?: number;
|
||||
};
|
||||
|
||||
export type SimulationState = {
|
||||
readonly elapsedMs: number;
|
||||
readonly resources: Record<string, number>;
|
||||
readonly npcs: readonly SimulationNpcInstance[];
|
||||
readonly nextNpcSeq: number;
|
||||
readonly spawnTimers: Record<string, number>;
|
||||
readonly servedNpcCount: number;
|
||||
readonly completedObjectiveIds: readonly string[];
|
||||
readonly status: "running" | "objective_completed" | "failed";
|
||||
readonly failureReasonCode?: string;
|
||||
};
|
||||
|
||||
export type SimulationTick = {
|
||||
readonly deltaMs: number;
|
||||
};
|
||||
|
||||
type SimulationConfig = GameConfig & {
|
||||
readonly world: Extract<GameConfig["world"], { readonly type: "grid_world" }>;
|
||||
};
|
||||
type SimulationFacility = Extract<GameConfig["world"], { readonly type: "grid_world" }>["facilities"][number];
|
||||
type SimulationObjective = GameConfig["objectives"][number];
|
||||
|
||||
const NPC_MOVE_SPEED_CELLS_PER_SECOND = 1;
|
||||
|
||||
export function createInitialSimulationState(config: GameConfig): SimulationState {
|
||||
return {
|
||||
elapsedMs: 0,
|
||||
resources: Object.fromEntries(config.resources.map((resource) => [resource.id, resource.initial ?? 0])),
|
||||
npcs: [],
|
||||
nextNpcSeq: 0,
|
||||
spawnTimers: Object.fromEntries(npcSystem(config)?.npcTypes?.map((npc) => [npc.id, 0]) ?? []),
|
||||
servedNpcCount: 0,
|
||||
completedObjectiveIds: [],
|
||||
status: "running"
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSimulation(state: SimulationState, config: GameConfig, tick: SimulationTick): SimulationState {
|
||||
if (state.status !== "running") return state;
|
||||
if (tick.deltaMs <= 0) return state;
|
||||
if (config.templateType !== "simulation_management" || config.world.type !== "grid_world") return state;
|
||||
|
||||
const simulationConfig: SimulationConfig = { ...config, world: config.world };
|
||||
const elapsedMs = state.elapsedMs + tick.deltaMs;
|
||||
let nextState: SimulationState = { ...state, elapsedMs };
|
||||
|
||||
nextState = applyFacilityTicks(nextState, simulationConfig, tick.deltaMs);
|
||||
nextState = moveAndServeNpcs(nextState, simulationConfig, tick.deltaMs);
|
||||
nextState = spawnNpcs(nextState, simulationConfig, tick.deltaMs);
|
||||
return evaluateObjectives(nextState, simulationConfig);
|
||||
}
|
||||
|
||||
function applyFacilityTicks(state: SimulationState, config: SimulationConfig, deltaMs: number): SimulationState {
|
||||
const economy = economySystem(config);
|
||||
if (!economy?.enabled || economy.tickIntervalMs === undefined || economy.tickIntervalMs <= 0 || economy.rules === undefined) return state;
|
||||
|
||||
const tickCount = Math.floor(state.elapsedMs / economy.tickIntervalMs) - Math.floor((state.elapsedMs - deltaMs) / economy.tickIntervalMs);
|
||||
if (tickCount <= 0) return state;
|
||||
|
||||
let resources = state.resources;
|
||||
for (const facility of config.world.facilities) {
|
||||
for (const rule of economy.rules.filter((candidate) => candidate.trigger === "facility_tick")) {
|
||||
const outputs = rule.outputs.filter((output) => facility.produces.includes(output.resourceId));
|
||||
if (outputs.length === 0 || !hasInputs(resources, rule.inputs)) continue;
|
||||
|
||||
resources = consumeInputs(resources, rule.inputs, tickCount);
|
||||
resources = addOutputs(resources, outputs, tickCount);
|
||||
}
|
||||
}
|
||||
|
||||
return resources === state.resources ? state : { ...state, resources };
|
||||
}
|
||||
|
||||
function spawnNpcs(state: SimulationState, config: SimulationConfig, deltaMs: number): SimulationState {
|
||||
const npc = npcSystem(config);
|
||||
const targetFacility = firstConsumerFacility(config);
|
||||
if (!npc?.enabled || npc.npcTypes === undefined || targetFacility === undefined) return state;
|
||||
|
||||
const spawnTimers = { ...state.spawnTimers };
|
||||
const spawned: SimulationNpcInstance[] = [];
|
||||
let nextNpcSeq = state.nextNpcSeq;
|
||||
|
||||
for (const npcType of npc.npcTypes) {
|
||||
const timer = (spawnTimers[npcType.id] ?? 0) + deltaMs;
|
||||
const spawnCount = Math.floor(timer / npcType.spawnEveryMs);
|
||||
spawnTimers[npcType.id] = timer % npcType.spawnEveryMs;
|
||||
|
||||
for (let index = 0; index < spawnCount; index += 1) {
|
||||
spawned.push({
|
||||
id: `${npcType.id}-${nextNpcSeq}`,
|
||||
typeId: npcType.id,
|
||||
state: "queue",
|
||||
position: { x: 0, y: targetFacility.position.y },
|
||||
targetFacilityId: targetFacility.id
|
||||
});
|
||||
nextNpcSeq += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (spawned.length === 0) return { ...state, spawnTimers };
|
||||
return { ...state, spawnTimers, nextNpcSeq, npcs: [...state.npcs, ...spawned] };
|
||||
}
|
||||
|
||||
function moveAndServeNpcs(state: SimulationState, config: SimulationConfig, deltaMs: number): SimulationState {
|
||||
let resources = state.resources;
|
||||
let servedNpcCount = state.servedNpcCount;
|
||||
const activeNpcs: SimulationNpcInstance[] = [];
|
||||
|
||||
for (const npc of state.npcs) {
|
||||
const facility = config.world.facilities.find((candidate) => candidate.id === npc.targetFacilityId);
|
||||
if (facility === undefined) continue;
|
||||
|
||||
if (npc.state === "consume") {
|
||||
activeNpcs.push({ ...npc, state: "leave" });
|
||||
continue;
|
||||
}
|
||||
if (npc.state === "leave") continue;
|
||||
|
||||
const nextPosition = moveToward(npc.position, facility.position, deltaMs);
|
||||
const arrived = nextPosition.x === facility.position.x && nextPosition.y === facility.position.y;
|
||||
if (!arrived) {
|
||||
activeNpcs.push({ ...npc, position: nextPosition });
|
||||
continue;
|
||||
}
|
||||
|
||||
const serviceRule = economySystem(config)?.rules?.find((rule) => rule.trigger === "npc_served");
|
||||
if (serviceRule === undefined || !hasInputs(resources, serviceRule.inputs)) {
|
||||
activeNpcs.push({ ...npc, position: nextPosition, state: "queue" });
|
||||
continue;
|
||||
}
|
||||
|
||||
resources = consumeInputs(resources, serviceRule.inputs, 1);
|
||||
resources = addOutputs(resources, serviceRule.outputs, 1);
|
||||
servedNpcCount += 1;
|
||||
activeNpcs.push({ ...npc, position: nextPosition, state: "consume", consumedAtMs: state.elapsedMs });
|
||||
}
|
||||
|
||||
return { ...state, resources, servedNpcCount, npcs: activeNpcs };
|
||||
}
|
||||
|
||||
function evaluateObjectives(state: SimulationState, config: SimulationConfig): SimulationState {
|
||||
const completedObjectiveIds = new Set(state.completedObjectiveIds);
|
||||
for (const objective of config.objectives) {
|
||||
if (objective.kind === "resource_at_least" && objective.target !== undefined && typeof objective.value === "number") {
|
||||
if ((state.resources[objective.target] ?? 0) >= objective.value) completedObjectiveIds.add(objective.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (completedObjectiveIds.size > state.completedObjectiveIds.length) {
|
||||
return { ...state, completedObjectiveIds: [...completedObjectiveIds], status: "objective_completed" };
|
||||
}
|
||||
|
||||
const expiredObjective = config.objectives.find((objective) => objectiveDeadlineMs(objective) !== undefined && state.elapsedMs >= objectiveDeadlineMs(objective)!);
|
||||
if (expiredObjective !== undefined) {
|
||||
return { ...state, failureReasonCode: "OBJECTIVE_TIME_EXPIRED", status: "failed" };
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function moveToward(
|
||||
current: { readonly x: number; readonly y: number },
|
||||
target: { readonly x: number; readonly y: number },
|
||||
deltaMs: number
|
||||
): { readonly x: number; readonly y: number } {
|
||||
const distance = (deltaMs / 1000) * NPC_MOVE_SPEED_CELLS_PER_SECOND;
|
||||
const nextX = moveAxis(current.x, target.x, distance);
|
||||
const remaining = Math.max(0, distance - Math.abs(nextX - current.x));
|
||||
return { x: nextX, y: moveAxis(current.y, target.y, remaining) };
|
||||
}
|
||||
|
||||
function moveAxis(current: number, target: number, maxDistance: number): number {
|
||||
if (current === target || maxDistance <= 0) return current;
|
||||
const direction = target > current ? 1 : -1;
|
||||
const next = current + direction * maxDistance;
|
||||
return direction > 0 ? Math.min(next, target) : Math.max(next, target);
|
||||
}
|
||||
|
||||
function hasInputs(resources: Record<string, number>, inputs: readonly string[]): boolean {
|
||||
return inputs.every((resourceId) => (resources[resourceId] ?? 0) > 0);
|
||||
}
|
||||
|
||||
function consumeInputs(resources: Record<string, number>, inputs: readonly string[], multiplier: number): Record<string, number> {
|
||||
if (inputs.length === 0) return resources;
|
||||
const next = { ...resources };
|
||||
for (const resourceId of inputs) {
|
||||
next[resourceId] = Math.max(0, (next[resourceId] ?? 0) - multiplier);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function addOutputs(
|
||||
resources: Record<string, number>,
|
||||
outputs: readonly { readonly resourceId: string; readonly amount: number }[],
|
||||
multiplier: number
|
||||
): Record<string, number> {
|
||||
if (outputs.length === 0) return resources;
|
||||
const next = { ...resources };
|
||||
for (const output of outputs) {
|
||||
next[output.resourceId] = (next[output.resourceId] ?? 0) + output.amount * multiplier;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function firstConsumerFacility(config: SimulationConfig): SimulationFacility | undefined {
|
||||
return config.world.facilities.find((facility) => (facility.consumes?.length ?? 0) > 0) ?? config.world.facilities[0];
|
||||
}
|
||||
|
||||
function economySystem(config: GameConfig): GameSystemConfig | undefined {
|
||||
return config.systems.find((system) => system.type === "economy");
|
||||
}
|
||||
|
||||
function npcSystem(config: GameConfig): GameSystemConfig | undefined {
|
||||
return config.systems.find((system) => system.type === "npc");
|
||||
}
|
||||
|
||||
function objectiveDeadlineMs(objective: SimulationObjective): number | undefined {
|
||||
return typeof (objective as SimulationObjective & { readonly deadlineMs?: unknown }).deadlineMs === "number"
|
||||
? (objective as SimulationObjective & { readonly deadlineMs: number }).deadlineMs
|
||||
: undefined;
|
||||
}
|
||||
223
apps/api/src/modules/simulation/simulation.logic.spec.ts
Normal file
223
apps/api/src/modules/simulation/simulation.logic.spec.ts
Normal file
@ -0,0 +1,223 @@
|
||||
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 { validateGameLogicModule } from "../../../../../packages/shared-contracts/src/game-logic-module.js";
|
||||
import { validateGameConfigFromArtifact } from "../../../../../packages/shared-contracts/src/game-config.js";
|
||||
import { GameConfigCompilerService, type GameConfig, type GameIRReadBoundary } from "../game-config/index.js";
|
||||
import {
|
||||
GameLogicCompilerService,
|
||||
readConsumableGameLogicModule,
|
||||
type GameLogicCompileResult
|
||||
} from "../game-logic/index.js";
|
||||
import { FileSystemGameLogicArtifactRepository } from "../game-logic/artifact-repository.js";
|
||||
import {
|
||||
createInitialSimulationState,
|
||||
updateSimulation,
|
||||
type SimulationTick
|
||||
} from "./index.js";
|
||||
|
||||
const fixturesRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
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 })));
|
||||
});
|
||||
|
||||
function stableJsonChecksum(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
function serviceFor(artifact: GameIRArtifact): GameConfigCompilerService {
|
||||
const boundary: GameIRReadBoundary = {
|
||||
async getCurrentValidatedArtifact() {
|
||||
return artifact;
|
||||
}
|
||||
};
|
||||
return new GameConfigCompilerService(boundary);
|
||||
}
|
||||
|
||||
async function compileFixtureConfig(name: string): Promise<{ artifact: GameIRArtifact; config: GameConfig }> {
|
||||
const artifact = await readArtifactFixture(name);
|
||||
const config = await serviceFor(artifact).compileCurrentGameConfig(ownerActor, artifact.projectId, artifact.versionId);
|
||||
expect(validateGameConfigFromArtifact(config, artifact)).toEqual({ ok: true });
|
||||
return { artifact, config };
|
||||
}
|
||||
|
||||
async function createRepository(): Promise<FileSystemGameLogicArtifactRepository> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s3-task4-"));
|
||||
tempRoots.push(root);
|
||||
return new FileSystemGameLogicArtifactRepository({ root });
|
||||
}
|
||||
|
||||
function runTicks(config: GameConfig, totalMs: number, deltaMs = 1000) {
|
||||
let state = createInitialSimulationState(config);
|
||||
for (let elapsed = 0; elapsed < totalMs; elapsed += deltaMs) {
|
||||
state = updateSimulation(state, config, { deltaMs } satisfies SimulationTick);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function withObjective(config: GameConfig, value: number, deadlineMs?: number): GameConfig {
|
||||
return {
|
||||
...config,
|
||||
objectives: [
|
||||
{
|
||||
id: "earn-test-coins",
|
||||
kind: "resource_at_least",
|
||||
target: "coin",
|
||||
value,
|
||||
description: "测试营收目标",
|
||||
...(deadlineMs === undefined ? {} : { deadlineMs })
|
||||
}
|
||||
]
|
||||
} as GameConfig;
|
||||
}
|
||||
|
||||
describe("S3 simulation behavior model", () => {
|
||||
it("facility produces coins over ticks", async () => {
|
||||
const { config } = await compileFixtureConfig("simulation-game-ir-artifact-valid.json");
|
||||
const coinConfig = {
|
||||
...config,
|
||||
systems: config.systems.map((system) =>
|
||||
system.type === "economy"
|
||||
? {
|
||||
...system,
|
||||
rules: [{ id: "mint-coins", trigger: "facility_tick", inputs: [], outputs: [{ resourceId: "coin", amount: 2 }] }]
|
||||
}
|
||||
: system
|
||||
)
|
||||
};
|
||||
|
||||
const state = runTicks(coinConfig, 3000);
|
||||
|
||||
expect(state.resources.coin).toBe(106);
|
||||
});
|
||||
|
||||
it("npc spawns, walks to facility, consumes, leaves", async () => {
|
||||
const { config } = await compileFixtureConfig("simulation-game-ir-artifact-valid.json");
|
||||
const npcOnlyConfig = {
|
||||
...config,
|
||||
systems: config.systems.map((system) => (system.type === "economy" ? { ...system, rules: system.rules?.filter((rule) => rule.trigger !== "facility_tick") } : system))
|
||||
};
|
||||
let state = createInitialSimulationState(npcOnlyConfig);
|
||||
|
||||
state = updateSimulation(state, npcOnlyConfig, { deltaMs: 5000 });
|
||||
expect(state.npcs).toHaveLength(1);
|
||||
expect(state.npcs[0]).toMatchObject({ state: "queue", targetFacilityId: "facility-coffee-counter" });
|
||||
|
||||
const firstX = state.npcs[0]?.position.x;
|
||||
state = updateSimulation(state, npcOnlyConfig, { deltaMs: 1000 });
|
||||
expect(state.npcs[0]?.position.x).toBeGreaterThan(firstX ?? -1);
|
||||
|
||||
for (let index = 0; index < 1; index += 1) {
|
||||
state = updateSimulation(state, npcOnlyConfig, { deltaMs: 1000 });
|
||||
}
|
||||
|
||||
expect(state.servedNpcCount).toBe(1);
|
||||
expect(state.resources.coin).toBe(108);
|
||||
expect(state.resources.coffee_bean).toBe(11);
|
||||
expect(state.npcs[0]).toMatchObject({ state: "consume", consumedAtMs: 7000 });
|
||||
|
||||
state = updateSimulation(state, npcOnlyConfig, { deltaMs: 1000 });
|
||||
expect(state.npcs[0]).toMatchObject({ state: "leave" });
|
||||
|
||||
state = updateSimulation(state, npcOnlyConfig, { deltaMs: 1000 });
|
||||
expect(state.npcs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("objective completes when revenue reaches threshold", async () => {
|
||||
const { config } = await compileFixtureConfig("simulation-game-ir-artifact-valid.json");
|
||||
const state = runTicks(withObjective(config, 108), 12_000);
|
||||
|
||||
expect(state.status).toBe("objective_completed");
|
||||
expect(state.completedObjectiveIds).toContain("earn-test-coins");
|
||||
});
|
||||
|
||||
it("failure triggers when time expires without objective", async () => {
|
||||
const { config } = await compileFixtureConfig("simulation-game-ir-artifact-valid.json");
|
||||
const state = runTicks(withObjective(config, 999, 2000), 3000);
|
||||
|
||||
expect(state.status).toBe("failed");
|
||||
expect(state.failureReasonCode).toBe("OBJECTIVE_TIME_EXPIRED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("S3 GameConfig to GameLogicModule compiler", () => {
|
||||
it("writes descriptor, artifact and validation report for simulation through repository reads", async () => {
|
||||
const { config } = await compileFixtureConfig("simulation-game-ir-artifact-valid.json");
|
||||
const repository = await createRepository();
|
||||
const result = await new GameLogicCompilerService(repository).compileGameLogicModule(config, {
|
||||
buildInputPath: "apps/api/src/modules/simulation/fixtures/simulation-fixture-v1.game-config.json"
|
||||
});
|
||||
|
||||
expectSharedDescriptorAndReport(result);
|
||||
expect(result.descriptor.moduleType).toBe("simulation_management");
|
||||
expect(result.descriptor.validationStatus).toBeUndefined();
|
||||
|
||||
const report = await repository.readValidationReport(result.descriptor.validationReportId);
|
||||
const artifact = await repository.resolveLogicArtifact(result.descriptor.artifactPath);
|
||||
expect(report).not.toBeNull();
|
||||
expect(report?.checksum).toBe(result.descriptor.checksum);
|
||||
expect(artifact.checksum).toBe(result.descriptor.checksum);
|
||||
|
||||
const consumable = await readConsumableGameLogicModule(repository, result.descriptor);
|
||||
expect(consumable.report).toEqual(report);
|
||||
expect(consumable.source).toContain("export function update");
|
||||
});
|
||||
|
||||
it("rejects unsafe repository paths before writing artifacts", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(repository.writeLogicArtifact({ artifactPath: "../escape/logic.mjs", source: "export {};" })).rejects.toMatchObject({
|
||||
reasonCode: "GAME_LOGIC_ARTIFACT_PATH_UNSAFE",
|
||||
path: "../escape/logic.mjs"
|
||||
});
|
||||
});
|
||||
|
||||
it("light template reaches GameLogicModule artifact through same output contract", async () => {
|
||||
const { config } = await compileFixtureConfig("light-game-ir-artifact-valid.json");
|
||||
const repository = await createRepository();
|
||||
const result = await new GameLogicCompilerService(repository).compileGameLogicModule(config, {
|
||||
buildInputPath: "apps/api/src/modules/simulation/fixtures/light-fixture-v1.game-config.json"
|
||||
});
|
||||
|
||||
expectSharedDescriptorAndReport(result);
|
||||
expect(result.descriptor.moduleType).toBe("light_collect");
|
||||
expect(result.descriptor.imports).toEqual(["@huijing/runtime-sdk"]);
|
||||
|
||||
const consumable = await readConsumableGameLogicModule(repository, result.descriptor);
|
||||
expect(consumable.report.targetContract).toBe("GameLogicModule");
|
||||
expect(consumable.source).toContain("light_collect");
|
||||
});
|
||||
});
|
||||
|
||||
function expectSharedDescriptorAndReport(result: GameLogicCompileResult): void {
|
||||
expect(validateGameLogicModule(result.descriptor)).toEqual({ ok: true });
|
||||
expect(result.descriptor.entrypoints).toEqual(["init", "update", "render", "handleInput", "onPause", "onResume"]);
|
||||
expect(result.descriptor.imports).toEqual(["@huijing/runtime-sdk"]);
|
||||
expect(result.report).toMatchObject({
|
||||
targetContract: "GameLogicModule",
|
||||
artifactPath: result.descriptor.artifactPath,
|
||||
checksum: result.descriptor.checksum,
|
||||
checkedFiles: [result.descriptor.artifactPath],
|
||||
allowedImports: ["@huijing/runtime-sdk", "@huijing/rules", "@huijing/math"],
|
||||
deniedSymbols: [],
|
||||
result: "passed"
|
||||
});
|
||||
}
|
||||
137
docs/memorys/2026-06-04-S3Task4逻辑模块.md
Normal file
137
docs/memorys/2026-06-04-S3Task4逻辑模块.md
Normal file
@ -0,0 +1,137 @@
|
||||
# 2026-06-04 S3 Task4 逻辑模块
|
||||
|
||||
## 结论
|
||||
|
||||
S3 Task4 `Implement Simulation Logic Module Compiler` 已完成实现、controller 自测,并通过 fresh review gate:
|
||||
|
||||
- spec compliance review:PASS,无 Critical / Important。
|
||||
- quality / feasibility review:PASS,无 Critical / Important。
|
||||
|
||||
可以提交 Task4,并在提交后进入 S3 Task5:`Add API Allowlist Validation`。
|
||||
|
||||
## 变更范围
|
||||
|
||||
- `apps/api/src/modules/game-logic/artifact-repository.ts`
|
||||
- `apps/api/src/modules/game-logic/index.ts`
|
||||
- `apps/api/src/modules/simulation/index.ts`
|
||||
- `apps/api/src/modules/simulation/simulation.logic.spec.ts`
|
||||
|
||||
未实现 routes、UI、S4/S5 runtime 或 conversion,也未提前实现 Task5 的完整 AST/import graph allowlist validator。
|
||||
|
||||
## 子代理与 Review Gate
|
||||
|
||||
| Role | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementer | `019e92c5-e18a-7fc0-86fe-21e6e809eacf` | DONE_WITH_CONCERNS | 无;concern 为既有 `pg@9` deprecation warning |
|
||||
| spec compliance review | `019e92dd-26d7-7fb1-9202-17cba1e9a017` | PASS | 无 Critical / Important |
|
||||
| quality / feasibility review | `019e92dd-7e57-74e1-bbd2-c778f131405b` | PASS | 无 Critical / Important |
|
||||
|
||||
## 实现摘要
|
||||
|
||||
- 新增 filesystem `GameLogicArtifactRepository`:
|
||||
- repository-relative path 校验。
|
||||
- 拒绝绝对路径、`../`、反斜杠、NUL 和 root escape。
|
||||
- `ValidationReport.id` 映射到 `reports/<id>.json`。
|
||||
- logic artifact 和 report 使用同目录 temp-file + rename 写入。
|
||||
- `readValidationReport` 和 `resolveLogicArtifact` 都从 repository 磁盘读取。
|
||||
- 写 report 前复算 stored artifact checksum,防止 report 与 artifact 不一致。
|
||||
- 新增 `GameLogicCompilerService.compileGameLogicModule(config, options)`:
|
||||
- 从 S3 `GameConfig` 生成 deterministic `GameLogicModule` descriptor、logic source 和 `ValidationReport`。
|
||||
- descriptor 固定 entrypoints:`init/update/render/handleInput/onPause/onResume`。
|
||||
- descriptor 包含 `sourceRef/artifactPath/checksum/exportSignature/runtimeSdkContractVersion/validationReportId/buildGraphId/languageSubset/imports/forbiddenApis/entrypoints`。
|
||||
- descriptor 不包含 `validationStatus`。
|
||||
- 只生成 allowlist import:`@huijing/runtime-sdk`。
|
||||
- report 对齐 harness `ValidationReport` schema,`allowedImports` 为 `@huijing/runtime-sdk`、`@huijing/rules`、`@huijing/math`。
|
||||
- 新增 `readConsumableGameLogicModule(repository, descriptor)`:
|
||||
- 从 repository 重读 report 和 artifact。
|
||||
- 验证 descriptor checksum、report checksum、resolved artifact checksum 三方一致。
|
||||
- 验证 checkedFiles 包含 descriptor artifactPath。
|
||||
- 新增 simulation 纯函数行为模型:
|
||||
- facility tick 产出资源。
|
||||
- NPC spawn、walk、consume、leave。
|
||||
- revenue objective complete。
|
||||
- deadline failure。
|
||||
- 测试覆盖 light template 通过同一 compiler/repository/output contract 生成 descriptor/report/artifact。
|
||||
|
||||
## Controller 修正
|
||||
|
||||
Controller 复核阶段补了一处状态机可观测性修正:
|
||||
|
||||
```text
|
||||
服务成功后 NPC 先进入 consume。
|
||||
下一 tick 进入 leave。
|
||||
再下一 tick 从 active NPC 列表移除。
|
||||
```
|
||||
|
||||
原因:Task4 明确要求覆盖 `npc spawns, walks to facility, consumes, leaves`;初版服务后直接移除 NPC,虽然资源结果正确,但状态链路不可观测。
|
||||
|
||||
## Controller 自测
|
||||
|
||||
已运行并通过:
|
||||
|
||||
```bash
|
||||
test -f apps/api/src/modules/simulation/simulation.logic.spec.ts
|
||||
test -f apps/api/src/modules/game-logic/artifact-repository.ts
|
||||
pnpm --filter @huijing/api test -- src/modules/simulation/simulation.logic.spec.ts
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm check:s3-scope
|
||||
git diff --check
|
||||
```
|
||||
|
||||
关键输出:
|
||||
|
||||
```text
|
||||
Test Files 19 passed (19)
|
||||
Tests 144 passed (144)
|
||||
S3 scope check passed.
|
||||
```
|
||||
|
||||
`pnpm --filter @huijing/api test -- src/modules/simulation/simulation.logic.spec.ts` 仍输出既有 `pg@9` deprecation warning,退出码为 0。
|
||||
|
||||
## Review Evidence
|
||||
|
||||
Spec reviewer 额外确认:
|
||||
|
||||
```text
|
||||
当前新增范围仅为 apps/api/src/modules/game-logic/**、apps/api/src/modules/simulation/index.ts、simulation.logic.spec.ts。
|
||||
未发现 routes/UI/S4-S8/runtime/conversion/feed/deploy 泄漏。
|
||||
未提前实现 Task5 完整 AST/import validator。
|
||||
已核对 descriptor 字段、固定 entrypoints、allowlist imports、separate ValidationReport、filesystem repository、路径安全、atomic temp-file + rename、repository read path 三方 checksum 校验。
|
||||
```
|
||||
|
||||
Quality reviewer 额外确认:
|
||||
|
||||
```text
|
||||
已区分 Task5/Task6 后续 validator / consumer mock 范围。
|
||||
RuntimeSdkContractVersion 在 API production 中是本地常量,存在轻微 drift 风险。
|
||||
但 Task3 memory 记录 production 直接 import shared contract 当前不可行,Task4 测试用 shared validateGameLogicModule 约束 descriptor 输出,足够作为 Task4 非阻断处理。
|
||||
```
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/src/modules/game-logic/artifact-repository.ts` | `1492a262ddcad5cc3870f535547046a4b4df035f2e929e9d0c30f1f2842aa093` |
|
||||
| `apps/api/src/modules/game-logic/index.ts` | `74dc20fb750a8721c65a69b34a8005b006d5fdaa2f566a2a7d4f39aec17e2a82` |
|
||||
| `apps/api/src/modules/simulation/index.ts` | `5945fc49c6f36831a69e2c3922c8d3a3c1299e6c4991324ade7eaf788fb585fc` |
|
||||
| `apps/api/src/modules/simulation/simulation.logic.spec.ts` | `011bd4ef6d585e0706a8e651a044b0bd8b528de4129607e4681b4591e6b0dbd6` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S3-simulation-slice.md` | `cbc7fb2afaab3cf64b578984b9def7ef265a46aa07029b732ac4ecbc2d68b76a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S3-simulation-slice-design.md` | `08abff96b9a1a7b7680e71678f5c479edab24467dd10185c0b0b0e29ea7e613e` |
|
||||
|
||||
## Git Status Snapshot
|
||||
|
||||
写入本文档前 `git status --short --branch --untracked-files=all`:
|
||||
|
||||
```text
|
||||
## codex/s1-task9-s2-prep...origin/codex/s1-task9-s2-prep [ahead 5]
|
||||
?? apps/api/src/modules/game-logic/artifact-repository.ts
|
||||
?? apps/api/src/modules/game-logic/index.ts
|
||||
?? apps/api/src/modules/simulation/index.ts
|
||||
?? apps/api/src/modules/simulation/simulation.logic.spec.ts
|
||||
```
|
||||
|
||||
## Residual Notes
|
||||
|
||||
- Task5 应实现 API allowlist validation:AST/import graph checks、denied symbols、missing/failed/mismatched ValidationReport 等;不要把 Task5 当成已完成。
|
||||
- 后续若 production 需要直接复用 shared `RuntimeSdkContractVersion`,必须先解决 shared contract package export / NodeNext typecheck / scope gate 三者的兼容问题,不能直接新增 `@huijing/shared-contracts/game-logic-module` export 绕过门禁。
|
||||
- 后续每个 task 继续使用 fresh implementer + controller 自测 + fresh spec/quality review;实现子代理等待上限 30 分钟,review 子代理等待上限 15 分钟。
|
||||
Loading…
x
Reference in New Issue
Block a user