games-development-ai/apps/api/src/modules/simulation/simulation.logic.spec.ts

224 lines
9.1 KiB
TypeScript

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