From 5f296dc93c8b37280ad38a50bbf6a6ab5b1663d4 Mon Sep 17 00:00:00 2001 From: Christopher Dollard Date: Thu, 8 Aug 2024 18:15:33 -0400 Subject: [PATCH] 0.10 - Rework cavern generation (#36) --- package.json | 2 +- src/core/architects/collapse.ts | 0 src/core/architects/established_hq.ts | 20 +- src/core/architects/fissure.ts | 117 ++++++++ src/core/architects/flooded.ts | 29 +- src/core/architects/index.ts | 21 +- src/core/architects/loopback.ts | 94 ++++++ src/core/architects/lost_miners.ts | 32 +- src/core/architects/nomads.ts | 20 +- src/core/architects/simple_cave.ts | 28 +- src/core/architects/simple_hall.ts | 18 +- src/core/architects/simple_spawn.ts | 10 +- src/core/architects/slugs.ts | 36 ++- src/core/architects/thin_hall.ts | 19 +- src/core/architects/treasure.ts | 43 +-- .../architects/utils/creature_spawners.ts | 139 +++++---- src/core/architects/utils/oyster.ts | 283 +++--------------- src/core/architects/utils/rough.ts | 196 ++++++++++++ src/core/common/context.ts | 10 +- src/core/lore/graphs/completeness.test.ts | 12 +- src/core/lore/graphs/events.ts | 3 +- src/core/lore/graphs/seismic.ts | 19 ++ src/core/lore/lore.ts | 15 + src/core/models/architect.ts | 9 +- src/core/models/tiles.ts | 66 ++-- src/core/transformers/01_planning/04_pearl.ts | 5 +- src/core/transformers/02_masonry/03_grout.ts | 23 +- src/core/transformers/02_masonry/04_sand.ts | 20 ++ .../02_masonry/{04_fine.ts => 05_fine.ts} | 0 .../02_masonry/{05_annex.ts => 06_annex.ts} | 2 +- src/core/transformers/02_masonry/index.ts | 6 +- src/core/transformers/03_plastic/00_fence.ts | 2 +- .../transformers/04_ephemera/00_aerate.ts | 15 +- .../transformers/04_ephemera/03_program.ts | 39 ++- .../transformers/04_ephemera/04_serialize.ts | 17 ++ src/core/transformers/README.md | 86 ++++-- .../map_preview/script_preview/index.tsx | 75 ++--- .../script_preview/styles.module.scss | 20 +- src/webui/components/popovers/error.tsx | 2 +- 39 files changed, 1008 insertions(+), 545 deletions(-) create mode 100644 src/core/architects/collapse.ts create mode 100644 src/core/architects/fissure.ts create mode 100644 src/core/architects/loopback.ts create mode 100644 src/core/architects/utils/rough.ts create mode 100644 src/core/lore/graphs/seismic.ts create mode 100644 src/core/transformers/02_masonry/04_sand.ts rename src/core/transformers/02_masonry/{04_fine.ts => 05_fine.ts} (100%) rename src/core/transformers/02_masonry/{05_annex.ts => 06_annex.ts} (95%) diff --git a/package.json b/package.json index 1c341dc..5b16bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "groundhog", - "version": "0.9.6", + "version": "0.10.0", "homepage": "https://charredutensil.github.io/groundhog", "private": true, "dependencies": { diff --git a/src/core/architects/collapse.ts b/src/core/architects/collapse.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/core/architects/established_hq.ts b/src/core/architects/established_hq.ts index edf08ad..e0f794d 100644 --- a/src/core/architects/established_hq.ts +++ b/src/core/architects/established_hq.ts @@ -16,7 +16,7 @@ import { import { Tile } from "../models/tiles"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; import { MakeBuildingFn, getBuildings } from "./utils/buildings"; -import { Rough, RoughOyster } from "./utils/oyster"; +import { mkRough, Rough } from "./utils/rough"; import { position } from "../models/position"; import { getPlaceRechargeSeams, sprinkleCrystals } from "./utils/resources"; import { placeLandslides } from "./utils/hazards"; @@ -224,7 +224,7 @@ function getPlaceBuildings({ }; } -export const gFoundHq = mkVars("gFoundHq", ["foundHq"]); +export const gLostHq = mkVars("gLostHq", ["foundHq"]); const WITH_FIND_OBJECTIVE: Pick< Architect, @@ -233,14 +233,14 @@ const WITH_FIND_OBJECTIVE: Pick< objectives: () => ({ variables: [ { - condition: `${gFoundHq.foundHq}>0`, + condition: `${gLostHq.foundHq}>0`, description: "Find the lost Rock Raider HQ", }, ], sufficient: false, }), scriptGlobals: () => - scriptFragment("# Lost HQ Globals", `int ${gFoundHq.foundHq}=0`), + scriptFragment("# Globals: Lost HQ", `int ${gLostHq.foundHq}=0`), script({ cavern, plan }) { const discoPoint = getDiscoveryPoint(cavern, plan); if (!discoPoint) { @@ -251,11 +251,11 @@ const WITH_FIND_OBJECTIVE: Pick< return r.pearlRadius > p.pearlRadius ? r : p; }).center; - const v = mkVars(`p${plan.id}FoundHq`, ["messageDiscover", "onDiscover"]); + const v = mkVars(`p${plan.id}LostHq`, ["messageDiscover", "onDiscover"]); const message = cavern.lore.foundHq(cavern.dice).text; return scriptFragment( - `# Lost HQ ${plan.id}`, + `# P${plan.id}: Lost HQ`, `string ${v.messageDiscover}="${escapeString(message)}"`, `if(change:${transformPoint(cavern, discoPoint)})[${v.onDiscover}]`, eventChain( @@ -263,7 +263,7 @@ const WITH_FIND_OBJECTIVE: Pick< `msg:${v.messageDiscover};`, `pan:${transformPoint(cavern, camPoint)};`, `wait:1;`, - `${gFoundHq.foundHq}=1;`, + `${gLostHq.foundHq}=1;`, ), ); }, @@ -272,12 +272,12 @@ const WITH_FIND_OBJECTIVE: Pick< const BASE: Omit, "prime"> & Pick, "rough" | "roughExtent"> = { ...DefaultCaveArchitect, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 2 }, { of: Rough.FLOOR, width: 0, grow: 2 }, { of: Rough.DIRT, width: 0, grow: 0.5 }, { of: Rough.DIRT_OR_LOOSE_ROCK, grow: 0.25 }, - { of: Rough.HARD_ROCK, grow: 0.25 }, + { of: Rough.MIX_LOOSE_HARD_ROCK, grow: 0.25 }, ), crystalsFromMetadata: (metadata) => metadata.crystalsInBuildings, placeRechargeSeam: getPlaceRechargeSeams(1), @@ -332,3 +332,5 @@ export const ESTABLISHED_HQ = [ ...WITH_FIND_OBJECTIVE, }, ] as const satisfies readonly Architect[]; + +export default ESTABLISHED_HQ; \ No newline at end of file diff --git a/src/core/architects/fissure.ts b/src/core/architects/fissure.ts new file mode 100644 index 0000000..d2fd871 --- /dev/null +++ b/src/core/architects/fissure.ts @@ -0,0 +1,117 @@ +import { Architect, BaseMetadata } from "../models/architect"; +import { DefaultHallArchitect, PartialArchitect } from "./default"; +import { mkRough, Rough } from "./utils/rough"; +import { + escapeString, + eventChain, + mkVars, + scriptFragment, + transformPoint, +} from "./utils/script"; +import { DiscoveredCavern } from "../transformers/03_plastic/01_discover"; +import { Plan } from "../models/plan"; +import { monsterSpawnScript } from "./utils/creature_spawners"; +import { Hardness, Tile } from "../models/tiles"; + +// Fissure halls are not drillable, but suddenly crack open without any player +// involvement after being discovered. + +const METADATA = { tag: "fissure" } as const satisfies BaseMetadata; + +function getDiscoveryPoints(cavern: DiscoveredCavern, plan: Plan) { + const used: true[] = []; + return plan.innerPearl[0].filter((pos) => { + const dz = cavern.discoveryZones.get(...pos); + if (!dz || dz.openOnSpawn || used[dz.id]) { + return false; + } + used[dz.id] = true; + return true; + }); +} + +const sVars = (plan: Plan) => + mkVars(`p${plan.id}Fissure`, [ + "onDiscover", + `onTrip`, + `msgForeshadow`, + `spawn`, + "tripCount", + ]); + +const BASE: PartialArchitect = { + ...DefaultHallArchitect, + prime: () => METADATA, + script: ({ cavern, plan }) => { + const v = sVars(plan); + const discoveryPoints = getDiscoveryPoints(cavern, plan); + const panTo = plan.innerPearl[0][Math.floor(plan.innerPearl[0].length / 2)]; + const rng = cavern.dice.script(plan.id); + + const drillPoints = plan.innerPearl[0].filter((pos) => { + const t = cavern.tiles.get(...pos) ?? Tile.SOLID_ROCK; + return t.isWall && t.hardness < Hardness.SOLID; + }); + const trips = Math.ceil((discoveryPoints.length + drillPoints.length) / 4); + + return scriptFragment( + `# P${plan.id}: Fissure`, + `int ${v.tripCount}=0`, + `string ${v.msgForeshadow}="${escapeString(cavern.lore.generateSeismicForeshadow(rng).text)}"`, + ...discoveryPoints.map( + (pos) => `if(change:${transformPoint(cavern, pos)})[${v.onTrip}]`, + ), + ...drillPoints.map( + (pos) => `if(drill:${transformPoint(cavern, pos)})[${v.onTrip}]`, + ), + eventChain( + v.onTrip, + `${v.tripCount}+=1;`, + `((${v.tripCount}!=${trips}))return;`, + `wait:random(5)(30);`, + `shake:1;`, + `msg:${v.msgForeshadow};`, + `wait:random(30)(150);`, + `shake:2;`, + `pan:${transformPoint(cavern, panTo)};`, + `wait:1;`, + `shake:4;`, + ...plan.innerPearl[0] + .filter((pos) => cavern.tiles.get(...pos)?.isWall) + .map( + (pos) => `drill:${transformPoint(cavern, pos)};` as `${string};`, + ), + cavern.context.hasMonsters && `${v.spawn};`, + ), + ); + }, + monsterSpawnScript: (args) => { + const bps = args.plan.path.baseplates; + const ebps = [bps[0], bps[bps.length - 1]]; + return monsterSpawnScript(args, { + armEvent: sVars(args.plan).spawn, + emerges: ebps.map((bp) => { + const [x, y] = bp.center; + return { x: Math.floor(x), y: Math.floor(y), radius: bp.pearlRadius }; + }), + maxTriggerCount: 1, + triggerOnFirstArmed: true, + }); + }, +}; + +const FISSURE = [ + { + name: "Fissure Hall", + ...BASE, + ...mkRough({ of: Rough.SOLID_ROCK }, { of: Rough.VOID, grow: 1 }), + hallBid: ({ plan, plans }) => + !plan.fluid && + plan.path.kind === "auxiliary" && + plan.path.exclusiveSnakeDistance > 1 && + !plan.intersects.some((_, i) => plans[i].metadata?.tag === "fissure") && + 1, + }, +] as const satisfies readonly Architect[]; + +export default FISSURE; diff --git a/src/core/architects/flooded.ts b/src/core/architects/flooded.ts index 153fd7a..6b3c60a 100644 --- a/src/core/architects/flooded.ts +++ b/src/core/architects/flooded.ts @@ -1,7 +1,7 @@ import { Architect } from "../models/architect"; import { Tile } from "../models/tiles"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster, weightedSprinkle } from "./utils/oyster"; +import { mkRough, Rough, weightedSprinkle } from "./utils/rough"; import { intersectsOnly, isDeadEnd } from "./utils/intersects"; import { monsterSpawnScript } from "./utils/creature_spawners"; import { sprinkleCrystals } from "./utils/resources"; @@ -25,11 +25,16 @@ const FLOODED = [ { name: "Lake", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.WATER, grow: 2 }, { of: Rough.FLOOR, shrink: 1, grow: 1 }, { of: Rough.LOOSE_ROCK }, - { of: Rough.LOOSE_OR_HARD_ROCK }, + { + of: weightedSprinkle( + { item: Rough.LOOSE_ROCK, bid: 10 }, + { item: Rough.LOOSE_OR_HARD_ROCK, bid: 1 }, + ), + }, ), caveBid: ({ plan }) => plan.fluid === Tile.WATER && plan.pearlRadius < 10 && 1, @@ -37,7 +42,7 @@ const FLOODED = [ { name: "Lake With Sleeping Monsters", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.WATER, grow: 2 }, { of: Rough.FLOOR, grow: 1 }, { of: Rough.LOOSE_ROCK }, @@ -60,7 +65,7 @@ const FLOODED = [ { name: "Island", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 0.7 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.2 }, { of: Rough.ALWAYS_LOOSE_ROCK, width: 0, grow: 0.2 }, @@ -69,7 +74,7 @@ const FLOODED = [ { of: Rough.WATER, grow: 2 }, { of: Rough.FLOOR, grow: 1 }, { of: Rough.LOOSE_ROCK }, - { of: Rough.LOOSE_OR_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, ), caveBid: ({ plan }) => plan.fluid === Tile.WATER && plan.pearlRadius > 5 && 2, @@ -77,7 +82,7 @@ const FLOODED = [ { name: "Lava Lake", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.LAVA, grow: 2 }, { of: Rough.FLOOR, grow: 1 }, { of: Rough.LOOSE_ROCK, shrink: 1 }, @@ -89,7 +94,7 @@ const FLOODED = [ { name: "Lava Island", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 0.7 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.2 }, { of: Rough.ALWAYS_LOOSE_ROCK, width: 0, grow: 0.2 }, @@ -105,7 +110,7 @@ const FLOODED = [ { name: "Peninsula", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 0.7 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.2 }, { of: Rough.ALWAYS_LOOSE_ROCK, width: 0, grow: 0.2 }, @@ -113,7 +118,7 @@ const FLOODED = [ { of: Rough.ALWAYS_FLOOR, width: 2, grow: 0.1 }, { of: Rough.BRIDGE_ON_WATER, grow: 2 }, { of: Rough.LOOSE_ROCK }, - { of: Rough.AT_MOST_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, ), caveBid: ({ plans, plan }) => plan.fluid === Tile.WATER && @@ -125,7 +130,7 @@ const FLOODED = [ { name: "Lava Peninsula", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 0.7 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.2 }, { of: Rough.ALWAYS_LOOSE_ROCK, grow: 0.2 }, @@ -144,7 +149,7 @@ const FLOODED = [ name: "Lava Stalagmite Cave", ...BASE, crystalsToPlace: ({ plan }) => plan.crystalRichness * plan.perimeter * 2, - ...new RoughOyster( + ...mkRough( { of: weightedSprinkle( { item: Rough.ALWAYS_DIRT, bid: 0.01 }, diff --git a/src/core/architects/index.ts b/src/core/architects/index.ts index 4a8ed28..de92782 100644 --- a/src/core/architects/index.ts +++ b/src/core/architects/index.ts @@ -1,8 +1,10 @@ import { Architect } from "../models/architect"; -import { ESTABLISHED_HQ } from "./established_hq"; +import ESTABLISHED_HQ, { HqMetadata } from "./established_hq"; +import FISSURE from "./fissure"; import FLOODED from "./flooded"; -import LOST_MINERS from "./lost_miners"; -import NOMAD_SPAWN from "./nomads"; +import LOOPBACK from "./loopback"; +import LOST_MINERS, { LostMinersMetadata } from "./lost_miners"; +import NOMAD_SPAWN, { NomadsMetadata } from "./nomads"; import SIMPLE_CAVE from "./simple_cave"; import SIMPLE_HALL from "./simple_hall"; import SIMPLE_SPAWN from "./simple_spawn"; @@ -10,9 +12,18 @@ import SLUGS from "./slugs"; import THIN_HALL from "./thin_hall"; import TREASURE from "./treasure"; +export type AnyMetadata = + | undefined + | HqMetadata + | LostMinersMetadata + | NomadsMetadata + | { tag: "fissure" | "slugNest" | "treasure" }; + export const ARCHITECTS = [ ...ESTABLISHED_HQ, + ...FISSURE, ...FLOODED, + ...LOOPBACK, ...LOST_MINERS, ...NOMAD_SPAWN, ...SIMPLE_CAVE, @@ -21,6 +32,4 @@ export const ARCHITECTS = [ ...SLUGS, ...THIN_HALL, ...TREASURE, -] as const satisfies readonly Architect[]; - -export type AnyMetadata = ReturnType<(typeof ARCHITECTS)[number]["prime"]>; +] as const satisfies readonly Architect[]; diff --git a/src/core/architects/loopback.ts b/src/core/architects/loopback.ts new file mode 100644 index 0000000..802c478 --- /dev/null +++ b/src/core/architects/loopback.ts @@ -0,0 +1,94 @@ +import { Architect } from "../models/architect"; +import { Tile } from "../models/tiles"; +import { DefaultHallArchitect } from "./default"; +import { mkRough, Rough } from "./utils/rough"; +import { NSEW, Point } from "../common/geometry"; + +// Loopback halls use solid rock to ensure they can only be accessed after +// excavating both ends. Note: This algorithm is a bit weak in that it can err +// in both directions - Sometimes, it generates a hall that isn't completely +// sealed off (particularly where multiple halls intersect one of the ends) +// and, while this hasn't come up in testing yet, it's possible for this to +// generate a pair of dead ends that don't open properly. + +const BASE: typeof DefaultHallArchitect = { + ...DefaultHallArchitect, +}; + +function withBarrier({ + roughExtent, + rough, +}: Pick, "roughExtent" | "rough">): Pick< + Architect, + "roughExtent" | "rough" +> { + const roughWithBarrier: Architect["rough"] = (args) => { + rough(args); + + // Is this hall allowed to place solid rock here? + function overlaps(pos: Point) { + const idx = args.cavern.pearlInnerDex.get(...pos); + // The position must ONLY overlap this hall. Otherwise, this might block + // another hall, which could make the level unplayable. + return idx && !idx.some((_, i) => i !== args.plan.id); + } + + const tipPlans = args.plan.intersects + .map((_, id) => args.cavern.plans[id]) + .filter((p) => p.kind === "cave") + .sort((a, b) => a.hops.length - b.hops.length); + + const op = Math.min(...tipPlans.map((tp) => tp.outerPearl.length)) - 1; + for (let i = 0; i < op; i++) { + for (const tipPlan of tipPlans) { + // Add a "crust" of solid rock on the first layer of the outer pearl. + const crust = tipPlan.outerPearl[i].filter(overlaps); + if (crust.length < args.plan.pearlRadius) { + continue; + } + crust.forEach((pos) => args.tiles.set(...pos, Tile.SOLID_ROCK)); + // For the second layer, add solid rock wherever it borders at least two + // solid rock. + tipPlan.outerPearl[i + 1] + .filter(overlaps) + .filter( + ([x, y]) => + NSEW.reduce( + (r, [ox, oy]) => + (args.tiles.get(x + ox, y + oy) ?? Tile.SOLID_ROCK) === + Tile.SOLID_ROCK + ? r + 1 + : r, + 0, + ) > 1, + ) + .forEach((pos) => args.tiles.set(...pos, Tile.SOLID_ROCK)); + return; + } + } + }; + return { roughExtent, rough: roughWithBarrier }; +} + +const LOOPBACK = [ + { + name: "Loopback Hall", + ...BASE, + ...withBarrier( + mkRough( + { of: Rough.FLOOR, grow: 2 }, + { of: Rough.AT_MOST_LOOSE_ROCK, grow: 1 }, + { of: Rough.AT_MOST_HARD_ROCK }, + { of: Rough.VOID, grow: 1 }, + ), + ), + hallBid: ({ plan }) => + !plan.fluid && + plan.pearlRadius > 1 && + plan.path.kind === "auxiliary" && + plan.path.exclusiveSnakeDistance > 5 && + 2, + }, +] as const satisfies readonly Architect[]; + +export default LOOPBACK; diff --git a/src/core/architects/lost_miners.ts b/src/core/architects/lost_miners.ts index 8a30714..9cd1a66 100644 --- a/src/core/architects/lost_miners.ts +++ b/src/core/architects/lost_miners.ts @@ -21,7 +21,7 @@ import { DiscoveredCavern } from "../transformers/03_plastic/01_discover"; import { StrataformedCavern } from "../transformers/03_plastic/02_strataform"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; import { isDeadEnd } from "./utils/intersects"; -import { Rough, RoughOyster } from "./utils/oyster"; +import { mkRough, Rough } from "./utils/rough"; import { pickPoint } from "./utils/placement"; import { escapeString, @@ -36,7 +36,7 @@ export type LostMinersMetadata = { readonly minersCount: number; }; -const g = mkVars("gFoundMiners", [ +export const gLostMiners = mkVars("gLostMiners", [ "lostMinersCount", "onFoundAll", "messageFoundAll", @@ -200,23 +200,23 @@ const BASE: PartialArchitect = { ? "Find the cave with the lost Rock Radiers" : `Find ${lostMiners} lost Rock Raiders`; return { - variables: [{ condition: `${g.done}>0`, description }], + variables: [{ condition: `${gLostMiners.done}>0`, description }], sufficient: true, }; }, scriptGlobals({ cavern }) { - const lostMiners = countLostMiners(cavern); + const { lostMiners } = countLostMiners(cavern); const message = cavern.lore.foundAllLostMiners(cavern.dice).text; return scriptFragment( - `# Lost Miners Globals`, - `int ${g.lostMinersCount}=${lostMiners}`, - `int ${g.done}=0`, - `string ${g.messageFoundAll}="${escapeString(message)}"`, + `# Globals: Lost Miners`, + `int ${gLostMiners.lostMinersCount}=${lostMiners}`, + `int ${gLostMiners.done}=0`, + `string ${gLostMiners.messageFoundAll}="${escapeString(message)}"`, eventChain( - g.onFoundAll, - `msg:${g.messageFoundAll};`, + gLostMiners.onFoundAll, + `msg:${gLostMiners.messageFoundAll};`, `wait:3;`, - `${g.done}=1;`, + `${gLostMiners.done}=1;`, ), ); }, @@ -230,20 +230,20 @@ const BASE: PartialArchitect = { rng, plan.metadata.minersCount, ).text; - const v = mkVars(`p${plan.id}FoundMiners`, [ + const v = mkVars(`p${plan.id}LostMiners`, [ "messageDiscover", "onDiscover", "onIncomplete", ]); return scriptFragment( - `# Lost Miners ${plan.id}`, + `# P${plan.id}: Lost Miners`, `string ${v.messageDiscover}="${escapeString(message)}"`, `if(change:${lostMinersPoint})[${v.onDiscover}]`, eventChain( v.onDiscover, `pan:${lostMinersPoint};`, - `${g.lostMinersCount}-=${plan.metadata.minersCount};`, - `((${g.lostMinersCount}>0))[${v.onIncomplete}][${g.onFoundAll}];`, + `${gLostMiners.lostMinersCount}-=${plan.metadata.minersCount};`, + `((${gLostMiners.lostMinersCount}>0))[${v.onIncomplete}][${gLostMiners.onFoundAll}];`, ), eventChain(v.onIncomplete, `msg:${v.messageDiscover};`), ); @@ -258,7 +258,7 @@ const LOST_MINERS = [ { name: "Lost Miners", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 2 }, { of: Rough.ALWAYS_LOOSE_ROCK, grow: 1 }, { of: Rough.HARD_ROCK, grow: 0.5 }, diff --git a/src/core/architects/nomads.ts b/src/core/architects/nomads.ts index 3e6353f..94a5d7f 100644 --- a/src/core/architects/nomads.ts +++ b/src/core/architects/nomads.ts @@ -1,6 +1,6 @@ import { Architect } from "../models/architect"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster } from "./utils/oyster"; +import { mkRough, Rough } from "./utils/rough"; import { intersectsAny, intersectsOnly, isDeadEnd } from "./utils/intersects"; import { getPlaceRechargeSeams, sprinkleOre } from "./utils/resources"; import { position, randomlyInTile } from "../models/position"; @@ -26,7 +26,7 @@ import { import { Loadout, Miner } from "../models/miner"; import { filterTruthy, pairEach } from "../common/utils"; import { plotLine } from "../common/geometry"; -import { gFoundHq } from "./established_hq"; +import { gLostHq } from "./established_hq"; export type NomadsMetadata = { readonly tag: "nomads"; @@ -157,7 +157,7 @@ const BASE: PartialArchitect = { if (cavern.plans.some((plan) => plan.metadata?.tag === "hq")) { // Has HQ: Disable everything until it's found. return scriptFragment( - "Nomads Globals (With HQ)", + "# Globals: Nomads with Lost HQ", `if(time:0)[${gNomads.onInit}]`, eventChain( gNomads.onInit, @@ -165,7 +165,7 @@ const BASE: PartialArchitect = { "disable:buildings;", "disable:vehicles;", ), - `if(${gFoundHq.foundHq}>0)[${gNomads.onFoundHq}]`, + `if(${gLostHq.foundHq}>0)[${gNomads.onFoundHq}]`, eventChain( gNomads.onFoundHq, "enable:miners;", @@ -179,7 +179,7 @@ const BASE: PartialArchitect = { const msg = escapeString(cavern.lore.nomadsSettled(cavern.dice).text); return scriptFragment( - "# Nomads Globals (No HQ)", + "# Globals: Nomads, no HQ", `string ${gNomads.messageBuiltBase}="${msg}"`, `if(${SUPPORT_STATION.id}.new)[${gNomads.onBuiltBase}]`, eventChain(gNomads.onBuiltBase, `msg:${gNomads.messageBuiltBase};`), @@ -191,10 +191,10 @@ const NOMAD_SPAWN = [ { name: "Nomad Spawn", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 2 }, { of: Rough.AT_MOST_LOOSE_ROCK, grow: 1 }, - { of: Rough.AT_MOST_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, ), crystalsToPlace: ({ plan }) => Math.max(plan.crystalRichness * plan.perimeter, 5), @@ -211,12 +211,12 @@ const NOMAD_SPAWN = [ { name: "Nomad Spawn Peninsula", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, grow: 2 }, { of: Rough.BRIDGE_ON_WATER, width: 2, grow: 0.5 }, { of: Rough.FLOOR }, { of: Rough.AT_MOST_LOOSE_ROCK, grow: 1 }, - { of: Rough.AT_MOST_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, ), prime: () => ({ tag: "nomads", minersCount: 1, vehicles: [RAPID_RIDER] }), spawnBid: ({ cavern, plan }) => @@ -228,7 +228,7 @@ const NOMAD_SPAWN = [ { name: "Nomad Spawn Lava Peninsula", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, grow: 2 }, { of: Rough.BRIDGE_ON_LAVA, width: 2, grow: 0.5 }, { of: Rough.FLOOR }, diff --git a/src/core/architects/simple_cave.ts b/src/core/architects/simple_cave.ts index 4a645f1..1903145 100644 --- a/src/core/architects/simple_cave.ts +++ b/src/core/architects/simple_cave.ts @@ -1,6 +1,6 @@ import { Architect } from "../models/architect"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster, weightedSprinkle } from "./utils/oyster"; +import { mkRough, Rough, weightedSprinkle } from "./utils/rough"; import { intersectsOnly } from "./utils/intersects"; import { monsterSpawnScript } from "./utils/creature_spawners"; @@ -13,7 +13,7 @@ const SIMPLE_CAVE = [ { name: "Filled Cave", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.DIRT, width: 0, grow: 0.25 }, { of: weightedSprinkle( @@ -30,7 +30,7 @@ const SIMPLE_CAVE = [ { name: "Open Cave", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR, grow: 2 }, { of: Rough.AT_MOST_DIRT, width: 0, grow: 0.5 }, { @@ -40,7 +40,7 @@ const SIMPLE_CAVE = [ ), grow: 1, }, - { of: Rough.AT_MOST_HARD_ROCK, grow: 0.25 }, + { of: Rough.MIX_FRINGE }, { of: Rough.VOID, width: 0, grow: 0.5 }, ), caveBid: ({ plans, plan }) => @@ -52,7 +52,7 @@ const SIMPLE_CAVE = [ { name: "Empty Cave", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR, grow: 2 }, { of: Rough.DIRT, width: 0, grow: 0.1 }, { @@ -62,7 +62,7 @@ const SIMPLE_CAVE = [ ), grow: 1, }, - { of: Rough.LOOSE_OR_HARD_ROCK, grow: 0.5 }, + { of: Rough.MIX_LOOSE_HARD_ROCK, grow: 0.5 }, { of: Rough.VOID, width: 0, grow: 0.5 }, ), caveBid: ({ plan }) => !plan.fluid && plan.pearlRadius < 10 && 1, @@ -70,7 +70,7 @@ const SIMPLE_CAVE = [ { name: "Filled Cave with Paths", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR, width: 0, grow: 0.5 }, { of: Rough.INVERT_TO_LOOSE_ROCK, grow: 0.5 }, { of: Rough.INVERT_TO_DIRT, grow: 1 }, @@ -88,7 +88,7 @@ const SIMPLE_CAVE = [ { name: "Doughnut Cave", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, grow: 0.2 }, { of: Rough.ALWAYS_HARD_ROCK, grow: 0.3 }, { of: Rough.LOOSE_ROCK, width: 0, grow: 0.5 }, @@ -102,7 +102,7 @@ const SIMPLE_CAVE = [ { name: "Stalagmite Cave", ...BASE, - ...new RoughOyster( + ...mkRough( { of: weightedSprinkle( { item: Rough.ALWAYS_DIRT, bid: 0.01 }, @@ -112,14 +112,8 @@ const SIMPLE_CAVE = [ width: 4, grow: 3, }, - { - of: weightedSprinkle( - { item: Rough.DIRT, bid: 0.25 }, - { item: Rough.LOOSE_ROCK, bid: 1 }, - ), - grow: 1, - }, - { of: Rough.LOOSE_OR_HARD_ROCK, grow: 0.25 }, + { of: Rough.MIX_DIRT_LOOSE_ROCK, grow: 1 }, + { of: Rough.MIX_LOOSE_HARD_ROCK, grow: 0.25 }, ), caveBid: ({ plan }) => !plan.fluid && plan.pearlRadius > 5 && 0.2, }, diff --git a/src/core/architects/simple_hall.ts b/src/core/architects/simple_hall.ts index f69b81a..7a27f74 100644 --- a/src/core/architects/simple_hall.ts +++ b/src/core/architects/simple_hall.ts @@ -1,7 +1,7 @@ import { Architect } from "../models/architect"; import { Tile } from "../models/tiles"; import { DefaultHallArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster, weightedSprinkle } from "./utils/oyster"; +import { mkRough, Rough, weightedSprinkle } from "./utils/rough"; import { intersectsOnly } from "./utils/intersects"; import { sprinkleCrystals } from "./utils/resources"; import { placeSleepingMonsters } from "./utils/creatures"; @@ -14,7 +14,7 @@ const SIMPLE_HALL = [ { name: "Open Hall", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR, grow: 2 }, { of: Rough.AT_MOST_LOOSE_ROCK, grow: 1 }, { of: Rough.AT_MOST_HARD_ROCK }, @@ -25,7 +25,7 @@ const SIMPLE_HALL = [ { name: "Wide Hall With Monsters", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR, grow: 1 }, { of: Rough.AT_MOST_HARD_ROCK }, { of: Rough.VOID }, @@ -45,7 +45,7 @@ const SIMPLE_HALL = [ { name: "Filled Hall", ...BASE, - ...new RoughOyster( + ...mkRough( { of: weightedSprinkle( { item: Rough.FLOOR, bid: 1 }, @@ -53,7 +53,7 @@ const SIMPLE_HALL = [ { item: Rough.LOOSE_ROCK, bid: 0.1 }, ), }, - { of: Rough.LOOSE_OR_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, { of: Rough.VOID, grow: 1 }, ), hallBid: ({ plan }) => !plan.fluid && plan.pearlRadius > 0 && 1, @@ -62,7 +62,7 @@ const SIMPLE_HALL = [ name: "River", ...BASE, crystalsToPlace: ({ plan }) => 3 * plan.crystalRichness * plan.perimeter, - ...new RoughOyster( + ...mkRough( { of: Rough.WATER, width: 2, grow: 1 }, { of: weightedSprinkle( @@ -82,7 +82,7 @@ const SIMPLE_HALL = [ { name: "Stream", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.WATER, grow: 0.5 }, { of: Rough.FLOOR, grow: 0.25, shrink: 1 }, { of: Rough.DIRT_OR_LOOSE_ROCK, grow: 1 }, @@ -94,7 +94,7 @@ const SIMPLE_HALL = [ { name: "Lava River", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.LAVA, width: 2, grow: 1 }, { of: Rough.AT_MOST_HARD_ROCK, width: 0, grow: 1 }, { of: Rough.VOID, grow: 1 }, @@ -104,7 +104,7 @@ const SIMPLE_HALL = [ { name: "Wide Lava River with Monsters", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.LAVA, width: 2, grow: 1 }, { of: Rough.AT_MOST_HARD_ROCK }, { of: Rough.VOID }, diff --git a/src/core/architects/simple_spawn.ts b/src/core/architects/simple_spawn.ts index 0462793..bea3e41 100644 --- a/src/core/architects/simple_spawn.ts +++ b/src/core/architects/simple_spawn.ts @@ -2,7 +2,7 @@ import { Architect } from "../models/architect"; import { TOOL_STORE } from "../models/building"; import { Tile } from "../models/tiles"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster } from "./utils/oyster"; +import { mkRough, Rough } from "./utils/rough"; import { getBuildings } from "./utils/buildings"; import { intersectsOnly } from "./utils/intersects"; import { getPlaceRechargeSeams } from "./utils/resources"; @@ -53,10 +53,10 @@ const BASE: PartialArchitect = { maxSlope: 15, }; -const OPEN = new RoughOyster( +const OPEN = mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 2 }, { of: Rough.AT_MOST_LOOSE_ROCK, grow: 1 }, - { of: Rough.AT_MOST_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, ); const SIMPLE_SPAWN = [ @@ -73,10 +73,10 @@ const SIMPLE_SPAWN = [ { name: "Spawn", ...BASE, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 2 }, { of: Rough.LOOSE_ROCK, grow: 1 }, - { of: Rough.AT_MOST_HARD_ROCK }, + { of: Rough.MIX_FRINGE }, ), spawnBid: ({ cavern, plan }) => !plan.fluid && diff --git a/src/core/architects/slugs.ts b/src/core/architects/slugs.ts index b369d47..365889c 100644 --- a/src/core/architects/slugs.ts +++ b/src/core/architects/slugs.ts @@ -8,8 +8,16 @@ import { import { slugSpawnScript } from "./utils/creature_spawners"; import { sprinkleSlugHoles } from "./utils/creatures"; import { intersectsOnly } from "./utils/intersects"; -import { Rough, RoughOyster, weightedSprinkle } from "./utils/oyster"; +import { mkRough, Rough, weightedSprinkle } from "./utils/rough"; import { getTotalCrystals, sprinkleCrystals } from "./utils/resources"; +import { getDiscoveryPoint } from "./utils/discovery"; +import { + escapeString, + eventChain, + mkVars, + scriptFragment, + transformPoint, +} from "./utils/script"; const getSlugHoles = (args: Parameters["slugSpawnScript"]>[0]) => args.plan.innerPearl.flatMap((layer) => @@ -44,6 +52,26 @@ const SLUG_NEST: PartialArchitect = { waveSize: holeCount, }); }, + script: ({ cavern, plan }) => { + const discoPoint = getDiscoveryPoint(cavern, plan); + if (!discoPoint) { + return undefined; + } + + const v = mkVars(`p${plan.id}SgNest`, ["messageDiscover", "onDiscover"]); + const message = cavern.lore.generateFoundSlugNest(cavern.dice).text; + + return scriptFragment( + `# P${plan.id}: Slug Nest`, + `string ${v.messageDiscover}="${escapeString(message)}"`, + `if(change:${transformPoint(cavern, discoPoint)})[${v.onDiscover}]`, + eventChain( + v.onDiscover, + `msg:${v.messageDiscover};`, + `pan:${transformPoint(cavern, discoPoint)};`, + ), + ); + }, }; const SLUG_HALL: PartialArchitect = { @@ -89,7 +117,7 @@ const SLUGS = [ { name: "Slug Nest", ...SLUG_NEST, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR, width: 3, grow: 1 }, { of: Rough.AT_MOST_DIRT, width: 0, grow: 0.5 }, { @@ -99,7 +127,7 @@ const SLUGS = [ ), grow: 1, }, - { of: Rough.LOOSE_OR_HARD_ROCK, grow: 0.25 }, + { of: Rough.MIX_LOOSE_HARD_ROCK, grow: 0.25 }, ), caveBid: ({ cavern, plans, plan }) => cavern.context.hasSlugs && @@ -114,7 +142,7 @@ const SLUGS = [ { name: "Slug Hall", ...SLUG_HALL, - ...new RoughOyster( + ...mkRough( { of: Rough.FLOOR }, { of: weightedSprinkle( diff --git a/src/core/architects/thin_hall.ts b/src/core/architects/thin_hall.ts index 05d4ce4..267373b 100644 --- a/src/core/architects/thin_hall.ts +++ b/src/core/architects/thin_hall.ts @@ -5,7 +5,7 @@ import { SUPPORT_STATION, } from "../models/building"; import { DefaultHallArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster, weightedSprinkle } from "./utils/oyster"; +import { mkRough, Rough, weightedSprinkle } from "./utils/rough"; const BASE: PartialArchitect = { ...DefaultHallArchitect, @@ -21,15 +21,24 @@ const HARD_ROCK_MIN_CRYSTALS = const THIN_HALL = [ { - name: "Thin, Open Hall", + name: "Thin Open Hall", ...BASE, - ...new RoughOyster({ of: Rough.FLOOR }, { of: Rough.VOID, grow: 1 }), + ...mkRough( + { of: Rough.FLOOR }, + { + of: weightedSprinkle( + { item: Rough.AT_MOST_HARD_ROCK, bid: 1 }, + { item: Rough.VOID, bid: 10 }, + ), + }, + { of: Rough.VOID, grow: 1 }, + ), hallBid: ({ plan }) => !plan.fluid && 0.2, }, { name: "Thin Filled Hall", ...BASE, - ...new RoughOyster( + ...mkRough( { of: weightedSprinkle( { item: Rough.FLOOR, bid: 1 }, @@ -43,7 +52,7 @@ const THIN_HALL = [ { name: "Thin Hard Rock Hall", ...BASE, - ...new RoughOyster({ of: Rough.HARD_ROCK }, { of: Rough.VOID, grow: 1 }), + ...mkRough({ of: Rough.HARD_ROCK }, { of: Rough.VOID, grow: 1 }), hallBid: ({ plan, totalCrystals }) => !plan.fluid && totalCrystals >= HARD_ROCK_MIN_CRYSTALS && diff --git a/src/core/architects/treasure.ts b/src/core/architects/treasure.ts index a616f1a..60ea928 100644 --- a/src/core/architects/treasure.ts +++ b/src/core/architects/treasure.ts @@ -1,7 +1,7 @@ import { Architect, BaseMetadata } from "../models/architect"; import { Tile } from "../models/tiles"; import { DefaultCaveArchitect, PartialArchitect } from "./default"; -import { Rough, RoughOyster } from "./utils/oyster"; +import { mkRough, Rough } from "./utils/rough"; import { intersectsOnly, isDeadEnd } from "./utils/intersects"; import { eventChain, @@ -12,6 +12,7 @@ import { import { monsterSpawnScript } from "./utils/creature_spawners"; import { bidsForOrdinaryWalls, sprinkleCrystals } from "./utils/resources"; import { placeSleepingMonsters } from "./utils/creatures"; +import { gLostMiners } from "./lost_miners"; const METADATA = { tag: "treasure", @@ -66,6 +67,7 @@ const HOARD: typeof BASE = { monsterSpawnScript: (args) => monsterSpawnScript(args, { meanWaveSize: args.plan.monsterWaveSize * 1.5, + retriggerMode: "hoard", rng: args.cavern.dice.monsterSpawnScript(args.plan.id), spawnRate: args.plan.monsterSpawnRate * 3.5, }), @@ -73,34 +75,42 @@ const HOARD: typeof BASE = { if (!cavern.objectives.crystals) { return undefined; } - return `# Hoard Globals -bool ${g.wasTriggered}=false -string ${g.message}="${cavern.lore.foundHoard(cavern.dice).text}" -int ${g.crystalsAvailable}=0 -`; + return scriptFragment( + "# Globals: Hoard", + `bool ${g.wasTriggered}=false`, + `string ${g.message}="${cavern.lore.foundHoard(cavern.dice).text}"`, + `int ${g.crystalsAvailable}=0`, + ); }, script({ cavern, plan }) { if (!cavern.objectives.crystals) { return undefined; } + const hasLostMiners = cavern.plans.some( + (p) => p.metadata?.tag === "lostMiners", + ); + // Generate a script that pans to this cave on discovery if collecting all // of the crystals would win the level. - // TODO(charredutensil): Need to figure out clashes with lost miners const centerPoint = transformPoint(cavern, plan.innerPearl[0][0]); const v = mkVars(`p${plan.id}Hoard`, ["onDiscovered", "go"]); return scriptFragment( - `# Hoard ${plan.id}`, + `# P${plan.id}: Hoard`, `if(change:${centerPoint})[${v.onDiscovered}]`, eventChain( v.onDiscovered, `((${g.wasTriggered}))return;`, `${g.wasTriggered}=true;`, `wait:1;`, + `${g.wasTriggered}=false;`, + // If there's a lost miners objective that isn't fulfilled, don't + // act like the level is done. + hasLostMiners && `((${gLostMiners.done}<1))return;`, // Count all the crystals in storage and on the floor. `${g.crystalsAvailable}=crystals+Crystal_C;`, // If this is enough to win the level, alert the player. - `((${g.crystalsAvailable}>=${cavern.objectives.crystals}))[${v.go}][${g.wasTriggered}=false];`, + `((${g.crystalsAvailable}>=${cavern.objectives.crystals}))${v.go};`, ), eventChain(v.go, `msg:${g.message};`, `pan:${centerPoint};`), ); @@ -112,7 +122,6 @@ const RICH: typeof BASE = { monsterSpawnScript: (args) => monsterSpawnScript(args, { meanWaveSize: args.plan.monsterWaveSize * 1.5, - retriggerMode: "hoard", spawnRate: args.plan.monsterSpawnRate * 2, }), }; @@ -121,7 +130,7 @@ const TREASURE = [ { name: "Open Hoard", ...HOARD, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 3 }, { of: Rough.LOOSE_ROCK, shrink: 1 }, { of: Rough.HARD_ROCK, grow: 0.5 }, @@ -136,7 +145,7 @@ const TREASURE = [ { name: "Sealed Hoard", ...HOARD, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 1, grow: 3 }, { of: Rough.ALWAYS_LOOSE_ROCK }, { of: Rough.ALWAYS_HARD_ROCK, grow: 0.5 }, @@ -150,7 +159,7 @@ const TREASURE = [ { name: "Open Rich Cave", ...RICH, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 1 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.5 }, { of: Rough.LOOSE_ROCK, grow: 2 }, @@ -164,7 +173,7 @@ const TREASURE = [ { name: "Rich Island", ...RICH, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 1 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.5 }, { of: Rough.ALWAYS_LOOSE_ROCK, grow: 2 }, @@ -189,7 +198,7 @@ const TREASURE = [ { name: "Peninsula Hoard", ...HOARD, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 1 }, { of: Rough.BRIDGE_ON_WATER, width: 2, grow: 3 }, { of: Rough.LOOSE_ROCK, shrink: 1 }, @@ -206,7 +215,7 @@ const TREASURE = [ { name: "Rich Lava Island", ...RICH, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_SOLID_ROCK, width: 0, grow: 1 }, { of: Rough.ALWAYS_HARD_ROCK, width: 0, grow: 0.5 }, { of: Rough.ALWAYS_LOOSE_ROCK, grow: 2 }, @@ -223,7 +232,7 @@ const TREASURE = [ { name: "Lava Peninsula Hoard", ...HOARD, - ...new RoughOyster( + ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2, grow: 1 }, { of: Rough.BRIDGE_ON_LAVA, width: 2, grow: 3 }, { of: Rough.HARD_ROCK, grow: 0.5 }, diff --git a/src/core/architects/utils/creature_spawners.ts b/src/core/architects/utils/creature_spawners.ts index 2f5f8cb..60e341c 100644 --- a/src/core/architects/utils/creature_spawners.ts +++ b/src/core/architects/utils/creature_spawners.ts @@ -6,31 +6,36 @@ import { monsterForBiome, } from "../../models/creature"; import { Plan } from "../../models/plan"; -import { FencedCavern } from "../../transformers/03_plastic/00_fence"; import { EnscribedCavern } from "../../transformers/04_ephemera/02_enscribe"; import { getDiscoveryPoint } from "./discovery"; import { eventChain, mkVars, scriptFragment, transformPoint } from "./script"; type CreatureSpawnerArgs = { - creature: CreatureTemplate; - emerges?: readonly Emerge[]; - initialCooldown?: { min: number; max: number }; - maxTriggerCount?: number; - meanWaveSize?: number; - needCrystals?: { base: number; increment?: number }; - retriggerMode: RetriggerMode; - rng: PseudorandomStream; - spawnRate?: number; - triggerOnFirstArmed: boolean; - triggerPoints?: readonly Point[]; - waveSize?: number; + readonly armEvent?: string; + readonly creature: CreatureTemplate; + readonly emerges?: readonly Emerge[]; + readonly initialCooldown?: { min: number; max: number }; + readonly maxTriggerCount?: number; + readonly meanWaveSize?: number; + readonly needCrystals?: { base: number; increment?: number }; + readonly retriggerMode: RetriggerMode; + readonly rng: PseudorandomStream; + readonly spawnRate?: number; + readonly triggerOnFirstArmed: boolean; + readonly triggerPoints?: readonly Point[]; + readonly waveSize?: number; }; const STATE = { + /** This spawner is deactivated and can't be reactivated. */ EXHAUSTED: 0, - UNDISCOVERED: 1, + /** This spawner has not been activated yet. */ + INITIAL: 1, + /** This spawner is waiting to be reactivated by some trigger. */ AWAITING_REARM: 2, + /** This spawner just activated and is waiting for time to pass. */ COOLDOWN: 3, + /** This spawner is ready to activate. */ ARMED: 4, } as const; @@ -67,7 +72,22 @@ function cycleEmerges( return result; } -function getTriggerPoints(cavern: FencedCavern, plan: Plan): Point[] { +function getArmTriggers( + cavern: EnscribedCavern, + plan: Plan, + armFn: string, +) { + const discoveryPoint = getDiscoveryPoint(cavern, plan); + if (discoveryPoint) { + // There is a non-wall tile that starts undiscovered. + // Enable when it is discovered. + return [`if(change:${transformPoint(cavern, discoveryPoint)})[${armFn}]`]; + } + // Just enable on init. + return [`if(time:0)[${armFn}]`]; +} + +function getTriggerPoints(cavern: EnscribedCavern, plan: Plan): Point[] { // Pick any tile that was set with a value, even if it is solid rock. return plan.outerPearl[0].filter((point) => cavern.tiles.get(...point)); } @@ -103,6 +123,15 @@ function creatureSpawnScript( { cavern, plan }: { cavern: EnscribedCavern; plan: Plan }, opts: CreatureSpawnerArgs, ) { + const v = mkVars(`p${plan.id}${opts.creature.inspectAbbrev}Sp`, [ + "doArm", + "doRetrigger", + "doSpawn", + "needCrystals", + "state", + "triggerCount", + ]); + const waveSize = opts.waveSize ?? opts.rng.betaInt({ @@ -112,64 +141,56 @@ function creatureSpawnScript( max: (opts.meanWaveSize ?? plan.monsterWaveSize) * 1.25, }); const delay = { min: 2 / waveSize, max: 15 / waveSize }; - const spawnRate = opts.spawnRate ?? plan.monsterSpawnRate; const meanCooldown = (60 * waveSize) / spawnRate; + + const armEvent = opts.armEvent ?? v.doArm; + const armTriggers = opts.armEvent + ? [] + : getArmTriggers(cavern, plan, armEvent); const cooldownOffset = meanCooldown / 4; const cooldown = { min: meanCooldown - cooldownOffset, max: meanCooldown + cooldownOffset, }; - - const discoveryPoint = getDiscoveryPoint(cavern, plan); const emerges = cycleEmerges( opts.emerges ?? getEmerges(plan), opts.rng, waveSize, ); + const once = opts.maxTriggerCount === 1; const triggerPoints = opts.triggerPoints ?? getTriggerPoints(cavern, plan); - const v = mkVars(`p${plan.id}${opts.creature.inspectAbbrev}Spawner`, [ - "needCrystals", - "state", - "triggerCount", - "onDiscovered", - "doSpawn", - "doRetrigger", - ]); + const needCountTriggerEvents = !once && opts.maxTriggerCount !== undefined; + const needTriggerPoints = !(once && opts.triggerOnFirstArmed); + return scriptFragment( - `# Spawn ${opts.creature.id} x${waveSize} ${plan.id}`, + `# P${plan.id}: Spawn ${opts.creature.name} x${waveSize}`, // Declare variables - `int ${v.state}=${STATE.UNDISCOVERED}`, - opts.maxTriggerCount !== undefined && `int ${v.triggerCount}=0`, + `int ${v.state}=${STATE.INITIAL}`, + needCountTriggerEvents && `int ${v.triggerCount}=0`, opts.needCrystals?.increment !== undefined && `int ${v.needCrystals}=${opts.needCrystals.base}`, - // Discovery - discoveryPoint - ? /* - * If there is a non-wall tile that starts undiscovered, generate an onDiscovered - * event chain that triggers when that tile changes (i.e. it becomes - * discovered). - */ - `if(change:${transformPoint(cavern, discoveryPoint)})[${v.onDiscovered}]` - : // Otherwise, just enable on init. - `if(time:0)[${v.onDiscovered}]`, + // Enable + ...armTriggers, eventChain( - v.onDiscovered, + armEvent, opts.initialCooldown && `wait:random(${opts.initialCooldown.min.toFixed(2)})(${opts.initialCooldown.max.toFixed(2)});`, + armTriggers.length !== 1 && `((${v.state}>${STATE.INITIAL}))return;`, `${v.state}=${STATE.ARMED};`, opts.triggerOnFirstArmed && `${v.doSpawn};`, ), - // Trigger points - ...triggerPoints.map( - (point) => `when(enter:${transformPoint(cavern, point)})[${v.doSpawn}]`, - ), - - // Do the actual spawning + // Do the actual spawning. + ...(needTriggerPoints + ? triggerPoints.map( + (point) => + `when(enter:${transformPoint(cavern, point)})[${v.doSpawn}]`, + ) + : []), eventChain( v.doSpawn, @@ -180,7 +201,7 @@ function creatureSpawnScript( // Update variables before triggering. `${v.state}=${RETRIGGER_MODES[opts.retriggerMode].afterTriggerState};`, - opts.maxTriggerCount !== undefined && `${v.triggerCount}+=1;`, + needCountTriggerEvents && `${v.triggerCount}+=1;`, opts.needCrystals?.increment !== undefined && `${v.needCrystals}=crystals+${opts.needCrystals.increment};`, @@ -191,25 +212,31 @@ function creatureSpawnScript( ]) as `${string};`[]), // Update the counter. - opts.maxTriggerCount !== undefined && - `((${v.triggerCount}>=${opts.maxTriggerCount}))${v.state}=${STATE.EXHAUSTED};`, + once + ? `${v.state}=${STATE.EXHAUSTED};` + : opts.maxTriggerCount !== undefined && + `((${v.triggerCount}>=${opts.maxTriggerCount}))${v.state}=${STATE.EXHAUSTED};`, // Wait for the cooldown period. - `wait:random(${cooldown.min.toFixed(2)})(${cooldown.max.toFixed(2)});`, + !once && + `wait:random(${cooldown.min.toFixed(2)})(${cooldown.max.toFixed(2)});`, // Re-arm if in cooldown. - `((${v.state}>=${STATE.COOLDOWN}))[${v.state}=${STATE.ARMED}][${v.state}=${STATE.EXHAUSTED}];`, + !once && + `((${v.state}>=${STATE.COOLDOWN}))[${v.state}=${STATE.ARMED}][${v.state}=${STATE.EXHAUSTED}];`, ), // Hoard mode must be "manually" re-armed by a monster visiting the hoard // within cooldown. - ...(opts.retriggerMode === "hoard" - ? plan.innerPearl[0].map( - (point) => - `when(enter:${transformPoint(cavern, point)},${opts.creature.id})[${v.doRetrigger}]`, + ...(!once && opts.retriggerMode === "hoard" + ? [ + ...plan.innerPearl[0].map( + (point) => + `when(enter:${transformPoint(cavern, point)},${opts.creature.id})[${v.doRetrigger}]`, + ), eventChain( v.doRetrigger, `((${v.state}==${STATE.AWAITING_REARM}))${v.state}=${STATE.COOLDOWN};`, ), - ) + ] : []), ); } diff --git a/src/core/architects/utils/oyster.ts b/src/core/architects/utils/oyster.ts index f2d1aef..dae1e4e 100644 --- a/src/core/architects/utils/oyster.ts +++ b/src/core/architects/utils/oyster.ts @@ -1,252 +1,61 @@ -import { PseudorandomStream } from "../../common"; -import { Architect } from "../../models/architect"; -import { RoughTile, Tile } from "../../models/tiles"; - -function rr({ - floor, - dirt, - looseRock, - hardRock, - solidRock, - water, - lava, -}: { - floor?: RoughTile; - dirt?: RoughTile; - looseRock?: RoughTile; - hardRock?: RoughTile; - solidRock?: RoughTile; - water?: RoughTile; - lava?: RoughTile; -}): ReplaceFn { - const r: RoughTile[] = []; - if (floor) { - r[Tile.FLOOR.id] = floor; - } - if (dirt) { - r[Tile.DIRT.id] = dirt; - } - if (looseRock) { - r[Tile.LOOSE_ROCK.id] = looseRock; - } - if (hardRock) { - r[Tile.HARD_ROCK.id] = hardRock; - } - if (solidRock) { - r[Tile.SOLID_ROCK.id] = solidRock; - } - if (water) { - r[Tile.WATER.id] = water; - } - if (lava) { - r[Tile.LAVA.id] = lava; - } - return (has: Tile) => r[has.id] ?? null; -} - -function roughNotFloodedTo(to: RoughTile) { - return rr({ - floor: to, - dirt: to, - looseRock: to, - hardRock: to, - solidRock: to, - }); -} - -type ReplaceFn = (has: T, rng: PseudorandomStream) => T | null; - -export const Rough = { - // VOID: No effect whatsoever - VOID: () => null, - // ALWAYS_*: Ignores existing tile - ALWAYS_FLOOR: () => Tile.FLOOR, - ALWAYS_DIRT: () => Tile.DIRT, - ALWAYS_LOOSE_ROCK: () => Tile.LOOSE_ROCK, - ALWAYS_HARD_ROCK: () => Tile.HARD_ROCK, - ALWAYS_SOLID_ROCK: () => Tile.SOLID_ROCK, - ALWAYS_WATER: () => Tile.WATER, - ALWAYS_LAVA: () => Tile.LAVA, - // AT_MOST_*: Replaces only if the existing tile is harder rock - AT_MOST_DIRT: rr({ - looseRock: Tile.DIRT, - hardRock: Tile.DIRT, - solidRock: Tile.DIRT, - }), - AT_MOST_LOOSE_ROCK: rr({ - hardRock: Tile.LOOSE_ROCK, - solidRock: Tile.LOOSE_ROCK, - }), - AT_MOST_HARD_ROCK: rr({ solidRock: Tile.HARD_ROCK }), - // No prefix: Replaces any non-flooded tile with the given tile - FLOOR: roughNotFloodedTo(Tile.FLOOR), - DIRT: roughNotFloodedTo(Tile.DIRT), - LOOSE_ROCK: roughNotFloodedTo(Tile.LOOSE_ROCK), - HARD_ROCK: roughNotFloodedTo(Tile.HARD_ROCK), - SOLID_ROCK: roughNotFloodedTo(Tile.SOLID_ROCK), - WATER: roughNotFloodedTo(Tile.WATER), - LAVA: roughNotFloodedTo(Tile.LAVA), - // Replaces floor -> dirt / loose rock <- hard rock, solid rock - DIRT_OR_LOOSE_ROCK: rr({ - floor: Tile.DIRT, - hardRock: Tile.LOOSE_ROCK, - solidRock: Tile.LOOSE_ROCK, - }), - // Replaces floor, dirt -> loose rock / hard rock <- solid rock - LOOSE_OR_HARD_ROCK: rr({ - floor: Tile.LOOSE_ROCK, - dirt: Tile.LOOSE_ROCK, - solidRock: Tile.HARD_ROCK, - }), - // Bridges - Replaces placed rock with floor and floods solid rock - // This can be used by caves to create a path to an island. - // Avoid using these if the cave intersects halls with fluid as the results - // will look extremely strange. - BRIDGE_ON_WATER: rr({ - dirt: Tile.FLOOR, - looseRock: Tile.FLOOR, - hardRock: Tile.FLOOR, - solidRock: Tile.WATER, - }), - BRIDGE_ON_LAVA: rr({ - dirt: Tile.FLOOR, - looseRock: Tile.FLOOR, - hardRock: Tile.FLOOR, - solidRock: Tile.LAVA, - }), - // Solid becomes dirt, other rock becomes floor. - INVERT_TO_DIRT: rr({ - dirt: Tile.FLOOR, - looseRock: Tile.FLOOR, - hardRock: Tile.FLOOR, - solidRock: Tile.DIRT, - }), - // Solid becomes loose rock, other rock becomes floor. - INVERT_TO_LOOSE_ROCK: rr({ - dirt: Tile.FLOOR, - looseRock: Tile.FLOOR, - hardRock: Tile.FLOOR, - solidRock: Tile.LOOSE_ROCK, - }), -} as const; - -export const uniformSprinkle = - (...args: ReplaceFn[]) => - (has: T, rng: PseudorandomStream) => - rng.uniformChoice(args)(has, rng); - -export const weightedSprinkle = - (...args: { bid: number; item: ReplaceFn }[]) => - (has: T, rng: PseudorandomStream) => - rng.weightedChoice(args)(has, rng); - -type Layer = { +type _Layer = { of: T; width: number; shrink: number; grow: number; }; -function* expand( - layers: Layer[], - shrinkFactor: number, - growFactor: number, -) { +export type Layer = { + of: T; + width?: number; + shrink?: number; + grow?: number; +}; + +export function fixLayers(layers: Layer[]): _Layer[] { + return layers.map((ly) => ({ width: 1, shrink: 0, grow: 0, ...ly })); +} + +export function expand(layers: readonly _Layer[], radius: number): T[] { + radius = radius + 1; + const totalWidth = layers.reduce((t, { width }) => t + width, 0); + const totalShrink = layers.reduce((t, { shrink }) => t + shrink, 0); + const totalGrow = layers.reduce((t, { grow }) => t + grow, 0); + let growFactor = 0; + let shrinkFactor = 0; + if (radius < totalWidth && totalShrink > 0) { + // For the shrink case, + // r = (w0 * (1 - s0 * sf)) + (w1 * (1 - s1 * sf)) + ... + // + (wn * (1 - sn * sf)) + + // Solve for sf + // r = w0 - w0 * s0 * sf + w1 - w1 * s1 * sf + ... + wn - wn * sn * sf + // r = (w0 + w1 + ... + wn) - (w0 * s0 + w1 * s1 + ... + wn * sn) * sf + // (w0 * s0 + w1 * s1 + ... + wn * sn) * sf = (w0 + w1 + ... + wn) - r + // sf = ((w0 + w1 + ... + wn) - r) / (w0 * s0 + w1 * s1 + ... + wn * sn) + + shrinkFactor = + (totalWidth - radius) / + layers.reduce((t, { width, shrink }) => t + width * shrink, 0); + } else if (radius > totalWidth && totalGrow > 0) { + // For the growth case, + // r = (w0 + g0 * gf) + (w1 + g1 * gf) + ... + (wn + gn * gf) + + // Solve for gf + // r = (w0 + w1 + ... + wn) + (g0 + g1 + ... + gn) * gf + // (r - (w0 + w1 + ... + wn)) / (g0 + g1 + ... + gn) = gf + growFactor = (radius - totalWidth) / totalGrow; + } + + let result = []; let w = 0; for (const { of, width, shrink, grow } of layers) { w = w + width * Math.max(0, 1 - shrink * shrinkFactor) + grow * growFactor; while (Math.round(w) > 0) { - yield of; + result.push(of); w -= 1; } } -} - -// Oyster class -class BaseOyster { - protected readonly _layers: Layer[]; - - constructor( - ...layers: { - of: T; - width?: number; - shrink?: number; - grow?: number; - }[] - ) { - this._layers = layers.map((ly) => ({ - width: 1, - shrink: 0, - grow: 0, - ...ly, - })); - } - - protected _expand(radius: number): T[] { - radius = radius + 1; - const totalWidth = this._layers.reduce((t, { width }) => t + width, 0); - const totalShrink = this._layers.reduce((t, { shrink }) => t + shrink, 0); - const totalGrow = this._layers.reduce((t, { grow }) => t + grow, 0); - let growFactor = 0; - let shrinkFactor = 0; - if (radius < totalWidth && totalShrink > 0) { - // For the shrink case, - // r = (w0 * (1 - s0 * sf)) + (w1 * (1 - s1 * sf)) + ... - // + (wn * (1 - sn * sf)) - - // Solve for sf - // r = w0 - w0 * s0 * sf + w1 - w1 * s1 * sf + ... + wn - wn * sn * sf - // r = (w0 + w1 + ... + wn) - (w0 * s0 + w1 * s1 + ... + wn * sn) * sf - // (w0 * s0 + w1 * s1 + ... + wn * sn) * sf = (w0 + w1 + ... + wn) - r - // sf = ((w0 + w1 + ... + wn) - r) / (w0 * s0 + w1 * s1 + ... + wn * sn) - - shrinkFactor = - (totalWidth - radius) / - this._layers.reduce((t, { width, shrink }) => t + width * shrink, 0); - } else if (radius > totalWidth && totalGrow > 0) { - // For the growth case, - // r = (w0 + g0 * gf) + (w1 + g1 * gf) + ... + (wn + gn * gf) - - // Solve for gf - // r = (w0 + w1 + ... + wn) + (g0 + g1 + ... + gn) * gf - // (r - (w0 + w1 + ... + wn)) / (g0 + g1 + ... + gn) = gf - growFactor = (radius - totalWidth) / totalGrow; - } - - return Array.from(expand(this._layers, shrinkFactor, growFactor)); - } -} -export class Oyster extends BaseOyster { - expand = (radius: number) => this._expand(radius); -} - -export class RoughOyster - extends BaseOyster> - implements Pick, "roughExtent" | "rough"> -{ - roughExtent: Architect["roughExtent"] = (plan) => { - if (this._layers[this._layers.length - 1].of !== Rough.VOID) { - return plan.pearlRadius; - } - const ly = this._expand(plan.pearlRadius); - for (let i = ly.length - 1; i > 0; i--) { - if (ly[i] !== Rough.VOID) { - return i; - } - } - return 0; - }; - rough: Architect["rough"] = ({ cavern, plan, tiles }) => { - const rng = cavern.dice.rough(plan.id); - const replacements = this._expand(plan.pearlRadius); - plan.innerPearl.forEach((layer, i) => { - layer.forEach(([x, y]) => { - const r = replacements[i](tiles.get(x, y) ?? Tile.SOLID_ROCK, rng); - if (r) { - tiles.set(x, y, r); - } - }); - }); - }; + return result; } diff --git a/src/core/architects/utils/rough.ts b/src/core/architects/utils/rough.ts new file mode 100644 index 0000000..3ee3a3a --- /dev/null +++ b/src/core/architects/utils/rough.ts @@ -0,0 +1,196 @@ +import { PseudorandomStream } from "../../common"; +import { Architect } from "../../models/architect"; +import { RoughTile, Tile } from "../../models/tiles"; +import { Layer, expand, fixLayers } from "./oyster"; + +function rr({ + floor, + dirt, + looseRock, + hardRock, + solidRock, + water, + lava, +}: { + floor?: RoughTile; + dirt?: RoughTile; + looseRock?: RoughTile; + hardRock?: RoughTile; + solidRock?: RoughTile; + water?: RoughTile; + lava?: RoughTile; +}): ReplaceFn { + const r: RoughTile[] = []; + if (floor) { + r[Tile.FLOOR.id] = floor; + } + if (dirt) { + r[Tile.DIRT.id] = dirt; + } + if (looseRock) { + r[Tile.LOOSE_ROCK.id] = looseRock; + } + if (hardRock) { + r[Tile.HARD_ROCK.id] = hardRock; + } + if (solidRock) { + r[Tile.SOLID_ROCK.id] = solidRock; + } + if (water) { + r[Tile.WATER.id] = water; + } + if (lava) { + r[Tile.LAVA.id] = lava; + } + return (has: Tile) => r[has.id] ?? null; +} + +function roughNotFloodedTo(to: RoughTile) { + return rr({ + floor: to, + dirt: to, + looseRock: to, + hardRock: to, + solidRock: to, + }); +} + +export const uniformSprinkle = + (...args: ReplaceFn[]) => + (has: T, rng: PseudorandomStream) => + rng.uniformChoice(args)(has, rng); + +export const weightedSprinkle = + (...args: { bid: number; item: ReplaceFn }[]) => + (has: T, rng: PseudorandomStream) => + rng.weightedChoice(args)(has, rng); + +const _Rough = { + // VOID: No effect whatsoever + VOID: () => null, + // ALWAYS_*: Ignores existing tile + ALWAYS_FLOOR: () => Tile.FLOOR, + ALWAYS_DIRT: () => Tile.DIRT, + ALWAYS_LOOSE_ROCK: () => Tile.LOOSE_ROCK, + ALWAYS_HARD_ROCK: () => Tile.HARD_ROCK, + ALWAYS_SOLID_ROCK: () => Tile.SOLID_ROCK, + ALWAYS_WATER: () => Tile.WATER, + ALWAYS_LAVA: () => Tile.LAVA, + // AT_MOST_*: Replaces only if the existing tile is harder rock + AT_MOST_DIRT: rr({ + looseRock: Tile.DIRT, + hardRock: Tile.DIRT, + solidRock: Tile.DIRT, + }), + AT_MOST_LOOSE_ROCK: rr({ + hardRock: Tile.LOOSE_ROCK, + solidRock: Tile.LOOSE_ROCK, + }), + AT_MOST_HARD_ROCK: rr({ solidRock: Tile.HARD_ROCK }), + // No prefix: Replaces any non-flooded tile with the given tile + FLOOR: roughNotFloodedTo(Tile.FLOOR), + DIRT: roughNotFloodedTo(Tile.DIRT), + LOOSE_ROCK: roughNotFloodedTo(Tile.LOOSE_ROCK), + HARD_ROCK: roughNotFloodedTo(Tile.HARD_ROCK), + SOLID_ROCK: roughNotFloodedTo(Tile.SOLID_ROCK), + WATER: roughNotFloodedTo(Tile.WATER), + LAVA: roughNotFloodedTo(Tile.LAVA), + // Replaces floor -> dirt / loose rock <- hard rock, solid rock + DIRT_OR_LOOSE_ROCK: rr({ + floor: Tile.DIRT, + hardRock: Tile.LOOSE_ROCK, + solidRock: Tile.LOOSE_ROCK, + }), + // Replaces floor, dirt -> loose rock / hard rock <- solid rock + LOOSE_OR_HARD_ROCK: rr({ + floor: Tile.LOOSE_ROCK, + dirt: Tile.LOOSE_ROCK, + solidRock: Tile.HARD_ROCK, + }), + // Bridges - Replaces placed rock with floor and floods solid rock + // This can be used by caves to create a path to an island. + // Avoid using these if the cave intersects halls with fluid as the results + // will look extremely strange. + BRIDGE_ON_WATER: rr({ + dirt: Tile.FLOOR, + looseRock: Tile.FLOOR, + hardRock: Tile.FLOOR, + solidRock: Tile.WATER, + }), + BRIDGE_ON_LAVA: rr({ + dirt: Tile.FLOOR, + looseRock: Tile.FLOOR, + hardRock: Tile.FLOOR, + solidRock: Tile.LAVA, + }), + // Solid becomes dirt, other rock becomes floor. + INVERT_TO_DIRT: rr({ + dirt: Tile.FLOOR, + looseRock: Tile.FLOOR, + hardRock: Tile.FLOOR, + solidRock: Tile.DIRT, + }), + // Solid becomes loose rock, other rock becomes floor. + INVERT_TO_LOOSE_ROCK: rr({ + dirt: Tile.FLOOR, + looseRock: Tile.FLOOR, + hardRock: Tile.FLOOR, + solidRock: Tile.LOOSE_ROCK, + }), +}; + +export const Rough = { + ..._Rough, + MIX_DIRT_LOOSE_ROCK: weightedSprinkle( + { item: _Rough.DIRT, bid: 1 }, + { item: _Rough.LOOSE_ROCK, bid: 4 }, + ), + MIX_LOOSE_HARD_ROCK: weightedSprinkle( + { item: _Rough.LOOSE_ROCK, bid: 4 }, + { item: _Rough.LOOSE_OR_HARD_ROCK, bid: 1 }, + ), + MIX_FRINGE: weightedSprinkle( + { item: _Rough.AT_MOST_LOOSE_ROCK, bid: 10 }, + { item: _Rough.AT_MOST_HARD_ROCK, bid: 1 }, + { item: _Rough.VOID, bid: 4 }, + ), +} as const; + +export type ReplaceFn = ( + has: T, + rng: PseudorandomStream, +) => T | null; + +export function mkRough( + ...args: Layer>[] +): Pick, "roughExtent" | "rough"> { + const layers = fixLayers(args); + + const roughExtent: Architect["roughExtent"] = + layers[layers.length - 1].of === Rough.VOID + ? (plan) => { + const ly = expand(layers, plan.pearlRadius); + for (let i = ly.length - 1; i > 0; i--) { + if (ly[i] !== Rough.VOID) { + return i; + } + } + return 0; + } + : (plan) => plan.pearlRadius; + + const rough: Architect["rough"] = ({ cavern, plan, tiles }) => { + const rng = cavern.dice.rough(plan.id); + const replacements = expand(layers, plan.pearlRadius); + plan.innerPearl.forEach((layer, i) => { + layer.forEach(([x, y]) => { + const r = replacements[i](tiles.get(x, y) ?? Tile.SOLID_ROCK, rng); + if (r) { + tiles.set(x, y, r); + } + }); + }); + }; + + return { roughExtent, rough }; +} diff --git a/src/core/common/context.ts b/src/core/common/context.ts index 340e1ec..cae7468 100644 --- a/src/core/common/context.ts +++ b/src/core/common/context.ts @@ -273,11 +273,11 @@ const STANDARD_DEFAULTS = { baseplateMaxOblongness: 3, baseplateMaxRatioOfSize: 0.33, caveCount: 20, - optimalAuxiliaryPathCount: 0, - randomAuxiliaryPathCount: 4, + optimalAuxiliaryPathCount: 2, + randomAuxiliaryPathCount: 3, auxiliaryPathMinAngle: Math.PI / 4, - caveBaroqueness: 0.14, - hallBaroqueness: 0.05, + caveBaroqueness: 0.16, + hallBaroqueness: 0.07, caveCrystalRichness: { base: 0.16, hops: 0.32, order: 0.32 }, hallCrystalRichness: { base: 0.07, hops: 0, order: 0 }, caveOreRichness: { base: 1.19, hops: -0.16, order: -0.08 }, @@ -381,7 +381,7 @@ export function inferContextDefaults( targetSize: dice.init(Die.targetSize).uniformInt({ min: 50, max: 78 }), ...args, }; - const hasAirLimit = false; // dice.init(Die.hasAirLimit).chance(0.75); + const hasAirLimit = dice.init(Die.hasAirLimit).chance(0.75); const hasSlugs = dice.init(Die.hasSlugs).chance( { rock: 0.25, diff --git a/src/core/lore/graphs/completeness.test.ts b/src/core/lore/graphs/completeness.test.ts index 6023131..6892a49 100644 --- a/src/core/lore/graphs/completeness.test.ts +++ b/src/core/lore/graphs/completeness.test.ts @@ -6,9 +6,11 @@ import { FOUND_HOARD, FOUND_HQ, FOUND_LOST_MINERS, + FOUND_SLUG_NEST, } from "./events"; import ORDERS from "./orders"; import PREMISE from "./premise"; +import { SEISMIC_FORESHADOW } from "./seismic"; function expectCompletion( actual: PhraseGraph, @@ -46,7 +48,7 @@ const EXPECTED = phraseGraph(({ pg, state, start, end, cut, skip }) => { .then(skip, state("spawnHasErosion")) .then(skip, state("treasureCaveOne", "treasureCaveMany")) .then( - skip, + skip, state("spawnIsNomadOne", "spawnIsNomadsTogether"), state("spawnIsHq").then(hasHq).then(cut), ) @@ -93,3 +95,11 @@ test(`Found hoard is complete`, () => { test(`Found HQ is complete`, () => { expectCompletion(FOUND_HQ, EXPECTED); }); + +test(`Found Slug Nest is complete`, () => { + expectCompletion(FOUND_SLUG_NEST, EXPECTED); +}); + +test(`Seismic Foreshadow is complete`, () => { + expectCompletion(SEISMIC_FORESHADOW, EXPECTED); +}); diff --git a/src/core/lore/graphs/events.ts b/src/core/lore/graphs/events.ts index 540a345..1d62216 100644 --- a/src/core/lore/graphs/events.ts +++ b/src/core/lore/graphs/events.ts @@ -191,6 +191,7 @@ export const FOUND_SLUG_NEST = phraseGraph( .then( "It must be a nest of Slimy Slugs!", "We need to keep these Slimy Slugs at bay.", - ); + ) + .then(end); }, ); diff --git a/src/core/lore/graphs/seismic.ts b/src/core/lore/graphs/seismic.ts new file mode 100644 index 0000000..23241bc --- /dev/null +++ b/src/core/lore/graphs/seismic.ts @@ -0,0 +1,19 @@ +import phraseGraph from "../builder"; +import { State } from "../lore"; + +export const SEISMIC_FORESHADOW = phraseGraph( + ({ pg, state, start, end, cut, skip }) => { + start + .then("I don't like the look of this.", "This could be a problem.", skip) + .then( + "Our scanners are picking up seismic activity in the area.", + "We're detecting an increase in geological activity nearby.", + ) + .then( + "Be careful down there!", + "Keep an eye out for anything unusual.", + "Stay sharp and keep your Rock Raiders safe.", + ) + .then(end); + }, +); diff --git a/src/core/lore/lore.ts b/src/core/lore/lore.ts index a4f5be6..c8ba56f 100644 --- a/src/core/lore/lore.ts +++ b/src/core/lore/lore.ts @@ -11,10 +11,12 @@ import { FOUND_HOARD, FOUND_HQ, FOUND_LOST_MINERS, + FOUND_SLUG_NEST, NOMADS_SETTLED, } from "./graphs/events"; import ORDERS from "./graphs/orders"; import PREMISE from "./graphs/premise"; +import { SEISMIC_FORESHADOW } from "./graphs/seismic"; export type State = { readonly floodedWithWater: boolean; @@ -57,6 +59,7 @@ enum Die { foundHq, foundAllLostMiners, nomadsSettled, + foundSlugNest, } function floodedWith(cavern: AdjuredCavern): FluidType { @@ -279,4 +282,16 @@ export class Lore { this.vars, ); } + + generateFoundSlugNest(dice: DiceBox) { + return FOUND_SLUG_NEST.generate( + dice.lore(Die.foundSlugNest), + this.state, + this.vars, + ); + } + + generateSeismicForeshadow(rng: PseudorandomStream) { + return SEISMIC_FORESHADOW.generate(rng, this.state, this.vars); + } } diff --git a/src/core/models/architect.ts b/src/core/models/architect.ts index ab739fe..8aed49b 100644 --- a/src/core/models/architect.ts +++ b/src/core/models/architect.ts @@ -18,6 +18,7 @@ import { Vehicle, VehicleFactory } from "./vehicle"; import { EnscribedCavern } from "../transformers/04_ephemera/02_enscribe"; import { StrataformedCavern } from "../transformers/03_plastic/02_strataform"; import { CollapseUnion } from "../common/utils"; +import { AnyMetadata } from "../architects"; type SpawnBidArgs = { readonly cavern: PartialPlannedCavern; @@ -26,9 +27,9 @@ type SpawnBidArgs = { export type BaseMetadata = { readonly tag: string } | undefined; -type BidArgs = SpawnBidArgs & { +type BidArgs = SpawnBidArgs & { readonly plans: readonly CollapseUnion< - FloodedPlan | EstablishedPlan + FloodedPlan | EstablishedPlan >[]; readonly hops: readonly number[]; readonly totalCrystals: number; @@ -48,8 +49,8 @@ type PrimeArgs = { export type BaseArchitect = { readonly name: string; - caveBid?(args: BidArgs): number | false; - hallBid?(args: BidArgs): number | false; + caveBid?(args: BidArgs): number | false; + hallBid?(args: BidArgs): number | false; spawnBid?(args: SpawnBidArgs): number | false; prime(args: PrimeArgs): T; diff --git a/src/core/models/tiles.ts b/src/core/models/tiles.ts index e6920ad..148b82b 100644 --- a/src/core/models/tiles.ts +++ b/src/core/models/tiles.ts @@ -1,30 +1,48 @@ -// Where possible, use colors from -// https://github.com/trigger-segfault/legorockraiders-analysis/blob/main/docs/LegoRR_Colors.h +export enum Hardness { + NONE = 0, + RUBBLE, + DIRT, + LOOSE, + SEAM, + HARD, + SOLID, +} + +type BaseTile = { + id: number; + name: string; + hardness: Hardness; + isWall: boolean; + isFluid: boolean; + maxSlope: number | undefined; + crystalYield: number; + oreYield: number; +}; // prettier-ignore const TILES = { - FLOOR: {id: 1, name: "Cavern Floor", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, - LAVA: {id: 6, name: "Lava", isWall: false, isFluid: true, maxSlope: 0, crystalYield: 0, oreYield: 0}, - WATER: {id: 11, name: "Water", isWall: false, isFluid: true, maxSlope: 0, crystalYield: 0, oreYield: 0}, - DIRT: {id: 26, name: "Dirt", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 4}, - LOOSE_ROCK: {id: 30, name: "Loose Rock", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 4}, - HARD_ROCK: {id: 34, name: "Hard Rock", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 4}, - SOLID_ROCK: {id: 38, name: "Solid Rock", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 4}, - RUBBLE_1: {id: 2, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 1}, - RUBBLE_2: {id: 3, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 2}, - RUBBLE_3: {id: 4, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 3}, - RUBBLE_4: {id: 5, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 4}, - SLUG_HOLE: {id: 12, name: "Slimy Slug Hole", isWall: false, isFluid: false, maxSlope: 15, crystalYield: 0, oreYield: 0}, - FOUNDATION: {id: 14, name: "Foundation", isWall: false, isFluid: false, maxSlope: 15, crystalYield: 0, oreYield: 0}, - POWER_PATH: {id: 24, name: "Power Path", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, - LANDSLIDE_RUBBLE_4: {id: 60, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, - LANDSLIDE_RUBBLE_3: {id: 61, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, - LANDSLIDE_RUBBLE_2: {id: 62, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, - LANDSLIDE_RUBBLE_1: {id: 63, name: "Rubble", isWall: false, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, - CRYSTAL_SEAM: {id: 42, name: "Energy Crystal Seam", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 4, oreYield: 4}, - ORE_SEAM: {id: 46, name: "Ore Seam", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 8}, - RECHARGE_SEAM: {id: 50, name: "Recharge Seam", isWall: true, isFluid: false, maxSlope: undefined, crystalYield: 0, oreYield: 0}, -} as const; + FLOOR: {id: 1, name: "Cavern Floor", crystalYield: 0, hardness: Hardness.NONE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0}, + LAVA: {id: 6, name: "Lava", crystalYield: 0, hardness: Hardness.NONE, isFluid: true, isWall: false, maxSlope: 0, oreYield: 0}, + WATER: {id: 11, name: "Water", crystalYield: 0, hardness: Hardness.NONE, isFluid: true, isWall: false, maxSlope: 0, oreYield: 0}, + DIRT: {id: 26, name: "Dirt", crystalYield: 0, hardness: Hardness.DIRT, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 4}, + LOOSE_ROCK: {id: 30, name: "Loose Rock", crystalYield: 0, hardness: Hardness.LOOSE, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 4}, + HARD_ROCK: {id: 34, name: "Hard Rock", crystalYield: 0, hardness: Hardness.HARD, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 4}, + SOLID_ROCK: {id: 38, name: "Solid Rock", crystalYield: 0, hardness: Hardness.SOLID, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 4}, + RUBBLE_1: {id: 2, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 1}, + RUBBLE_2: {id: 3, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 2}, + RUBBLE_3: {id: 4, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 3}, + RUBBLE_4: {id: 5, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 4}, + SLUG_HOLE: {id: 12, name: "Slimy Slug Hole", crystalYield: 0, hardness: Hardness.NONE, isFluid: false, isWall: false, maxSlope: 15, oreYield: 0}, + FOUNDATION: {id: 14, name: "Foundation", crystalYield: 0, hardness: Hardness.NONE, isFluid: false, isWall: false, maxSlope: 15, oreYield: 0}, + POWER_PATH: {id: 24, name: "Power Path", crystalYield: 0, hardness: Hardness.NONE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0}, + LANDSLIDE_RUBBLE_4: {id: 60, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0}, + LANDSLIDE_RUBBLE_3: {id: 61, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0}, + LANDSLIDE_RUBBLE_2: {id: 62, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0}, + LANDSLIDE_RUBBLE_1: {id: 63, name: "Rubble", crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0}, + CRYSTAL_SEAM: {id: 42, name: "Energy Crystal Seam", crystalYield: 4, hardness: Hardness.SEAM, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 4}, + ORE_SEAM: {id: 46, name: "Ore Seam", crystalYield: 0, hardness: Hardness.SEAM, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 8}, + RECHARGE_SEAM: {id: 50, name: "Recharge Seam", crystalYield: 0, hardness: Hardness.SOLID, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 0}, +} as const satisfies { [K in any]: BaseTile }; export const Tile = TILES; export type Tile = (typeof TILES)[keyof typeof TILES]; // eslint-disable-line @typescript-eslint/no-redeclare diff --git a/src/core/transformers/01_planning/04_pearl.ts b/src/core/transformers/01_planning/04_pearl.ts index d172008..7fa0d14 100644 --- a/src/core/transformers/01_planning/04_pearl.ts +++ b/src/core/transformers/01_planning/04_pearl.ts @@ -169,9 +169,12 @@ export default function pearl( const innerPearl: Point[][] = [grid.map((_, x, y) => [x, y])]; const outerPearl: Point[][] = []; const pearlRadius = plan.architect.roughExtent(plan); - for (let i = 1; i <= pearlRadius; i++) { + for (let i = 1; i < pearlRadius; i++) { innerPearl.push(addLayer(grid, rng, plan.baroqueness, i)); } + if (pearlRadius > 0) { + innerPearl.push(addLayer(grid, rng, 0, pearlRadius)); + } for (let i = 1; i < 4; i++) { outerPearl.push(addLayer(grid, rng, 0, i + pearlRadius)); } diff --git a/src/core/transformers/02_masonry/03_grout.ts b/src/core/transformers/02_masonry/03_grout.ts index 03b5b02..11658f3 100644 --- a/src/core/transformers/02_masonry/03_grout.ts +++ b/src/core/transformers/02_masonry/03_grout.ts @@ -41,14 +41,23 @@ function isHole(tiles: Grid, x: number, y: number) { export default function grout(cavern: RoughPlasticCavern): RoughPlasticCavern { const tiles = cavern.tiles.copy(); cavern.tiles.forEach((t, x, y) => { - if (t.isWall) { - return; - } - if (isHole(tiles, x, y)) { - tiles.set(x, y, Tile.DIRT); - return; - } if ( + // If the point is surrounded by hard or solid rock, make it hard rock + t !== Tile.SOLID_ROCK && + !NSEW.some(([ox, oy]) => { + const ot = tiles.get(x + ox, y + oy); + return ot && ot !== Tile.HARD_ROCK && ot !== Tile.SOLID_ROCK; + }) + ) { + tiles.set(x, y, Tile.HARD_ROCK); + } else if ( + // If the point is not wall and should be, make it dirt + !t.isWall && + isHole(tiles, x, y) + ) { + tiles.set(x, y, Tile.DIRT); + } else if ( + // If the point is fluid and not connected to other fluid, make it floor t.isFluid && !NSEW.some(([ox, oy]) => tiles.get(x + ox, y + oy)?.isFluid) ) { diff --git a/src/core/transformers/02_masonry/04_sand.ts b/src/core/transformers/02_masonry/04_sand.ts new file mode 100644 index 0000000..472537a --- /dev/null +++ b/src/core/transformers/02_masonry/04_sand.ts @@ -0,0 +1,20 @@ +import { NSEW } from "../../common/geometry"; +import { Tile } from "../../models/tiles"; +import { RoughPlasticCavern } from "./01_rough"; + +export default function sand(cavern: RoughPlasticCavern): RoughPlasticCavern { + const tiles = cavern.tiles.copy(); + cavern.tiles.forEach((t, x, y) => { + if ( + // Shave down any hard rock that doesn't border other hard rock + t === Tile.HARD_ROCK && + !NSEW.some(([ox, oy]) => { + const ot = tiles.get(x + ox, y + oy); + return !ot || ot === Tile.HARD_ROCK || ot === Tile.SOLID_ROCK; + }) + ) { + tiles.set(x, y, Tile.LOOSE_ROCK); + } + }); + return { ...cavern, tiles }; +} diff --git a/src/core/transformers/02_masonry/04_fine.ts b/src/core/transformers/02_masonry/05_fine.ts similarity index 100% rename from src/core/transformers/02_masonry/04_fine.ts rename to src/core/transformers/02_masonry/05_fine.ts diff --git a/src/core/transformers/02_masonry/05_annex.ts b/src/core/transformers/02_masonry/06_annex.ts similarity index 95% rename from src/core/transformers/02_masonry/05_annex.ts rename to src/core/transformers/02_masonry/06_annex.ts index b601116..1432bb2 100644 --- a/src/core/transformers/02_masonry/05_annex.ts +++ b/src/core/transformers/02_masonry/06_annex.ts @@ -1,6 +1,6 @@ import { NSEW, Point } from "../../common/geometry"; import { Tile } from "../../models/tiles"; -import { FinePlasticCavern } from "./04_fine"; +import { FinePlasticCavern } from "./05_fine"; function isInPlay(voidNeighbors: Point[]) { if (voidNeighbors.length === 2) { diff --git a/src/core/transformers/02_masonry/index.ts b/src/core/transformers/02_masonry/index.ts index 07c6119..3d70f0c 100644 --- a/src/core/transformers/02_masonry/index.ts +++ b/src/core/transformers/02_masonry/index.ts @@ -3,12 +3,14 @@ import foundation from "./00_foundation"; import rough from "./01_rough"; import brace from "./02_brace"; import grout from "./03_grout"; -import fine from "./04_fine"; -import annex from "./05_annex"; +import sand from "./04_sand"; +import fine from "./05_fine"; +import annex from "./06_annex"; export const MASONRY_TF = tf(foundation) .then(rough) .then(brace) .then(grout) + .then(sand) .then(fine) .then(annex); diff --git a/src/core/transformers/03_plastic/00_fence.ts b/src/core/transformers/03_plastic/00_fence.ts index 9147af3..271f829 100644 --- a/src/core/transformers/03_plastic/00_fence.ts +++ b/src/core/transformers/03_plastic/00_fence.ts @@ -1,4 +1,4 @@ -import { FinePlasticCavern } from "../02_masonry/04_fine"; +import { FinePlasticCavern } from "../02_masonry/05_fine"; export type FencedCavern = FinePlasticCavern & { left: number; diff --git a/src/core/transformers/04_ephemera/00_aerate.ts b/src/core/transformers/04_ephemera/00_aerate.ts index 470d155..1e7d46e 100644 --- a/src/core/transformers/04_ephemera/00_aerate.ts +++ b/src/core/transformers/04_ephemera/00_aerate.ts @@ -15,6 +15,11 @@ export type AeratedCavern = PopulatedCavern & { readonly aerationLog: null | Grid; }; +const MIN_STARTING_AIR = 1000; +const MIN_AIR_CAP = 3000; +const FALLBACK_AIR = 8000; +const AIR_STEP = 250; + // Some timing stats const TIMING = { // Time required to walk across 1 tile. @@ -108,6 +113,8 @@ export default function aerate(cavern: PopulatedCavern): AeratedCavern { ore -= 5 + SUPPORT_STATION.ore; } + crystals = Math.min(crystals, -1); + const origin = getOrigin(cavern); function drillTiming(t: Tile) { @@ -190,7 +197,7 @@ export default function aerate(cavern: PopulatedCavern): AeratedCavern { // Failure mode: This simulation can't figure out how to build a // Support Station. Use an arbitrary high air quantity. console.log("Unable to playtest this level for air consumption."); - return { ...cavern, oxygen: [8000, 8000], aerationLog }; + return { ...cavern, oxygen: [FALLBACK_AIR, FALLBACK_AIR], aerationLog }; } } } @@ -203,13 +210,13 @@ export default function aerate(cavern: PopulatedCavern): AeratedCavern { // Multiply by safety factor and round up to the nearest 250. air *= cavern.context.airSafetyFactor; - air = Math.max(500, Math.ceil(air / 250) * 250); + air = Math.max(MIN_STARTING_AIR, Math.ceil(air / AIR_STEP) * AIR_STEP); // Larger caverns should have more max air, even if the starting air is low. const maxAir = Math.max( air, - 3000, - Math.ceil((cavern.context.targetSize * 80) / 250) * 250, + MIN_AIR_CAP, + Math.ceil((cavern.context.targetSize * 80) / AIR_STEP) * AIR_STEP, ); return { ...cavern, oxygen: [air, maxAir], aerationLog }; diff --git a/src/core/transformers/04_ephemera/03_program.ts b/src/core/transformers/04_ephemera/03_program.ts index 69f06ef..e761349 100644 --- a/src/core/transformers/04_ephemera/03_program.ts +++ b/src/core/transformers/04_ephemera/03_program.ts @@ -17,20 +17,35 @@ export default function program(cavern: EnscribedCavern): ProgrammedCavern { return r; }, []), ); - const script = filterTruthy([ - ...globalsFns.map((fn) => fn({ cavern })), - ...cavern.plans.map((plan) => plan.architect.script({ cavern, plan })), - ...(cavern.context.hasMonsters - ? cavern.plans.map((plan) => + const archGlobals = filterTruthy(globalsFns.map((fn) => fn({ cavern }))); + const archScripts = filterTruthy( + cavern.plans.map((plan) => plan.architect.script({ cavern, plan })), + ); + const monsters = cavern.context.hasMonsters + ? filterTruthy( + cavern.plans.map((plan) => plan.architect.monsterSpawnScript({ cavern, plan }), - ) - : []), - ...(cavern.context.hasSlugs - ? cavern.plans.map((plan) => + ), + ) + : []; + const slugs = cavern.context.hasSlugs + ? filterTruthy( + cavern.plans.map((plan) => plan.architect.slugSpawnScript({ cavern, plan }), - ) - : []), - ]).join("\n"); + ), + ) + : []; + const na = ["# n/a", ""]; + const script = [ + "# I. Architect Globals", + ...(archGlobals.length ? archGlobals : na), + "# II. Architect Scripts", + ...(archScripts.length ? archScripts : na), + "# III. Spawn Monsters", + ...(monsters.length ? monsters : na), + "# IV. Spawn Slugs", + ...(slugs.length ? slugs : na), + ].join("\n"); return { ...cavern, script }; } diff --git a/src/core/transformers/04_ephemera/04_serialize.ts b/src/core/transformers/04_ephemera/04_serialize.ts index 97206dd..692be61 100644 --- a/src/core/transformers/04_ephemera/04_serialize.ts +++ b/src/core/transformers/04_ephemera/04_serialize.ts @@ -14,6 +14,10 @@ export type SerializedCavern = ProgrammedCavern & { serialized: string; }; +// If any of these are found in the level output, assume JavaScript did +// something stupid and throw an error. +const RESTRICTED_STRINGS = ["[object Object]", "undefined", "NaN"] as const; + function indent(it: string, prefix: string) { return it .split(/\r?\n/) @@ -68,6 +72,18 @@ export function serializeHazards( .join("\n"); } +function performErrorChecking(serialized: string) { + serialized.split("\n").forEach((line, i) => { + RESTRICTED_STRINGS.forEach((restricted) => { + if (line.includes(restricted)) { + throw new Error( + `Found restricted string ${JSON.stringify(restricted)} on line ${i}:\n${line}`, + ); + } + }); + }); +} + export default function serialize(cavern: ProgrammedCavern): SerializedCavern { const offset: Point = [-cavern.left, -cavern.top]; @@ -152,5 +168,6 @@ ${ }`; + performErrorChecking(serialized); return { ...cavern, serialized }; } diff --git a/src/core/transformers/README.md b/src/core/transformers/README.md index 39133c5..bd04a4c 100644 --- a/src/core/transformers/README.md +++ b/src/core/transformers/README.md @@ -1,35 +1,55 @@ This directory contains all of the individual steps to "transform" a cavern from a context object to a completed, serialized cavern. Each step builds on the previous, and is meant to perform a single concrete task. These steps are organized into different "phases" as follows: -1. _Outlines_: Determine the rough position of the playable area of the cavern. The result of this phase is a graph of baseplates (non-overlapping regions of 2D space) connected by paths. This phase is loosely based on [AAdonaac's dungeon generation algorithm](https://www.gamedeveloper.com/programming/procedural-dungeon-generation-algorithm) with some modifications to make a more organic result. - 1. _Partition_: Starting with a square, slice it repeatedly into smaller rectangles, trimming off some edges at each step. The remaining rectangles become "baseplates" that later steps will build on. - 1. _Discriminate_: Choose the largest baseplates to become caves. - 1. _Triangulate_: Draw lines between the centers of the caves to create a mesh of triangles. These lines are now ambiguous paths. - 1. _Span_: Find the minimum spanning tree of paths and mark these paths as spanning. These will become halls. - 1. _Clip_: Remove some ambiguous paths that would be boring to include. - 1. _Bore_: Paths so far have been straight lines connecting caves. Add some detours where these paths intersect thus-far-unused baseplates to include them. - 1. _Weave_: Choose some of the ambiguous paths to become auxilliary halls. -1. _Planning_: Create "plans" for the baseplates and paths that will determine how the space will be used, but don't actually place anything in the map yet. - 1. _Negotiate_: Assign baseplates and paths to new plans. Some paths connecting two adjacent baseplates will become caves. All remaining paths become halls and all remaining baseplates become caves. - 1. _Measure_: Determine size information for the plans. - 1. _Flood_: Choose which plans will have water, lava, and erosion. - 1. _Establish_: Choose which plan will be the spawn, walk through adjacent plans, assign architects, and determine other information based on distance from spawn. - 1. _Pearl_: Create "pearls" that determine exactly where plans will go. This is the step that ensures the caves and halls will be more "natural" shapes. -1. _Masonry_: Place tiles and a few other related things in the map. After this phase, tiles may not be modified - 1. _Foundation_: Determine which tiles are assigned to which plans. - 1. _Rough_: All plans perform a "rough draft" of their own tile placement. This step only places floor, water, lava, dirt, loose, hard, and solid rock. Tiles may be overwritten multiple times. - 1. _Brace_: Find walls that would collapse immediately and add dirt so they don't. - 1. _Grout_: Eliminate single-tile features that would otherwise look bad, such as single unconnected tiles of water or lava or single-tile undiscovered caverns. - 1. _Fine_: All plans add resources, buildings, and other tile types like seams, paths, and rubble. Tiles may be overritten, but plans should avoid significant changes like replacing walls with non-walls. - 1. _Annex_: Find any solid rock that can be collapsed by drilling adjacent walls. Mark these tiles as in-play. -1. _Plastic_: Place other things in the map that rely on the tile placement being finalized. - 1. _Fence_: Determine the final bounds of the map. - 1. _Discover_: Break the map into contiguous regions of non-wall tiles. Determine which of these are open at start. - 1. _Strataform_: Plans determine a discrete "target height" for their tiles. - 1. _Strataflux_: Determine the final height of all tile corners within the map bounds, smoothing out the target height map determined earlier. - 1. _Populate_: Plans add miners, vehicles, landslides, erosion, and creatures (but not monster spawns). -1. _Ephemera_: Add everything else that doesn't necessarily have a position within the map. - 1. _Aerate_: Estimate the total playtime required before the player will have a working Support Station and use this to determine how much oxygen the level should have. - 1. _Adjure_: Determine the level objectives. This is done late in the transformation so those objectives can match reality. For example, it's possible to generate lost miners and have them discovered at map start, meaning they should not be used as an objective. - 1. _Enscribe_: Write the level name, briefing, success, and failure messages. This also creates a Lore object which is used to produce additional text strings during scripting. - 1. _Program_: Plans add any scripts they need. This includes monster and slug spawns, along with some level objectives that are triggered by scripts. - 1. _Serialize_: Convert the cavern to a text string, which is the final contents of the level.dat file. +# I. Outlines + +Determine the rough position of the playable area of the cavern. The result of this phase is a graph of baseplates (non-overlapping regions of 2D space) connected by paths. This phase is loosely based on [AAdonaac's dungeon generation algorithm](https://www.gamedeveloper.com/programming/procedural-dungeon-generation-algorithm) with some modifications to make a more organic result. + +1. _Partition_: Starting with a square, slice it repeatedly into smaller rectangles, trimming off some edges at each step. The remaining rectangles become "baseplates" that later steps will build on. +1. _Discriminate_: Choose the largest baseplates to become caves. +1. _Triangulate_: Draw lines between the centers of the caves to create a mesh of triangles. These lines are now ambiguous paths. +1. _Span_: Find the minimum spanning tree of paths and mark these paths as spanning. These will become halls. +1. _Clip_: Remove some ambiguous paths that would be boring to include. +1. _Bore_: Paths so far have been straight lines connecting caves. Add some detours where these paths intersect thus-far-unused baseplates to include them. +1. _Weave_: Choose some of the ambiguous paths to become auxilliary halls. + +# II. Planning + +Create "plans" for the baseplates and paths that will determine how the space will be used, but don't actually place anything in the map yet. + +1. _Negotiate_: Assign baseplates and paths to new plans. Some paths connecting two adjacent baseplates will become caves. All remaining paths become halls and all remaining baseplates become caves. +1. _Measure_: Determine size information for the plans. +1. _Flood_: Choose which plans will have water, lava, and erosion. +1. _Establish_: Choose which plan will be the spawn, walk through adjacent plans, assign architects, and determine other information based on distance from spawn. +1. _Pearl_: Create "pearls" that determine exactly where plans will go. This is the step that ensures the caves and halls will be more "natural" shapes. + +# III. Masonry + +Place tiles and a few other related things in the map. After this phase, tiles may not be modified + +1. _Foundation_: Determine which tiles are assigned to which plans. +1. _Rough_: All plans perform a "rough draft" of their own tile placement. This step only places floor, water, lava, dirt, loose, hard, and solid rock. Tiles may be overwritten multiple times. +1. _Brace_: Find walls that would collapse immediately and add dirt so they don't. +1. _Grout_: Fill single-tile "holes" that would otherwise look bad, such as single unconnected tiles of water or lava or single-tile undiscovered caverns. +1. _Sand_: Downgrade single-tile spots of hard rock to loose rock. +1. _Fine_: All plans add resources, buildings, and other tile types like seams, paths, and rubble. Tiles may be overritten, but plans should avoid significant changes like replacing walls with non-walls. +1. _Annex_: Find any solid rock that can be collapsed by drilling adjacent walls. Mark these tiles as in-play. + +# IV. Plastic + +Place other things in the map that rely on the tile placement being finalized. + +1. _Fence_: Determine the final bounds of the map. +1. _Discover_: Break the map into contiguous regions of non-wall tiles. Determine which of these are open at start. +1. _Strataform_: Plans determine a discrete "target height" for their tiles. +1. _Strataflux_: Determine the final height of all tile corners within the map bounds, smoothing out the target height map determined earlier. +1. _Populate_: Plans add miners, vehicles, landslides, erosion, and creatures (but not monster spawns). + +# V. Ephemera + +Add everything else that doesn't necessarily have a position within the map. + +1. _Aerate_: Estimate the total playtime required before the player will have a working Support Station and use this to determine how much oxygen the level should have. +1. _Adjure_: Determine the level objectives. This is done late in the transformation so those objectives can match reality. For example, it's possible to generate lost miners and have them discovered at map start, meaning they should not be used as an objective. +1. _Enscribe_: Write the level name, briefing, success, and failure messages. This also creates a Lore object which is used to produce additional text strings during scripting. +1. _Program_: Plans add any scripts they need. This includes monster and slug spawns, along with some level objectives that are triggered by scripts. +1. _Serialize_: Convert the cavern to a text string, which is the final contents of the level.dat file. diff --git a/src/webui/components/map_preview/script_preview/index.tsx b/src/webui/components/map_preview/script_preview/index.tsx index 2ad93ce..17249c4 100644 --- a/src/webui/components/map_preview/script_preview/index.tsx +++ b/src/webui/components/map_preview/script_preview/index.tsx @@ -129,44 +129,35 @@ export function ScriptOverlay({ const ox = cavern.left!; const oy = cavern.top!; return ( - <> - - {statements!.map(({ kind, pos }, i) => { - if (!pos || scriptLineOffsets[i] === undefined) { - return null; - } - const active = true; - const className = filterTruthy([ - styles.tile, - active ? styles.active : styles.inactive, - scriptLineHovered === i && styles.hovered, - styles[kind], - ]).join(" "); - return ( - - ); - })} - - {parse(cavern.script).map(({ kind, pos }, i) => { - if ( - !pos || - scriptLineOffsets[i] === undefined || - scriptLineHovered !== i - ) { + + {statements!.map(({ kind, pos }, i) => { + if (!pos || scriptLineOffsets[i] === undefined) { return null; } const className = filterTruthy([ - styles.arrow, + styles.tile, scriptLineHovered === i && styles.hovered, styles[kind], ]).join(" "); + return ( + + ); + })} + {parse(cavern.script).map(({ kind, pos }, i) => { + if ( + !pos || + scriptLineHovered !== i || + scriptLineOffsets[i] === undefined + ) { + return null; + } const lx = -9999; const ly = scriptLineOffsets[i] / scale; const px = (pos[0] + ox + 0.5) * SCALE; @@ -177,13 +168,27 @@ export function ScriptOverlay({ `L ${bx} ${ly}`, `L ${lx} ${ly}`, ]).join(""); + const cr = 10; + const tr = 4; return ( - - + + + ); })} - + ); } diff --git a/src/webui/components/map_preview/script_preview/styles.module.scss b/src/webui/components/map_preview/script_preview/styles.module.scss index 48d185c..d34068b 100644 --- a/src/webui/components/map_preview/script_preview/styles.module.scss +++ b/src/webui/components/map_preview/script_preview/styles.module.scss @@ -28,20 +28,22 @@ } } -.tiles { - .active.tile { +.scriptOverlay { + .tile { fill: var(--color-codehlt); } - .inactive.tile { - fill: transparent; + .arrow { + fill: none; + stroke: var(--color-codehlt); + stroke-width: 2px; } -} -.arrow { - fill: none; - stroke: var(--color-codehlt); - stroke-width: 2px; + .arrowhead { + fill: var(--palette-bg); + stroke: var(--color-codehlt); + stroke-width: 2px; + } } .misc { diff --git a/src/webui/components/popovers/error.tsx b/src/webui/components/popovers/error.tsx index a1d62f2..11ba6d5 100644 --- a/src/webui/components/popovers/error.tsx +++ b/src/webui/components/popovers/error.tsx @@ -16,6 +16,7 @@ const ErrorPreview = ({