feat(s3): add simulation logic compiler

This commit is contained in:
zizi 2026-06-04 21:46:14 +08:00
parent 490f2a3b25
commit 991fc3d6bb
5 changed files with 1042 additions and 0 deletions

View 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;
}

View 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
};

View 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;
}

View 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"
});
}

View File

@ -0,0 +1,137 @@
# 2026-06-04 S3 Task4 逻辑模块
## 结论
S3 Task4 `Implement Simulation Logic Module Compiler` 已完成实现、controller 自测,并通过 fresh review gate
- spec compliance reviewPASS无 Critical / Important。
- quality / feasibility reviewPASS无 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 validationAST/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 分钟。