224 lines
9.1 KiB
TypeScript
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"
|
|
});
|
|
}
|