From 991fc3d6bb406bfa1553bf1e9d2f21cc8e11af64 Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 4 Jun 2026 21:46:14 +0800 Subject: [PATCH] feat(s3): add simulation logic compiler --- .../modules/game-logic/artifact-repository.ts | 170 +++++++++++ apps/api/src/modules/game-logic/index.ts | 278 ++++++++++++++++++ apps/api/src/modules/simulation/index.ts | 234 +++++++++++++++ .../simulation/simulation.logic.spec.ts | 223 ++++++++++++++ docs/memorys/2026-06-04-S3Task4逻辑模块.md | 137 +++++++++ 5 files changed, 1042 insertions(+) create mode 100644 apps/api/src/modules/game-logic/artifact-repository.ts create mode 100644 apps/api/src/modules/game-logic/index.ts create mode 100644 apps/api/src/modules/simulation/index.ts create mode 100644 apps/api/src/modules/simulation/simulation.logic.spec.ts create mode 100644 docs/memorys/2026-06-04-S3Task4逻辑模块.md diff --git a/apps/api/src/modules/game-logic/artifact-repository.ts b/apps/api/src/modules/game-logic/artifact-repository.ts new file mode 100644 index 00000000..4a1b48c2 --- /dev/null +++ b/apps/api/src/modules/game-logic/artifact-repository.ts @@ -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; + 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; + readonly readValidationReport: (id: string) => Promise; + readonly resolveLogicArtifact: (artifactPath: string) => Promise; +}; + +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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/modules/game-logic/index.ts b/apps/api/src/modules/game-logic/index.ts new file mode 100644 index 00000000..8f20f30b --- /dev/null +++ b/apps/api/src/modules/game-logic/index.ts @@ -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; + 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 = { + 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 { + 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 { + 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): never { + throw new UnprocessableEntityException(apiError("GAME_LOGIC_COMPILE_FAILED", message, { reasonCode, ...details })); +} + +export const __gameLogicCompilerInternals = { + checksumText, + renderLogicModuleSource +}; diff --git a/apps/api/src/modules/simulation/index.ts b/apps/api/src/modules/simulation/index.ts new file mode 100644 index 00000000..5cbd27d5 --- /dev/null +++ b/apps/api/src/modules/simulation/index.ts @@ -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; + readonly npcs: readonly SimulationNpcInstance[]; + readonly nextNpcSeq: number; + readonly spawnTimers: Record; + 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; +}; +type SimulationFacility = Extract["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, inputs: readonly string[]): boolean { + return inputs.every((resourceId) => (resources[resourceId] ?? 0) > 0); +} + +function consumeInputs(resources: Record, inputs: readonly string[], multiplier: number): Record { + 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, + outputs: readonly { readonly resourceId: string; readonly amount: number }[], + multiplier: number +): Record { + 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; +} diff --git a/apps/api/src/modules/simulation/simulation.logic.spec.ts b/apps/api/src/modules/simulation/simulation.logic.spec.ts new file mode 100644 index 00000000..33e86da6 --- /dev/null +++ b/apps/api/src/modules/simulation/simulation.logic.spec.ts @@ -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 { + 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 { + 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" + }); +} diff --git a/docs/memorys/2026-06-04-S3Task4逻辑模块.md b/docs/memorys/2026-06-04-S3Task4逻辑模块.md new file mode 100644 index 00000000..44aefc47 --- /dev/null +++ b/docs/memorys/2026-06-04-S3Task4逻辑模块.md @@ -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/.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 分钟。