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