diff --git a/src/core/architects/build_and_power.ts b/src/core/architects/build_and_power.ts index 34d60ae..9ae6869 100644 --- a/src/core/architects/build_and_power.ts +++ b/src/core/architects/build_and_power.ts @@ -16,6 +16,7 @@ import { OrderedOrEstablishedPlan } from "../transformers/01_planning/05_establi import { DefaultCaveArchitect, PartialArchitect } from "./default"; import { intersectsOnly } from "./utils/intersects"; import { gObjectives } from "./utils/objectives"; +import { getPlaceRechargeSeams } from "./utils/resources"; import { Rough, mkRough } from "./utils/rough"; import { EventChainLine, mkVars, transformPoint } from "./utils/script"; @@ -47,10 +48,10 @@ function buildAndPower( pgFirst: PhraseGraph, pgPenultimate: PhraseGraph, pgLast: PhraseGraph, - minLevel: Building["level"] = 1, + minLevel: Building["level"], ): Pick< Architect, - "prime" | "objectives" | "script" | "scriptGlobals" + "prime" | "placeRechargeSeam" | "objectives" | "script" | "scriptGlobals" > { const g = mkVars(`gBuPw${template.inspectAbbrev}`, [ "built", @@ -70,6 +71,7 @@ function buildAndPower( ]); return { prime: () => metadata, + placeRechargeSeam: getPlaceRechargeSeams(3), objectives({ cavern }) { const count = cavern.plans.filter( (plan) => @@ -270,7 +272,7 @@ export const BUILD_AND_POWER = [ ...buildAndPower(SUPPORT_STATION, BUILD_POWER_SS_FIRST, BUILD_POWER_SS_PENULTIMATE, - BUILD_POWER_SS_LAST, 5), + BUILD_POWER_SS_LAST, 1), ...mkRough( { of: Rough.ALWAYS_FLOOR, width: 2 }, { of: Rough.LAVA, width: 2, grow: 1 }, @@ -283,6 +285,7 @@ export const BUILD_AND_POWER = [ return ( plan.fluid === Tile.LAVA && !plan.hasErosion && + !plan.intersects.some((_, pi) => cavern.plans[pi].hasErosion) && plan.pearlRadius > 3 && plan.path.baseplates.length === 1 && amd?.tag === "hq" && diff --git a/src/core/architects/default.ts b/src/core/architects/default.ts index c4f6b04..f577854 100644 --- a/src/core/architects/default.ts +++ b/src/core/architects/default.ts @@ -59,7 +59,6 @@ export const [DefaultCaveArchitect, DefaultHallArchitect] = ( }, placeErosion: (args) => placeErosion(30, 10, args), placeEntities: () => ({}), - objectives: () => undefined, maxSlope: undefined, claimEventOnDiscover: () => [], }) as PartialArchitect, diff --git a/src/core/architects/established_hq/base.ts b/src/core/architects/established_hq/base.ts index c51c5ae..7c7ea71 100644 --- a/src/core/architects/established_hq/base.ts +++ b/src/core/architects/established_hq/base.ts @@ -146,7 +146,7 @@ export function getPlaceBuildings({ const building = buildings[i]; let fTile: Tile; if ("placeRubbleInstead" in building) { - fTile = Tile.RUBBLE_4; + fTile = Tile.RUBBLE_1; } else { fTile = Tile.FOUNDATION; if (dependencies.has(building.template)) { diff --git a/src/core/architects/established_hq/gas_leak.ts b/src/core/architects/established_hq/gas_leak.ts index 3a23ab6..baa27fe 100644 --- a/src/core/architects/established_hq/gas_leak.ts +++ b/src/core/architects/established_hq/gas_leak.ts @@ -1,3 +1,5 @@ +import { inferContextDefaults } from "../../common"; +import { GAS_LEAK_NO_AIR } from "../../lore/graphs/events"; import { Architect } from "../../models/architect"; import { TOOL_STORE, @@ -6,6 +8,9 @@ import { TELEPORT_PAD, ELECTRIC_FENCE_ID, } from "../../models/building"; +import { gCreatures } from "../utils/creature_spawners"; +import { gObjectives } from "../utils/objectives"; +import { mkVars } from "../utils/script"; import { BASE, HqMetadata, getPlaceBuildings } from "./base"; const T0_BUILDINGS = [TOOL_STORE, TELEPORT_PAD, POWER_STATION, SUPPORT_STATION] as const; @@ -15,11 +20,19 @@ const STARTING_BONUS_CRYSTALS = 2; const GAS_LEAK_BASE: Pick< Architect, | "crystalsFromMetadata" + | "mod" | "prime" | "placeBuildings" - | "scriptGlobals" + | "holdCreatures" + | "script" > = { - crystalsFromMetadata: (metadata) => STARTING_BONUS_CRYSTALS + metadata.crystalsInBuildings, + mod(cavern) { + const context = inferContextDefaults({ + globalHostilesCap: 4, + ...cavern.initialContext, + }); + return {...cavern, context, oxygen: [500, 500]} + }, prime: () => { return { crystalsInBuildings: T0_CRYSTALS, @@ -28,16 +41,41 @@ const GAS_LEAK_BASE: Pick< tag: "hq", }; }, + crystalsFromMetadata: (metadata) => STARTING_BONUS_CRYSTALS + metadata.crystalsInBuildings, placeBuildings: getPlaceBuildings({ discovered: true, from: 2, templates: () => T0_BUILDINGS, }), - scriptGlobals({sb}) { + holdCreatures: () => true, + script({cavern, plan, sb}) { + const v = mkVars(`p${plan.id}GlHq`, ['msgNoAir', 'holdLoop']); + const rng = cavern.dice.script(plan.id); sb.onInit( `disable:${ELECTRIC_FENCE_ID};`, - `crystals=${STARTING_BONUS_CRYSTALS};` - ) + `crystals=${STARTING_BONUS_CRYSTALS};`, + `${v.holdLoop};`, + ); + sb.declareString( + v.msgNoAir, {pg: GAS_LEAK_NO_AIR, rng} + ); + sb.if( + `${gCreatures.airMiners}==0`, + `((${gObjectives.won}>0))return;`, + `wait:5;`, + `msg:${v.msgNoAir};`, + ); + sb.event( + v.holdLoop, + // 5-10 minutes of peace + `wait:random(${5 * 60})(${10 * 60});`, + `${gCreatures.anchorHold}=0;`, + // 5-10 minutes of monsters + `wait:random(${5 * 60})(${10 * 60});`, + `${gCreatures.anchorHold}=1;`, + // Loop + `${v.holdLoop};`, + ); }, }; diff --git a/src/core/architects/mob_farm.ts b/src/core/architects/mob_farm.ts index 28545a2..90f777a 100644 --- a/src/core/architects/mob_farm.ts +++ b/src/core/architects/mob_farm.ts @@ -28,9 +28,9 @@ import { getPlaceRechargeSeams, sprinkleCrystals } from "./utils/resources"; import { inferContextDefaults } from "../common"; import { mkVars } from "./utils/script"; import { - HINT_SELECT_LASER_GROUP, MOB_FARM_NO_LONGER_BLOCKING, } from "../lore/graphs/events"; +import { HINT_SELECT_LASER_GROUP } from "../lore/graphs/hints"; import { gObjectives } from "./utils/objectives"; const BANLIST = [ diff --git a/src/core/architects/ore_waste.ts b/src/core/architects/ore_waste.ts index eed8164..731fa73 100644 --- a/src/core/architects/ore_waste.ts +++ b/src/core/architects/ore_waste.ts @@ -73,7 +73,7 @@ const BASE: PartialArchitect = { // ore. No reason to prolong this with an additional ore objective. return undefined; } - const studs = (getTotalOre(cavern) - ORE_OVERHEAD) * cavern.context.crystalGoalRatio / 2; + const studs = (getTotalOre(cavern) - ORE_OVERHEAD) * 0.12 / 2; return { studs: Math.floor(studs / 5) * 5, sufficient: false, diff --git a/src/core/architects/utils/objectives.ts b/src/core/architects/utils/objectives.ts index 22ee484..5ff7a35 100644 --- a/src/core/architects/utils/objectives.ts +++ b/src/core/architects/utils/objectives.ts @@ -17,11 +17,16 @@ export function objectiveGlobals({ sb.declareInt(gObjectives.met, 0); sb.declareInt(gObjectives.won, 0); if (!resources.length) { - // skip + // No resource goals. Skip. } else if (resources.length === 1) { - sb.if(`${resources[0]}>=${objectives[resources[0]]}`, `${gObjectives.met}+=1;`) + // One resource goal. When met, mark objective as completed. + sb.if( + `${resources[0]}>=${objectives[resources[0]]}`, + `${gObjectives.met}+=1;`, + ); } else { - sb.declareInt(gObjectives.res, 1); + // Multiple resource goals. Must check all are satisfied simultaneously. + sb.declareInt(gObjectives.res, 0); resources.forEach((resource) => sb.when( `${resource}>=${objectives[resource]}`, diff --git a/src/core/architects/utils/tile_scripts.ts b/src/core/architects/utils/tile_scripts.ts index 287f287..222c686 100644 --- a/src/core/architects/utils/tile_scripts.ts +++ b/src/core/architects/utils/tile_scripts.ts @@ -4,7 +4,7 @@ import { EventChainLine, ScriptBuilder, transformPoint } from "./script"; const TRIGGERS = { flood: (cavern, x, y) => `place:${transformPoint(cavern, [x, y])},${Tile.WATER.id};`, - waste: (cavern, x, y) => `place:${transformPoint(cavern, [x, y])},${Tile.WASTE_RUBBLE_4.id};`, + waste: (cavern, x, y) => `place:${transformPoint(cavern, [x, y])},${Tile.WASTE_RUBBLE_3.id};`, } as const satisfies {[key: string]: (cavern: PreprogrammedCavern, x: number, y: number) => EventChainLine}; export function tileScript({cavern, sb}: { diff --git a/src/core/common/context.ts b/src/core/common/context.ts index d794228..0689c14 100644 --- a/src/core/common/context.ts +++ b/src/core/common/context.ts @@ -96,6 +96,10 @@ export type CavernContext = { * Does this cavern have limited air? */ readonly hasAirLimit: boolean; + /** + * Bias toward (or against) the anchor being in the center. + */ + readonly anchorGravity: number; /** * How blobby and jagged caves should be. * 0 results in perfectly squashed octagons. @@ -295,6 +299,7 @@ const STANDARD_DEFAULTS = { optimalAuxiliaryPathCount: 2, randomAuxiliaryPathCount: 3, auxiliaryPathMinAngle: Math.PI / 4, + anchorGravity: 0, caveBaroqueness: 0.16, hallBaroqueness: 0.07, caveCrystalRichness: { base: 0.16, hops: 0.32, order: 0.32 }, diff --git a/src/core/lore/graphs/build_and_power.test.ts b/src/core/lore/graphs/build_and_power.test.ts new file mode 100644 index 0000000..15e523f --- /dev/null +++ b/src/core/lore/graphs/build_and_power.test.ts @@ -0,0 +1,11 @@ +import { BUILD_POWER_GC_FIRST, BUILD_POWER_GC_LAST, BUILD_POWER_GC_PENULTIMATE, BUILD_POWER_SS_FIRST, BUILD_POWER_SS_LAST, BUILD_POWER_SS_PENULTIMATE } from "./build_and_power"; +import testCompleteness from "./completeness"; + +testCompleteness( + BUILD_POWER_GC_FIRST, + BUILD_POWER_GC_LAST, + BUILD_POWER_GC_PENULTIMATE, + BUILD_POWER_SS_FIRST, + BUILD_POWER_SS_LAST, + BUILD_POWER_SS_PENULTIMATE, +) \ No newline at end of file diff --git a/src/core/lore/graphs/completeness.test.ts b/src/core/lore/graphs/completeness.ts similarity index 94% rename from src/core/lore/graphs/completeness.test.ts rename to src/core/lore/graphs/completeness.ts index 29784c6..2756469 100644 --- a/src/core/lore/graphs/completeness.test.ts +++ b/src/core/lore/graphs/completeness.ts @@ -1,4 +1,3 @@ -import ALL_GRAPHS from "."; import phraseGraph, { PhraseGraph } from "../utils/builder"; import { FoundLostMinersState, State } from "../lore"; @@ -86,10 +85,12 @@ function expectCompletion(actual: PhraseGraph) { expect(missing).toEqual([]); } -describe(`Graph is complete`, () => { - ALL_GRAPHS.forEach((pg) => { - test(`for ${pg.name}`, () => { - expectCompletion(pg); +export default function testCompleteness(...graphs: PhraseGraph[]) { + describe(`Graph is complete`, () => { + graphs.forEach((pg) => { + test(`for ${pg.name}`, () => { + expectCompletion(pg); + }); }); - }); -}); + }) +}; diff --git a/src/core/lore/graphs/conclusions.test.ts b/src/core/lore/graphs/conclusions.test.ts new file mode 100644 index 0000000..0ed6936 --- /dev/null +++ b/src/core/lore/graphs/conclusions.test.ts @@ -0,0 +1,7 @@ +import testCompleteness from "./completeness"; +import { FAILURE, SUCCESS } from "./conclusions"; + +testCompleteness( + SUCCESS, + FAILURE, +) \ No newline at end of file diff --git a/src/core/lore/graphs/conclusions.ts b/src/core/lore/graphs/conclusions.ts index d059b3c..365b8de 100644 --- a/src/core/lore/graphs/conclusions.ts +++ b/src/core/lore/graphs/conclusions.ts @@ -48,8 +48,7 @@ const COMMENDATIONS = [ export const SUCCESS = phraseGraph( "Conclusion - Success", ({ pg, state, start, end, cut, skip }) => { - const coda = pg(); - (() => { + const head = (() => { const commend = state("commend").then("Wow!", ...COMMENDATIONS); const hasMonsters = state("hasMonsters").then( ({format: {enemies}}) => `Those ${enemies} were no match for you!`, @@ -67,7 +66,9 @@ export const SUCCESS = phraseGraph `\ +With ${buildAndPowerGcCount === 1 ? 'this' : 'these'} built, we can safely \ +make our way further into the planet.`, + ), + ).then( + skip, state("hasMonsters") + ).then(coda); }, ); diff --git a/src/core/lore/graphs/events.test.ts b/src/core/lore/graphs/events.test.ts new file mode 100644 index 0000000..fd5b683 --- /dev/null +++ b/src/core/lore/graphs/events.test.ts @@ -0,0 +1,17 @@ +import testCompleteness from "./completeness"; +import { BLACKOUT_END, BLACKOUT_START, BOSS_ENEMY_DEFEATED, FAILURE_BASE_DESTROYED, FOUND_ALL_LOST_MINERS, FOUND_HOARD, FOUND_HQ, FOUND_LM_BREADCRUMB, FOUND_LOST_MINERS, FOUND_SLUG_NEST, GAS_LEAK_NO_AIR, MOB_FARM_NO_LONGER_BLOCKING } from "./events"; + +testCompleteness( + FOUND_ALL_LOST_MINERS, + FOUND_HOARD, + FOUND_HQ, + FOUND_LM_BREADCRUMB, + FOUND_LOST_MINERS, + FOUND_SLUG_NEST, + FAILURE_BASE_DESTROYED, + BOSS_ENEMY_DEFEATED, + BLACKOUT_START, + BLACKOUT_END, + MOB_FARM_NO_LONGER_BLOCKING, + GAS_LEAK_NO_AIR, +) \ No newline at end of file diff --git a/src/core/lore/graphs/events.ts b/src/core/lore/graphs/events.ts index f5feb10..81dd4ea 100644 --- a/src/core/lore/graphs/events.ts +++ b/src/core/lore/graphs/events.ts @@ -356,6 +356,19 @@ export const MOB_FARM_NO_LONGER_BLOCKING = phraseGraph( }, ); -export const HINT_SELECT_LASER_GROUP = `Hint: Hold SHIFT+click to select multiple units. -CTRL+[0-9] assigns a group of units that you can recall with [0-9]. -X activates laser mode.`; +export const GAS_LEAK_NO_AIR = phraseGraph( + "Gas Leak - All Support Stations offline", + ({ pg, state, start, end, cut, skip }) => { + start.then( + skip, + "Careful there!", + ).then( + "Without even one Support Station online, this cavern will be " + + "uninhabitable very quickly.", + ).then( + "Fix it NOW or we will need to abort!" + ).then(end); + } +) + + diff --git a/src/core/lore/graphs/hints.ts b/src/core/lore/graphs/hints.ts new file mode 100644 index 0000000..34a28ac --- /dev/null +++ b/src/core/lore/graphs/hints.ts @@ -0,0 +1,5 @@ + +export const HINT_SELECT_LASER_GROUP = `\ +Hint: Hold SHIFT+click to select multiple units.\ +CTRL+[0-9] assigns a group of units that you can recall with [0-9].\ +X activates laser mode.`; diff --git a/src/core/lore/graphs/index.ts b/src/core/lore/graphs/index.ts index 878be27..86ca320 100644 --- a/src/core/lore/graphs/index.ts +++ b/src/core/lore/graphs/index.ts @@ -17,6 +17,7 @@ import { FOUND_HQ, FOUND_LOST_MINERS, FOUND_SLUG_NEST, + GAS_LEAK_NO_AIR, NOMADS_SETTLED, } from "./events"; import { NAME } from "./names"; @@ -41,6 +42,7 @@ const ALL_GRAPHS = [ FOUND_HQ, FOUND_LOST_MINERS, FOUND_SLUG_NEST, + GAS_LEAK_NO_AIR, NAME, NOMADS_SETTLED, ORDERS, diff --git a/src/core/lore/graphs/names.test.ts b/src/core/lore/graphs/names.test.ts new file mode 100644 index 0000000..fdd3d1c --- /dev/null +++ b/src/core/lore/graphs/names.test.ts @@ -0,0 +1,4 @@ +import testCompleteness from "./completeness"; +import { NAME } from "./names"; + +testCompleteness(NAME); \ No newline at end of file diff --git a/src/core/lore/graphs/names.ts b/src/core/lore/graphs/names.ts index 880409f..40f7b02 100644 --- a/src/core/lore/graphs/names.ts +++ b/src/core/lore/graphs/names.ts @@ -153,18 +153,23 @@ export const OVERRIDE_SUFFIXES = [ "Episode One", "Episode Two", "Extended", + "Extremely Fungible Edition", "Forever", + "Founders Edition", "Gold Version", "Green Crystal Version", "HD", "HD 1.5 Remix", + "Limited Edition", "Millenium Edition", "New", "Now With Flavor", "Onyx Version", "Original Level, Do Not Steal", + "Pioneers Edition", "Planet U Remix", "Platinum Version", + "Power Brick Edition", "Premium", "Rebirthed", "Reborn", @@ -195,6 +200,7 @@ export const OVERRIDE_SUFFIXES = [ "Unplugged", "Unsanctioned", "Unstable", + "Unverified", "Uranium Version", "XP", "Xtreme Edition", diff --git a/src/core/lore/graphs/orders.test.ts b/src/core/lore/graphs/orders.test.ts new file mode 100644 index 0000000..0f13aed --- /dev/null +++ b/src/core/lore/graphs/orders.test.ts @@ -0,0 +1,4 @@ +import testCompleteness from "./completeness"; +import ORDERS from "./orders"; + +testCompleteness(ORDERS); \ No newline at end of file diff --git a/src/core/lore/graphs/orders.ts b/src/core/lore/graphs/orders.ts index 2ae827d..af274b3 100644 --- a/src/core/lore/graphs/orders.ts +++ b/src/core/lore/graphs/orders.ts @@ -1,5 +1,7 @@ import { Format, State } from "../lore"; +import { TextFn } from "../utils/base"; import phraseGraph from "../utils/builder"; +import { listJoiner } from "../utils/list"; const ORDERS = phraseGraph( "Briefing - Orders", @@ -22,6 +24,8 @@ const ORDERS = phraseGraph( const tailOptional = pg(tail, end); + const joiner: TextFn = listJoiner(); + start .then( state("hasMonsters") @@ -30,17 +34,16 @@ const ORDERS = phraseGraph( "defend the Rock Radier HQ", "build up your defenses", "arm your Rock Raiders", - ) - .then("and"), + ), state("hasSlugs") - .then("defend the Rock Radier HQ", "arm your Rock Raiders") - .then("and"), + .then("defend the Rock Radier HQ", "arm your Rock Raiders"), pg( skip, state("spawnIsNomadOne", "spawnIsNomadsTogether"), state("spawnIsHq") .then(state("hqIsRuin", "spawnHasErosion")) - .then("move to a safer cavern,", "find a more suitable location,"), + .then("move to a safer cavern", "find a more suitable location") + .then(joiner), ) .then( "build the Rock Raider HQ", @@ -51,7 +54,8 @@ const ORDERS = phraseGraph( ), state("spawnIsHq").then( "send some Rock Raiders down to this base", - pg("resume mining operations and") + pg("resume our mining operations") + .then(joiner) .then(collectResources) .then(cut), state("hqIsRuin").then( @@ -70,55 +74,69 @@ const ORDERS = phraseGraph( .then("reach the Rock Raider HQ", "locate the base") .then( skip, - state("hqIsRuin").then( - ", salvage what's left", - ", repair it", - ", get it back in working order", + state("hqIsRuin").then(joiner).then( + "salvage what's left", + "repair it", + "get it back in working order", ), ), - ) + ).then(joiner) .then( - "and", - "and use it to", - ", and when you are ready,", + skip, pg( state("hasMonsters").then(state("hasSlugs"), skip), state("hasSlugs"), ) .then( - "and keep it safe.", - "and make sure it is heavily defended.", + "keep it safe", + "make sure it is heavily defended", ) - .then("Then,", weNeed.then(cut)), - ), + ).then( + ". Use it to", + ". When you ready,", + ". You must", + pg(". Then,").then(weNeed).then(cut), + ) ) .then( skip, collectResources.then(cut), - state("buildAndPowerGcOne") - .then("construct a Geological Center in the marked cavern") - .then( - pg(", upgrade it to Level 5, and keep it powered on.").then( - tail, - pg("Finally, ").then(collectResources).then(cut), - pg("Then,"), + pg( + state("buildAndPowerGcOne") + .then( + "construct a Geological Center in the marked cavern, upgrade " + + "it to Level 5, and keep it powered on." + ), + state("buildAndPowerGcMultiple") + .then( + ({format: {buildAndPowerGcCount}}) => `\ +build a Geological Center in ${buildAndPowerGcCount === 2 ? 'both' : 'each'} of the marked caverns`, + ) + .then( + ", upgrade them to Level 5, and keep them powered on.", + "and upgrade them all to Level 5. They must all be powered at the same time for the scans to work properly.", + ), + state("buildAndPowerSsOne") + .then( + "construct a Support Station in the marked cavern and find some" + + "way to power it.", + "go to the island we've chosen and build a Support Station " + + "there. It will need power, so build a Power Station too." ), - state("buildAndPowerGcMultiple") + state("buildAndPowerSsMultiple") .then( - "construct a Geological Center in each of the marked caverns", - ({format: {buildAndPowerGcCount}}) => `build a Geological Center in ${buildAndPowerGcCount === 2 ? 'both of the' : `each of the ${buildAndPowerGcCount}`} marked caverns`, + ({format: {buildAndPowerSsCount}}) => `\ +build a Support Station in ${buildAndPowerSsCount === 2 ? 'both' : 'each'} of the marked caverns`, ) .then( - pg( - ", upgrade them to Level 5, and keep them powered on.", - "and upgrade them all to Level 5. They must all be powered at the same time for the scans to work properly.", - ).then( + ", and keep them powered on. We think this will mitigate the gas.", + ), + ).then( tail, pg("Finally,").then(collectResources).then(cut), pg("Then,"), ), - ), ) .then( pg("find", "locate", "search the cavern for") diff --git a/src/core/lore/graphs/premise.test.ts b/src/core/lore/graphs/premise.test.ts new file mode 100644 index 0000000..476cb8f --- /dev/null +++ b/src/core/lore/graphs/premise.test.ts @@ -0,0 +1,4 @@ +import testCompleteness from "./completeness"; +import PREMISE from "./premise"; + +testCompleteness(PREMISE); \ No newline at end of file diff --git a/src/core/lore/graphs/premise.ts b/src/core/lore/graphs/premise.ts index 95a9622..4a6ffa3 100644 --- a/src/core/lore/graphs/premise.ts +++ b/src/core/lore/graphs/premise.ts @@ -151,8 +151,7 @@ cavern instead.`, "There seems to be some geomagnetic anomaly in this area and " + "researching it could prove vital to our mining operations", ), - ) - .then(""), + ), // Maybe treasure, maybe spawn is HQ. pg( pg("A recent scan", "Our most recent geological survey").then( @@ -342,6 +341,19 @@ unnecessary risk.`, pg(findThem, findTheOthers) .then(state("spawnHasErosion"), skip) .then(state("hasSlugs"), skip) + .then( + skip, + state("spawnIsGasLeak").then( + "Our geologists have warned me that the air in this cavern " + + "contains a gas that reacts explosively with plasma, so we can't" + + "rely on Electric Fences." + ), + state('spawnIsOreWaste').then( + "This cavern has very little ore, so build only what you really need" + ).then("!", state("spawnIsGasLeak").then( + " - and no Electric Fences either. The atmosphere here would " + + "likely react with plasma... explosively.")), + ) .then( skip, state("spawnIsBlackout") diff --git a/src/core/lore/graphs/seismic.test.ts b/src/core/lore/graphs/seismic.test.ts new file mode 100644 index 0000000..20292a0 --- /dev/null +++ b/src/core/lore/graphs/seismic.test.ts @@ -0,0 +1,4 @@ +import testCompleteness from "./completeness"; +import { SEISMIC_FORESHADOW, SEISMIC_FORESHADOW_AGAIN } from "./seismic"; + +testCompleteness(SEISMIC_FORESHADOW, SEISMIC_FORESHADOW_AGAIN) \ No newline at end of file diff --git a/src/core/lore/lore.ts b/src/core/lore/lore.ts index 68f147b..28e5d3d 100644 --- a/src/core/lore/lore.ts +++ b/src/core/lore/lore.ts @@ -3,6 +3,7 @@ import { countLostMiners } from "../architects/lost_miners"; import { DiceBox } from "../common"; import { filterTruthy } from "../common/utils"; import { GEOLOGICAL_CENTER, SUPPORT_STATION } from "../models/building"; +import { Objectives } from "../models/objectives"; import { Plan } from "../models/plan"; import { FluidType, Tile } from "../models/tiles"; import { AdjuredCavern } from "../transformers/04_ephemera/01_adjure"; @@ -105,16 +106,19 @@ function joinHuman(things: string[], conjunction: string = "and"): string { return `${things.slice(0, -1).join(", ")} ${conjunction} ${things[things.length - 1]}`; } -function spellResourceGoal(cavern: AdjuredCavern) { +export function spellResourceGoal(objectives: Objectives) { const a = [ - { count: cavern.objectives.crystals, name: "Energy Crystals" }, - { count: cavern.objectives.ore, name: "Ore" }, - { count: cavern.objectives.studs, name: "Building Studs" }, + { count: objectives.crystals, name: "Energy Crystals" }, + { count: objectives.ore, name: "Ore" }, + { count: objectives.studs, name: "Building Studs" }, ].filter(({ count }) => count > 0); return { resourceGoal: joinHuman( a.map(({ count, name }) => `${spellNumber(count)} ${name}`), ), + resourceGoalNumbers: joinHuman( + a.map(({ count, name }) => `${count} ${name}`), + ), resourceGoalNamesOnly: joinHuman(a.map(({ name }) => name)), }; } @@ -225,7 +229,7 @@ export class Lore { lostMinerCaves, buildAndPowerGcCount, buildAndPowerSsCount, - ...spellResourceGoal(cavern), + ...spellResourceGoal(cavern.objectives), }; } diff --git a/src/core/lore/utils/list.ts b/src/core/lore/utils/list.ts index 0175d5c..7e67073 100644 --- a/src/core/lore/utils/list.ts +++ b/src/core/lore/utils/list.ts @@ -1,14 +1,20 @@ import { BaseState, PgArgs, PgNodeArgs, TextFn } from "./base"; -import { PgNode } from "./builder"; +import { PgNode, PhraseGraph } from "./builder"; -export function listOfAny({pg, separator = ',', conjunction = 'and'}: {pg: PgArgs['pg'], separator?: string, conjunction?: string}, arg: PgNodeArgs[number], ...args: PgNodeArgs) { - const sepFn: TextFn = ({chosen, index}) => { +export function listJoiner(separator = ',', conjunction = 'and') { + const result: TextFn = ({chosen, index}) => { for (let i = index + 1; i < chosen.length; i++) { - if (chosen[i] === sepFn) { + if (chosen[i] === result) { return separator; } } return conjunction; }; - return args.reduce((r: PgNode, n) => pg(r, n, r.then(sepFn).then(n)), pg(arg)); + return result; +} + +export function listOfAny({pg, joiner}: { + pg: PgArgs['pg'], joiner?: PgNodeArgs[number]}, arg: PgNodeArgs[number], ...args: PgNodeArgs) { + const j = joiner || listJoiner(); + return args.reduce((r: PgNode, n) => pg(r, n, r.then(j).then(n)), pg(arg)); } \ No newline at end of file diff --git a/src/core/models/architect.ts b/src/core/models/architect.ts index 499c3f0..3e6879f 100644 --- a/src/core/models/architect.ts +++ b/src/core/models/architect.ts @@ -159,7 +159,7 @@ export type BaseArchitect = { * If no plans return a sufficient objective, a "collect N crystals" * objective will be added. */ - objectives(args: { + objectives?(args: { cavern: DiscoveredCavern; }): (Partial & { sufficient: boolean }) | undefined; diff --git a/src/core/models/tiles.ts b/src/core/models/tiles.ts index b45d781..47eec50 100644 --- a/src/core/models/tiles.ts +++ b/src/core/models/tiles.ts @@ -42,10 +42,10 @@ const TILES = { WASTE_LOOSE_ROCK: {id: 30, name: "Loose Rock (Waste)", canLandslide: true, crystalYield: 0, hardness: Hardness.LOOSE, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 0, trigger: 'waste' }, WASTE_HARD_ROCK: {id: 34, name: "Hard Rock (Waste)", canLandslide: true, crystalYield: 0, hardness: Hardness.HARD, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 0, trigger: 'waste' }, WASTE_SOLID_ROCK: {id: 38, name: "Solid Rock (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.SOLID, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 0, trigger: 'waste' }, - WASTE_RUBBLE_4: {id: 60, name: "Rubble 4 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, - WASTE_RUBBLE_3: {id: 61, name: "Rubble 3 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, - WASTE_RUBBLE_2: {id: 62, name: "Rubble 2 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, - WASTE_RUBBLE_1: {id: 63, name: "Rubble 1 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, + WASTE_RUBBLE_1: {id: 60, name: "Rubble 1 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, + WASTE_RUBBLE_2: {id: 61, name: "Rubble 2 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, + WASTE_RUBBLE_3: {id: 62, name: "Rubble 3 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, + WASTE_RUBBLE_4: {id: 63, name: "Rubble 4 (Waste)", canLandslide: false, crystalYield: 0, hardness: Hardness.RUBBLE, isFluid: false, isWall: false, maxSlope: undefined, oreYield: 0, trigger: null }, CRYSTAL_SEAM: {id: 42, name: "Energy Crystal Seam", canLandslide: false, crystalYield: 4, hardness: Hardness.SEAM, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 4, trigger: null }, ORE_SEAM: {id: 46, name: "Ore Seam", canLandslide: false, crystalYield: 0, hardness: Hardness.SEAM, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 8, trigger: null }, RECHARGE_SEAM: {id: 50, name: "Recharge Seam", canLandslide: false, crystalYield: 0, hardness: Hardness.SOLID, isFluid: false, isWall: true, maxSlope: undefined, oreYield: 0, trigger: null }, diff --git a/src/core/transformers/01_planning/03_anchor.ts b/src/core/transformers/01_planning/03_anchor.ts index bb8d11f..89d7372 100644 --- a/src/core/transformers/01_planning/03_anchor.ts +++ b/src/core/transformers/01_planning/03_anchor.ts @@ -17,19 +17,32 @@ export type AnchoredCavern = WithPlanType< export default function anchor(cavern: FloodedCavern): AnchoredCavern { const architects = encourageDisable(ARCHITECTS, cavern); + const gravities: number[] = cavern.plans + .map((plan) => { + if (plan.kind === "cave") { + return Math.pow( + Math.min(...plan.path.baseplates.map(bp => Math.hypot(...bp.center))), + -cavern.context.anchorGravity); + } + return 1; + }); + + console.log(gravities); + // Choose a spawn and an architect for that spawn. const anchor = cavern.dice.pickSpawn.weightedChoice( architects - .filter((architect) => architect.anchorBid) - .flatMap((architect) => - cavern.plans + .flatMap((architect) => { + if (!architect.anchorBid) { + return []; + } + return cavern.plans .filter((p) => p.kind === "cave") .map((plan) => ({ item: { ...plan, architect, hops: [] }, - bid: architect.anchorBid!({ cavern, plan }) || 0, - })), - ), - ); + bid: (architect.anchorBid!({ cavern, plan }) || 0) * gravities[plan.id], + })); + })); const plans: (FloodedPlan | OrderedPlan)[] = [...cavern.plans]; plans[anchor.id] = anchor; diff --git a/src/core/transformers/01_planning/04_mod.ts b/src/core/transformers/01_planning/04_mod.ts index 2d4503c..cf8a734 100644 --- a/src/core/transformers/01_planning/04_mod.ts +++ b/src/core/transformers/01_planning/04_mod.ts @@ -1,6 +1,8 @@ import { AnchoredCavern, OrderedPlan } from "./03_anchor"; -export type ModdedCavern = AnchoredCavern; +export type ModdedCavern = AnchoredCavern & { + readonly oxygen?: null | readonly [number, number]; +}; // In this step, the anchor architect has carte blanche to change any aspect of // the cavern before any plans are established. Use with caution. diff --git a/src/core/transformers/04_ephemera/00_aerate.ts b/src/core/transformers/04_ephemera/00_aerate.ts index 84960be..93bd4bc 100644 --- a/src/core/transformers/04_ephemera/00_aerate.ts +++ b/src/core/transformers/04_ephemera/00_aerate.ts @@ -59,6 +59,9 @@ function getOrigin(cavern: PopulatedCavern): Point { } export default function aerate(cavern: PopulatedCavern): AeratedCavern { + if (cavern.oxygen !== undefined) { + return {...cavern, oxygen: cavern.oxygen, aerationLog: null}; + } if (!cavern.context.hasAirLimit) { return { ...cavern, oxygen: null, aerationLog: null }; } diff --git a/src/core/transformers/04_ephemera/01_adjure.ts b/src/core/transformers/04_ephemera/01_adjure.ts index c0bb7b3..dab031c 100644 --- a/src/core/transformers/04_ephemera/01_adjure.ts +++ b/src/core/transformers/04_ephemera/01_adjure.ts @@ -9,9 +9,9 @@ export type AdjuredCavern = AeratedCavern & { export default function adjure(cavern: AeratedCavern): AdjuredCavern { const objectives = cavern.plans - .reduce((r: Architect["objectives"][], plan) => { + .reduce((r: NonNullable["objectives"]>[], plan) => { const fn = plan.architect.objectives; - if (!r.some((f) => Object.is(fn, f))) { + if (fn && !r.some((f) => Object.is(fn, f))) { r.push(fn); } return r; diff --git a/src/core/transformers/README.md b/src/core/transformers/README.md index f707f23..e162fe5 100644 --- a/src/core/transformers/README.md +++ b/src/core/transformers/README.md @@ -2,13 +2,13 @@ This directory contains all of the individual steps to "transform" a cavern from # 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. +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 produce 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. _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. @@ -22,7 +22,7 @@ Create "plans" for the baseplates and paths that will determine how the space wi 1. _Anchor_: Choose which plan will be the anchor and assign an architect to it. 1. _Mod_: The anchor architect has a chance to modify the cavern in any way. 1. _Establish_: Perform a breadth-first search of all plans, starting with the anchor. Assign architects and 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. _Pearl_: Create "pearls" that determine exactly where plans will go. This is the step that ensures the caves and halls will have more "natural" looking shapes. # III. Masonry @@ -35,6 +35,7 @@ Place tiles and a few other related things in the map. After this phase, tiles m 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. +1. _Closer_: The anchor architect has a final chance to modify tiles. # IV. Plastic diff --git a/src/webui/App.tsx b/src/webui/App.tsx index a1c96cc..fc1d3c7 100644 --- a/src/webui/App.tsx +++ b/src/webui/App.tsx @@ -30,6 +30,7 @@ const MAP_OVERLAY_BUTTONS: readonly { { of: "landslides", label: "Landslides", enabled: (c) => !!c?.landslides }, { of: "erosion", label: "Erosion", enabled: (c) => !!c?.erosion }, { of: "oxygen", label: "Oxygen", enabled: (c) => c?.oxygen !== undefined }, + { of: "objectives", label: "Objectives", enabled: (c) => !!c?.objectives }, { of: "lore", label: "Lore", enabled: (c) => !!c?.lore }, { of: "script", label: "Script", enabled: (c) => !!c?.script }, { of: "about", label: "About", enabled: (c) => true }, @@ -113,7 +114,7 @@ function App() {
diff --git a/src/webui/components/context_editor/architects.tsx b/src/webui/components/context_editor/architects.tsx index 026bfb3..fb716c9 100644 --- a/src/webui/components/context_editor/architects.tsx +++ b/src/webui/components/context_editor/architects.tsx @@ -1,9 +1,11 @@ import { UpdateData } from "./controls"; import { ARCHITECTS } from "../../../core/architects"; import styles from "./style.module.scss"; -import React from "react"; +import React, { useMemo } from "react"; +import { Cavern } from "../../../core/models/cavern"; +import { Plan } from "../../../core/models/plan"; -export const ArchitectsInput = ({ update, initialContext }: UpdateData) => { +export const ArchitectsInput = ({ update, initialContext, cavern }: UpdateData & {cavern: Cavern | undefined}) => { function updateArchitects( key: string, value: "encourage" | "disable" | undefined, @@ -25,6 +27,13 @@ export const ArchitectsInput = ({ update, initialContext }: UpdateData) => { update({ architects: r }); } + const used = useMemo(() => { + const r: {[K: string]: number} = {}; + (cavern?.plans as Partial>[] | undefined)?.forEach( + plan => plan.architect && (r[plan.architect.name] = (r[plan.architect.name] ?? 0) + 1)); + return r; + }, [cavern]); + return [...ARCHITECTS].map((a) => { const state = initialContext.architects?.[a.name]; return ( @@ -53,6 +62,15 @@ export const ArchitectsInput = ({ update, initialContext }: UpdateData) => { > Disable + {(state === "encourage" && !used[a.name]) ? ( +
+ warning +
+ ) : ( +
+ {used[a.name]} +
+ )} ); diff --git a/src/webui/components/context_editor/index.tsx b/src/webui/components/context_editor/index.tsx index 6d1324f..3b47fba 100644 --- a/src/webui/components/context_editor/index.tsx +++ b/src/webui/components/context_editor/index.tsx @@ -5,6 +5,7 @@ import styles from "./style.module.scss"; import { Choice, CurveSliders, Slider } from "./controls"; import { ArchitectsInput } from "./architects"; import { PartialCavernContext } from "../../../core/common/context"; +import { Cavern } from "../../../core/models/cavern"; const INITIAL_SEED = Date.now() % MAX_PLUS_ONE; @@ -36,17 +37,18 @@ const expectedTotalPlans = (contextWithDefaults: CavernContext) => { export function CavernContextInput({ initialContext, - context, + cavern, setInitialContext, }: { initialContext: PartialCavernContext; - context: CavernContext | undefined; + cavern: Cavern | undefined; setInitialContext: React.Dispatch>; }) { return ( ); @@ -55,10 +57,12 @@ export function CavernContextInput({ function CavernContextInputInner({ initialContext, context, + cavern, setInitialContext, }: { initialContext: PartialCavernContext; context: CavernContext; + cavern: Cavern | undefined; setInitialContext: React.Dispatch>; }) { const [showAdvanced, setShowAdvanced] = useState(false); @@ -231,6 +235,16 @@ function CavernContextInputInner({ {...rest} /> +
+

Anchor

+ +

Establish

{( @@ -258,7 +272,7 @@ function CavernContextInputInner({ step={0.25} {...rest} /> - +

Pearl

diff --git a/src/webui/components/context_editor/style.module.scss b/src/webui/components/context_editor/style.module.scss index 4ecbe23..569e60b 100644 --- a/src/webui/components/context_editor/style.module.scss +++ b/src/webui/components/context_editor/style.module.scss @@ -118,6 +118,12 @@ font-size: 24px; } + .architectCount { + border: none; + flex-shrink: 0; + width: 36px; + } + .showAdvanced { display: flex; justify-content: center; diff --git a/src/webui/components/map_preview/index.tsx b/src/webui/components/map_preview/index.tsx index 9996e75..18477aa 100644 --- a/src/webui/components/map_preview/index.tsx +++ b/src/webui/components/map_preview/index.tsx @@ -18,6 +18,7 @@ import HeightPreview from "./height"; import Stats from "./stats"; import PlansPreview from "./plan"; import ScriptPreview, { ScriptOverlay } from "./script_preview"; +import ObjectivesPreview from "./objectives"; export type MapOverlay = | "about" @@ -28,6 +29,7 @@ export type MapOverlay = | "height" | "landslides" | "lore" + | "objectives" | "ore" | "overview" | "oxygen" @@ -199,6 +201,9 @@ export default function CavernPreview({ cavern.openCaveFlags?.map((_, x, y) => ( ))} + {mapOverlay === "objectives" && ( + + )} {mapOverlay === "script" && ( + { + cavern.anchor && (() => { + const [x, y] = cavern.plans![cavern.anchor].path.baseplates[0].center; + return + })() + } + { + cavern.plans?.map((plan: Partial>) => { + if (plan.architect?.objectives) { + const [x, y] = plan.path!.baseplates[0].center; + return ( + + + + ) + } + return null; + }) + } + + ) +} \ No newline at end of file diff --git a/src/webui/components/map_preview/stats.tsx b/src/webui/components/map_preview/stats.tsx index 9c53e6b..3e6e002 100644 --- a/src/webui/components/map_preview/stats.tsx +++ b/src/webui/components/map_preview/stats.tsx @@ -10,6 +10,7 @@ import { Tile } from "../../../core/models/tiles"; import { Building } from "../../../core/models/building"; import { Vehicle } from "../../../core/models/vehicle"; import { Creature } from "../../../core/models/creature"; +import { spellResourceGoal } from "../../../core/lore/lore"; function EntitySummary({ entities, @@ -166,6 +167,22 @@ export default function Stats({ case "oxygen": { return

Oxygen: {cavern.oxygen?.join("/") ?? "Infinity"}

; } + case "objectives": { + if (!cavern.objectives) { + return

none

+ } + const resourceGoal = spellResourceGoal(cavern.objectives).resourceGoalNumbers; + return (
    + { + cavern.objectives.variables.map(({description}) => ( +
  • {description}
  • + )) + } + { + resourceGoal &&
  • Collect {resourceGoal}.
  • + } +
) + } default: return null; } diff --git a/src/webui/components/map_preview/style.module.scss b/src/webui/components/map_preview/style.module.scss index 26a3b65..f63519d 100644 --- a/src/webui/components/map_preview/style.module.scss +++ b/src/webui/components/map_preview/style.module.scss @@ -276,6 +276,19 @@ stroke: red; fill: white; } + + .objectives { + .origin { + stroke: white; + stroke-width: 3px; + fill: none; + } + .objective > path { + stroke: white; + stroke-width: 3px; + fill: none; + } + } } } diff --git a/src/webui/components/map_preview/tiles.tsx b/src/webui/components/map_preview/tiles.tsx index ce1b049..34e5c1e 100644 --- a/src/webui/components/map_preview/tiles.tsx +++ b/src/webui/components/map_preview/tiles.tsx @@ -104,6 +104,15 @@ function getFill( } return t.isWall ? tk(t) : "oxex"; } + case "objectives": { + if (cavern.objectives?.crystals && (t.crystalYield > 0 || cavern.crystals?.get(x, y))) { + return tk(Tile.CRYSTAL_SEAM); + } + if ((cavern.objectives?.ore || cavern.objectives?.studs) && (t.oreYield > 0 || cavern.ore?.get(x, y))) { + return tk(Tile.ORE_SEAM); + } + return dk(t); + } case "script": return dk(t); }