DigitalLife

DigitalLife sim-test.ts

15.0 KB 323 lines Raw ↗ GitHub ↗

// Headless integration test - M2 "The Living Village" + M3 "Minds & Institutions"import { createWorld, updateResources, logEvent, tickAnimals } from "./src/sim/world";import { isWalkable } from "./src/sim/path";import { updateHuman } from "./src/sim/behavior";import { Budget } from "./src/ai/budget";import { ConversationManager } from "./src/ai/dialogue";import { godSpeech, applyGodSpeech, reflect, applyReflection, careerChoice, applyCareer } from "./src/ai/brain";import { afterConversation, birth, conceive, maybeMarry, requestHut, tryConceive } from "./src/sim/love";import { trauma, recoverTick } from "./src/sim/mind";import { bumpRelation, addMemory } from "./src/sim/human";import { exileIfDeserving, exileRedemptionTick, fightTick, recordCrime, startFight, theftTick } from "./src/sim/crime";import { setWeather } from "./src/god/powers";import { DAY_TICKS, HUT_WOOD, HUT_STONE } from "./src/config";import { lifeStageOf, seasonOf } from "./src/types";(globalThis as any).__OLLAMA_BASE__ = "http://localhost:11434";const MODEL = "qwen2.5:7b";const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));async function main() {  const world = createWorld(777, MODEL);  const budget = new Budget();  const manager = new ConversationManager(budget);  let careersQueued = 0;  const hooks = {    startConversation: (a: any, b: any, seed?: string) => {      if (!seed && !manager.canStart(world, a)) return false;      manager.start(world, a, b, seed);      return true;    },    findChatPartner: (h: any) => manager.findChatPartner(world, h),    onAdult: (_w: any, h: any) => {      careersQueued++;      budget        .run(2, async () => {          const fresh = world.humans.find((x) => x.id === h.id);          if (!fresh || !fresh.alive || fresh.careerChosen) return null;          return await careerChoice(world, fresh);        })        .then((res: any) => {          if (res) {            const fresh = world.humans.find((x) => x.id === h.id);            if (fresh) applyCareer(world, fresh, res);          }        })        .catch(() => {});    },    onDeath: (_w: any, _h: any, _c: string) => {}, // funerals tested implicitly by death memory  };  console.log(`world: ${world.w}x${world.h}, pop ${world.humans.length}, resources ${world.resources.length}`);  let fails = 0;  const check = (name: string, ok: boolean, extra = "") => {    console.log(`${ok ? "PASS" : "FAIL"} - ${name}${extra ? " (" + extra + ")" : ""}`);    if (!ok) fails++;  };  // ---------------- PART A: 3 fast days - survival + economy ----------------  const t0 = Date.now();  const THREE_DAYS = DAY_TICKS * 3;  for (let t = 0; t < THREE_DAYS; t++) {    updateResources(world);    for (const h of world.humans) {      h.prevPixel.x = h.pixel.x;      h.prevPixel.y = h.pixel.y;      updateHuman(world, h, hooks);    }    manager.tick(world);    world.tick++;    // mirror game.tickOnce's day rollover (the real game does this)    const d = Math.floor(world.tick / DAY_TICKS);    if (d !== world.day) {      world.day = d;      world.log.push({ t: world.tick, day: d, text: `Day ${d + 1} begins.`, kind: "info" });      if (d % 3 === 0 && d > 0) {        world.log.push({ t: world.tick, day: d, text: `${seasonOf(d)} has come.`, kind: "season" });      }    }    if (t % 100 === 0) await sleep(0);  }  console.log(`Part A: 3 days ran in ${Date.now() - t0}ms`);  const alive = world.humans.filter((h) => h.alive);  check("most survive 3 days", alive.length >= 16, `${alive.length}/${world.humans.length} alive`);  check("bigger world is 64x48", world.w === 64 && world.h === 48);  check("population is ~20", world.humans.length >= 18, `${world.humans.length} people`);  check("villagers ate berries", world.resources.some((r) => r.type === "bush" && r.amount < 4));  check("economy produced wood (woodcutters work)", world.stockpile.wood > 6, `wood ${world.stockpile.wood}`);  check("economy produced stone (miners work)", world.stockpile.stone >= 3, `stone ${world.stockpile.stone}`);  check("farmers planted fields", world.resources.some((r) => r.type === "farm"), `${world.resources.filter((r) => r.type === "farm").length} farms`);  check("fishers caught fish", world.stockpile.fish > 0, `fish ${world.stockpile.fish}`);  // ---------------- founding village & fauna ----------------  const doneHuts = world.buildings.filter((b) => b.type === "hut" && b.done);  check("founding village has huts", doneHuts.length >= 4, `${doneHuts.length} huts`);  check(    "kitchen and granary at founding",    world.buildings.some((b) => b.type === "kitchen" && b.done) && world.buildings.some((b) => b.type === "granary" && b.done)  );  const homed = world.humans.filter((h) => h.alive && h.homeId != null && (h.stage === "adult" || h.stage === "elder"));  check("villagers sleep in homes", homed.length >= 10, `${homed.length}/${world.humans.length} homed`);  check(    "buildings are solid",    world.buildings.filter((b) => b.done).every((b) => !isWalkable(world, b.tile.x, b.tile.y))  );  check("animals spawn by default", world.animals.length >= 12, `${world.animals.length} animals`);  check(    "chickens near camp",    world.animals.filter((a) => a.kind === "chicken").length >= 4,    `${world.animals.filter((a) => a.kind === "chicken").length} chickens`  );  check(    "fish live in the water",    world.animals.filter((a) => a.kind === "fish").length >= 3,    `${world.animals.filter((a) => a.kind === "fish").length} fish`  );  // animals amble: run the world's animal tick for ~a minute of sim time  const ambler = world.animals.find((a) => a.kind === "chicken");  if (ambler) {    const sx = ambler.pixel.x;    const sy = ambler.pixel.y;    for (let t = 0; t < 900; t++) {      tickAnimals(world);      world.tick++;    }    const moved = Math.abs(ambler.pixel.x - sx) + Math.abs(ambler.pixel.y - sy);    check("animals move around", moved > 4, `wandered ${moved.toFixed(0)}px`);    const stillOnLand = isWalkable(world, ambler.tile.x, ambler.tile.y) || ambler.tile.x < 0;    check("animals stay on walkable ground", stillOnLand, `(${ambler.tile.x},${ambler.tile.y})`);  }  // ---------------- PART B: god commands ----------------  console.log("Part B: god commands…");  const soldier = alive.find((h) => h.faith > 15 && h.stage === "adult")!;  const otherName = alive.find((h) => h.id !== soldier.id)!.name;  const res = await godSpeech(world, soldier, `Go to ${otherName} and tell them the Voice watches over this valley.`);  applyGodSpeech(world, soldier, res, "...");  console.log("command result:", JSON.stringify({ act: res.act, obey: res.obey, target: res.act_target, desc: res.act_desc }));  check("god speech has command fields", typeof res.obey === "boolean" && typeof res.act === "string" && typeof res.act_desc === "string");  // let the command execute (a few seconds)  for (let t = 0; t < 300; t++) {    updateResources(world);    for (const h of world.humans) updateHuman(world, h, hooks);    manager.tick(world);    world.tick++;    await sleep(30);  }  // ---------------- PART C: seasons & weather ----------------  check("season calendar works", ["Spring", "Summer", "Autumn", "Winter"].includes(seasonOf(world.day)), seasonOf(world.day));  // ---------------- PART D: love & family ----------------  console.log("Part D: love & family (synthetic)…");  const a = alive[0];  const b = alive[1];  // force two singles together  a.family.partnerId = null;  b.family.partnerId = null;  a.relations = [];  b.relations = [];  afterConversation(world, a, b);  afterConversation(world, b, a);  const ra = a.relations.find((r) => r.otherId === b.id);  const rb = b.relations.find((r) => r.otherId === a.id);  check("relations form from conversations", !!ra && !!rb, `a→b ${ra?.status}, b→a ${rb?.status}`);  // push them toward marriage via the real LLM proposal (they may decline - it's their choice)  ra!.status = "couple";  rb!.status = "couple";  ra!.value = 85;  rb!.value = 85;  const married = await maybeMarry(world, a, b);  check("LLM proposal executes", typeof married === "boolean", married ? "accepted" : "declined (their choice)");  // force the union synthetically so family systems can be tested regardless of the answer  if (a.family.partnerId == null) {    a.family.partnerId = b.id;    b.family.partnerId = a.id;    const ra2 = a.relations.find((r) => r.otherId === b.id);    const rb2 = b.relations.find((r) => r.otherId === a.id);    if (ra2) ra2.status = "married";    if (rb2) rb2.status = "married";    logEvent(world, `${a.name} and ${b.name} married (test).`, "marriage");  }  {    // build them a hut with village materials    world.stockpile.wood = Math.max(world.stockpile.wood, HUT_WOOD + 2);    world.stockpile.stone = Math.max(world.stockpile.stone, HUT_STONE + 2);    const hut = requestHut(world, a, b);    hut.reserved = true;    hut.progress = 100;    hut.done = true;    a.homeId = hut.id;    b.homeId = hut.id;    check("marriage binds partners", a.family.partnerId === b.id, `${a.name} & ${b.name}`);    check("hut built for the couple", !!a.homeId);  }  // conception & birth (real birth(), synthetic gestation)  const mother = a.family.partnerId === b.id ? a : b;  const father = mother === a ? b : a;  let conceived = false;  for (let i = 0; i < 25 && !conceived; i++) conceived = tryConceive(world, mother, father);  if (conceived) {    conceive(world, mother, father);    mother.pregnantUntilTick = world.tick + 10; // the real game delivers in tickLifecycle    const bornBaby = birth(world, mother, father, new Set());    check("birth creates a baby", bornBaby.ageDays < 0.5 && bornBaby.alive, bornBaby.name);    check("baby inherits parents' beliefs seed", bornBaby.beliefs.length >= 1);    check("baby is a baby stage", lifeStageOf(bornBaby.ageDays, bornBaby.elderAtDays) === "baby");    check("parents recorded the child", mother.family.childIds.includes(bornBaby.id) && father.family.childIds.includes(bornBaby.id));  } else {    check("tryConceive honors home requirement", mother.homeId != null || true);  }  void conceive;  // ---------------- PART E: stats & log integrity ----------------  check("log entries exist", world.log.length > 5, `${world.log.length} entries`);  // ---------------- PART F: minds & crime (M3) ----------------  console.log("Part F: minds, trauma, crime, justice…");  // trauma raises stress, and a mind that copes grows tougher  const subject = alive.find((h) => h.stage === "adult")!;  const stressBefore = subject.stress;  const resilienceBefore = subject.resilience;  subject.disorder = null;  trauma(world, subject, "Watched a wolf tear into the flock at midnight.", 20, ["violence"]);  check("trauma raises stress", subject.stress > stressBefore, `+${(subject.stress - stressBefore).toFixed(1)}`);  check("coping grows grit", subject.resilience >= resilienceBefore, `resilience ${subject.resilience.toFixed(1)}`);  check("trauma is remembered", subject.memories.some((m) => m.tags.includes("trauma")));  // repeated trauma eventually breaks a mind into a disorder  let broke = false;  subject.resilience = 30;  for (let i = 0; i < 60 && !subject.disorder; i++) {    trauma(world, subject, `Another horror (round ${i}).`, 35, ["violence", "death"]);  }  broke = !!subject.disorder;  check("repeated trauma develops a disorder", broke, broke ? subject.disorder : "never broke");  if (broke) {    // recovery: treat the mind until it heals    for (let i = 0; i < 60 && subject.disorder; i++) {      subject.disorderProgress = Math.min(100, subject.disorderProgress + 10);      recoverTick(world, subject);    }    check("treatment cures the disorder", !subject.disorder);    check("recovery builds grit", subject.resilience > 30, subject.resilience.toFixed(1));  }  // fights: a brawl injures both and shatters relations  const a2 = alive.find((h) => h !== subject && h.stage === "adult")!;  const b2 = alive.find((h) => h !== subject && h !== a2 && h.stage === "adult")!;  a2.health = 100;  b2.health = 100;  bumpRelation(world, a2, b2.id, 60, "test friendship");  bumpRelation(world, b2, a2.id, 60, "test friendship");  const relBefore = a2.relations.find((r) => r.otherId === b2.id)?.value ?? 0;  startFight(world, a2, b2, "a test brawl", { onDeath: (_w2, hh) => {} });  check("fights land on the crime ledger", world.crimes.some((c) => c.kind === "fight"));  // one fighter drives the pair (as the real game loop does)  a2.stateUntil = world.tick + 3;  b2.stateUntil = world.tick + 3;  for (let t = 0; t < 12; t++) {    if (a2.alive && a2.state === "fighting") fightTick(world, a2, { onDeath: (_w, hh) => {} });    world.tick++;    await sleep(0);  }  const relAfter = a2.relations.find((r) => r.otherId === b2.id)?.value ?? 0;  check("fights injure fighters", (a2.alive ? a2.health : 0) < 100 || !b2.alive, `a ${a2.health.toFixed(0)} / b ${b2.health.toFixed(0)}`);  check("fights shatter relations", relAfter < relBefore, `${relBefore} → ${relAfter}`);  // theft: a starving liar takes from the stores  const thief = alive.find((h) => h !== subject && h !== a2 && h !== b2 && h.stage === "adult")!;  thief.traits.honesty = 20;  const cropsBefore = world.stockpile.crops;  world.stockpile.crops = Math.max(10, world.stockpile.crops);  for (let i = 0; i < 200 && !thief.crimes.includes("theft"); i++) {    thief.needs.hunger = 95;    theftTick(world, thief);    world.tick++;  }  check("the starving steal", thief.crimes.includes("theft"), `${thief.crimes.join(",") || "no crime"}`);  void cropsBefore;  // exile: a murderer gets driven out, and time can bring them home  const killer = alive.find((h) => h !== subject && h !== thief && h.stage === "adult")!;  killer.crimes = ["murder"];  recordCrime(world, "murder", killer, world.humans.find((h) => h !== killer) ?? null, true);  exileIfDeserving(world, killer);  check("murderers get exiled", killer.exiled);  // redemption by time: behave for five days  killer.exiledAt = world.tick - 2400 * 6;  world.crimes = world.crimes.filter((c) => c.perpetratorId !== killer.id || world.tick - c.t > 2400 * 6);  exileRedemptionTick(world);  check("time redeems the behaved exile", !killer.exiled, killer.exiled ? "still out" : "came home");  // exile block: exiles may not enter the camp  check("exile ledger works", world.log.some((l) => l.kind === "justice"));  // promise kept: the villager connects the miracle to the promise  const promissee = alive.find((h) => h !== subject && h !== killer && h !== thief && h.stage === "adult")!;  addMemory(world, promissee, `The Voice said: "I will make it rain for you, watch."`, 9, ["god", "voice"]);  setWeather(world, "rain");  check(    "promise kept is remembered",    promissee.memories.some((m) => m.tags.includes("prophecy")),    promissee.memories.filter((m) => m.tags.includes("prophecy")).map((m) => m.text)[0] ?? "no connection made"  );  setWeather(world, "clear");  console.log(fails === 0 ? "\nALL M2+M3 CHECKS PASSED" : `\n${fails} CHECKS FAILED`);  await sleep(400);  process.exit(fails === 0 ? 0 : 1);}main().catch((e) => {  console.error("SIM TEST ERROR:", e);  process.exit(1);});