From e5c59cd0c163ccb6df1d195dcf855febfcdb3074 Mon Sep 17 00:00:00 2001 From: charredUtensil Date: Sun, 10 Nov 2024 12:47:29 -0500 Subject: [PATCH] blackout and mob farm updates --- src/core/architects/blackout.ts | 20 ++- src/core/architects/build_and_power.ts | 2 + src/core/architects/established_hq/lost.ts | 6 +- src/core/architects/index.ts | 5 +- src/core/architects/lost_miners.ts | 18 ++- src/core/architects/mob_farm.ts | 101 ++++++++++---- .../architects/utils/creature_spawners.ts | 29 ++--- src/core/architects/utils/script.test.ts | 17 ++- src/core/architects/utils/script.ts | 14 +- src/core/common/prng.ts | 1 + src/core/lore/graphs/completeness.test.ts | 3 +- src/core/lore/graphs/events.ts | 24 ++++ src/core/lore/graphs/premise.ts | 52 +++++++- src/core/lore/lore.ts | 2 + src/core/transformers/02_masonry/02_brace.ts | 123 ++++++++++++------ 15 files changed, 299 insertions(+), 118 deletions(-) diff --git a/src/core/architects/blackout.ts b/src/core/architects/blackout.ts index 1d48c92..68a37f0 100644 --- a/src/core/architects/blackout.ts +++ b/src/core/architects/blackout.ts @@ -1,5 +1,6 @@ +import { inferContextDefaults } from "../common"; import { BLACKOUT_START, BLACKOUT_END } from "../lore/graphs/events"; -import { Architect } from "../models/architect"; +import { Architect, BaseMetadata } from "../models/architect"; import { DefaultSpawnArchitect, PartialArchitect } from "./default"; import { mkRough, Rough } from "./utils/rough"; import { eventChain, mkVars, scriptFragment } from "./utils/script"; @@ -11,8 +12,19 @@ const MIN_AIR = 500; const RESET_SECONDS = 120; const RESET_END = 999; -const BASE: PartialArchitect = { +const METADATA = {tag:'blackout'} as const satisfies BaseMetadata; + +const BASE: PartialArchitect = { ...DefaultSpawnArchitect, + prime: () => METADATA, + mod(cavern) { + const context = inferContextDefaults({ + caveHasRechargeSeamChance: 0.15, + hallHasRechargeSeamChance: 0.15, + ...cavern.initialContext + }) + return {...cavern, context} + }, script({cavern, plan, sh}) { const v = mkVars(`p${plan.id}Bo`, [ 'crystalBank', @@ -98,7 +110,7 @@ const BLACKOUT = [ plan.lakeSize >= 3 && plan.pearlRadius > 0 && !cavern.context.hasSlugs && - 0.4, + 0.03, }, -] as const satisfies readonly Architect[]; +] as const satisfies readonly Architect[]; export default BLACKOUT; diff --git a/src/core/architects/build_and_power.ts b/src/core/architects/build_and_power.ts index 3efb37d..e97178e 100644 --- a/src/core/architects/build_and_power.ts +++ b/src/core/architects/build_and_power.ts @@ -255,7 +255,9 @@ export const BUILD_AND_POWER = [ plan.pearlRadius > 2 && plan.pearlRadius < 10 && plan.path.baseplates.length === 1 && + // Incompatible with fchq or mob farm !(amd?.tag === "hq" && amd.fixedComplete) && + !(amd?.tag === "mobFarm") && intersectsOnly(plans, plan, null) && hops.length > 5 && !hops.some((h) => { diff --git a/src/core/architects/established_hq/lost.ts b/src/core/architects/established_hq/lost.ts index 64c66c2..d824583 100644 --- a/src/core/architects/established_hq/lost.ts +++ b/src/core/architects/established_hq/lost.ts @@ -75,10 +75,11 @@ const LOST = [ ...LOST_BASE, prime: getPrime(15, false), placeBuildings: getPlaceBuildings({}), - caveBid: ({ plan, hops, plans }) => + caveBid: ({ cavern, plan, hops, plans }) => !plan.fluid && plan.pearlRadius > 5 && hops.length <= MAX_HOPS && + plans[cavern.anchor]?.metadata?.tag !== 'mobFarm' && !hops.some((id) => plans[id].fluid) && !plans.some((p) => p.metadata?.tag === "hq") && 0.5, @@ -90,10 +91,11 @@ const LOST = [ prime: getPrime(15, true), placeBuildings: getPlaceBuildings({ from: 3 }), placeLandslides: (args) => placeLandslides({ min: 15, max: 100 }, args), - caveBid: ({ plan, hops, plans }) => + caveBid: ({ cavern, plan, hops, plans }) => !plan.fluid && plan.pearlRadius > 6 && hops.length <= MAX_HOPS && + plans[cavern.anchor]?.metadata?.tag !== 'mobFarm' && !plans.some((p) => p.metadata?.tag === "hq") && (plans[hops[0]].metadata?.tag === "nomads" ? 5 : 0.5), }, diff --git a/src/core/architects/index.ts b/src/core/architects/index.ts index 75d6ed5..b27d27a 100644 --- a/src/core/architects/index.ts +++ b/src/core/architects/index.ts @@ -14,7 +14,7 @@ import SLUGS from "./slugs"; import THIN_HALL from "./thin_hall"; import TREASURE from "./treasure"; import BLACKOUT from "./blackout"; -import MOB_FARM from "./mob_farm"; +import MOB_FARM, { MobFarmMetadata } from "./mob_farm"; export type AnyMetadata = | undefined @@ -22,7 +22,8 @@ export type AnyMetadata = | HqMetadata | LostMinersMetadata | NomadsMetadata - | { tag: "mobFarm" | "seismic" | "slugNest" | "treasure" }; + | MobFarmMetadata + | { tag: 'blackout' | "seismic" | "slugNest" | "treasure" }; export const ARCHITECTS = [ ...BLACKOUT, diff --git a/src/core/architects/lost_miners.ts b/src/core/architects/lost_miners.ts index 71dc02f..ffe56bd 100644 --- a/src/core/architects/lost_miners.ts +++ b/src/core/architects/lost_miners.ts @@ -16,6 +16,7 @@ import { SMALL_DIGGER, SMALL_TRANSPORT_TRUCK, TUNNEL_SCOUT, + SMLC, } from "../models/vehicle"; import { DiscoveredCavern } from "../transformers/03_plastic/01_discover"; import { StrataformedCavern } from "../transformers/03_plastic/02_strataform"; @@ -38,6 +39,7 @@ import { FOUND_LOST_MINERS, } from "../lore/graphs/events"; import { gObjectives } from "./utils/objectives"; +import { filterTruthy } from "../common/utils"; export type LostMinersMetadata = { readonly tag: "lostMiners"; @@ -103,14 +105,16 @@ function placeBreadcrumbVehicles( ): Vehicle[] { const tile = cavern.tiles.get(x, y); const fluid = tile === Tile.LAVA || tile === Tile.WATER ? tile : null; - const template = rng.weightedChoice([ - { item: HOVER_SCOUT, bid: fluid ? 0 : 2 }, - { item: SMALL_DIGGER, bid: fluid ? 0 : 0.5 }, - { item: SMALL_TRANSPORT_TRUCK, bid: fluid ? 0 : 0.75 }, - { item: RAPID_RIDER, bid: fluid === Tile.WATER ? 1 : 0 }, - { item: TUNNEL_SCOUT, bid: 0.25 }, + const isMobFarm = cavern.plans[cavern.anchor].metadata?.tag === 'mobFarm'; + const template = rng.weightedChoice(filterTruthy([ + !fluid && !isMobFarm && { item: HOVER_SCOUT, bid: 2 }, + !fluid && { item: SMALL_DIGGER, bid: 0.5 }, + !fluid && { item: SMALL_TRANSPORT_TRUCK, bid: 0.75 }, + !fluid && { item: SMLC, bid: 0.05}, + !isMobFarm && fluid === Tile.WATER && { item: RAPID_RIDER, bid: 1 }, + !isMobFarm && { item: TUNNEL_SCOUT, bid: 0.25 }, { item: null, bid: 0.0025 }, - ]); + ])); if (template) { return [ vehicleFactory.create({ diff --git a/src/core/architects/mob_farm.ts b/src/core/architects/mob_farm.ts index 2f19a21..6ed52a6 100644 --- a/src/core/architects/mob_farm.ts +++ b/src/core/architects/mob_farm.ts @@ -1,16 +1,19 @@ -import { Architect, BaseMetadata } from "../models/architect"; +import { Architect } from "../models/architect"; import { Tile } from "../models/tiles"; import { DefaultSpawnArchitect, PartialArchitect } from "./default"; import { mkRough, Rough, weightedSprinkle } from "./utils/rough"; import { monsterSpawnScript } from "./utils/creature_spawners"; import { getBuildings } from "./utils/buildings"; -import { DOCKS, SUPER_TELEPORT, TOOL_STORE } from "../models/building"; +import { DOCKS, MINING_LASER, SUPER_TELEPORT, TOOL_STORE } from "../models/building"; import { position, randomlyInTile } from "../models/position"; import { asXY, closestTo, NSEW, offsetBy, Point } from "../common/geometry"; -import { CARGO_CARRIER, CHROME_CRUSHER, GRANITE_GRINDER, HOVER_SCOUT, LMLC, LOADER_DOZER, RAPID_RIDER, TUNNEL_SCOUT, TUNNEL_TRANSPORT } from "../models/vehicle"; +import { CARGO_CARRIER, CHROME_CRUSHER, GRANITE_GRINDER, HOVER_SCOUT, LMLC, LOADER_DOZER, RAPID_RIDER, SMLC, TUNNEL_SCOUT, TUNNEL_TRANSPORT } from "../models/vehicle"; import { getPlaceRechargeSeams, sprinkleCrystals } from "./utils/resources"; import { inferContextDefaults } from "../common"; -import { scriptFragment } from "./utils/script"; +import { mkVars, scriptFragment } from "./utils/script"; +import { HINT_SELECT_LASER_GROUP, MOB_FARM_NO_LONGER_BLOCKING } from "../lore/graphs/events"; +import { gObjectives } from "./utils/objectives"; +import { PreprogrammedCavern } from "../transformers/04_ephemera/03_preprogram"; const BANLIST = [ DOCKS, @@ -21,27 +24,37 @@ const BANLIST = [ LOADER_DOZER, GRANITE_GRINDER, CARGO_CARRIER, + LMLC, CHROME_CRUSHER, TUNNEL_TRANSPORT, ] as const; -const METADATA = { +export type MobFarmMetadata = { tag: "mobFarm", -} as const satisfies BaseMetadata; + hoardSize: number, +}; + +function totalAccessibleWalls({aerationLog, tiles}: PreprogrammedCavern) { + let result = 0; + aerationLog?.forEach((_, x, y) => tiles.get(x, y)?.isWall && result++); + return result; +} -const BASE: PartialArchitect = { +const BASE: PartialArchitect = { ...DefaultSpawnArchitect, - prime: () => METADATA, + prime: ({cavern, plan}) => ({tag: "mobFarm", hoardSize: cavern.dice.prime(plan.id).betaInt({a: 4, b: 4, min: 170, max: 230})}), mod(cavern) { const context = inferContextDefaults({ caveCrystalRichness: { base: -0.16, hops: 0.32, order: 0.32 }, hallCrystalRichness: { base: 0, hops: 0, order: 0 }, + caveCrystalSeamBias: 0.7, + globalHostilesCap: 10, ...cavern.initialContext }) return {...cavern, context} }, - crystalsToPlace: () => 200, - crystalsFromMetadata: () => 8, + crystalsToPlace: ({ plan }) => Math.max(plan.crystalRichness * plan.perimeter, 9), + crystalsFromMetadata: (metadata) => 4 + LMLC.crystals + metadata.hoardSize, placeRechargeSeam: getPlaceRechargeSeams(3), placeBuildings(args) { const [toolStore] = getBuildings({ @@ -64,11 +77,12 @@ const BASE: PartialArchitect = { cameraPosition: position({ ...asXY(args.plan.innerPearl[0][0]), aimedAt: [toolStore.x, toolStore.y], - pitch: Math.PI / 8, + pitch: Math.PI / 3, }) } }, placeCrystals(args) { + sprinkleCrystals(args); const tiles = args.plan.innerPearl.flatMap((ly, i) => i <= 2 ? ly : []).filter(pos => { const t = args.tiles.get(...pos); return t && !t.isWall && !t.isFluid; @@ -77,40 +91,81 @@ const BASE: PartialArchitect = { sprinkleCrystals(args, { getRandomTile: () => rng.betaChoice(tiles, {a: 1, b: 2.5}), seamBias: 0, + count: args.plan.metadata.hoardSize, }); }, placeSlugHoles() {}, placeLandslides() {}, - placeEntities({cavern, plan, vehicleFactory}) { + placeEntities({cavern, plan, minerFactory, vehicleFactory}) { const rng = cavern.dice.placeEntities(plan.id); const ts = cavern.buildings.find(b => cavern.pearlInnerDex.get(...b.foundation[0])?.[plan.id])!; const tiles = NSEW.map(oPos => offsetBy(ts.foundation[0], oPos)).filter(pos => { const t = cavern.tiles.get(...pos); return t && !t.isWall && !t.isFluid; }) + const pos = randomlyInTile({...asXY(rng.uniformChoice(tiles)), rng}); + const driver = minerFactory.create({ + loadout: ['Drill', 'JobDriver', 'JobEngineer'], + planId: plan.id, + ...pos, + }) const lmlc = vehicleFactory.create({ template: LMLC, upgrades: ['UpLaser'], planId: plan.id, - ...randomlyInTile({...asXY(rng.uniformChoice(tiles)), rng}), + driverId: driver.id, + ...pos, }); return { - vehicles: [lmlc] + miners: [driver], + vehicles: [lmlc], } }, objectives({cavern}) { - const crystals = cavern.plans[cavern.anchor].crystals * 0.75 + const crystals = cavern.plans[cavern.anchor].crystals * 0.6; return { crystals: Math.floor(crystals / 5) * 5, sufficient: true }; }, - scriptGlobals({cavern, sh}) { + script({cavern, plan, sh}) { + const v = mkVars(`p${plan.id}MF`, ['hintGroup', 'msgHintGroup', 'msgNotBlocking']) + const rng = cavern.dice.script(plan.id) return scriptFragment( '# Globals: Mob Farm', sh.trigger( 'if(time:0)', ...BANLIST.map(t => `disable:${t.id};` satisfies `${string};`) ), + cavern.objectives.variables.length > 0 && scriptFragment( + sh.declareString(v.msgNotBlocking, {rng, pg: MOB_FARM_NO_LONGER_BLOCKING}), + sh.trigger( + // There's a good chance any further objectives are softlocked by the + // inability to cross lakes and rivers - so unlock them. + `if(crystals>=${cavern.objectives.crystals})`, + `wait:5;`, + ...BANLIST.map(t => `enable:${t.id};` satisfies `${string};`), + `((${gObjectives.won}==0))msg:${v.msgNotBlocking};`, + ), + ), + // Hint to tell players about control groups. This isn't super annoying + // under normal circumstances, but here it's almost a necessity that the + // player have their lasers bound to a single key. + sh.declareInt(v.hintGroup, 0), + sh.trigger( + `when(${MINING_LASER.id}.click)`, + `((${MINING_LASER.id}<2))return;`, + `${v.hintGroup}=1;`, + ), + sh.trigger( + `when(${SMLC.id}.click)`, + `((${SMLC.id}<2))return;`, + `${v.hintGroup}=1;`, + ), + sh.declareString(v.msgHintGroup, HINT_SELECT_LASER_GROUP), + sh.trigger( + `if(${v.hintGroup}>0)`, + `msg:${v.msgHintGroup};`, + ), ); }, monsterSpawnScript: (args) => monsterSpawnScript(args, { @@ -120,11 +175,6 @@ const BASE: PartialArchitect = { }) }; -// TODO: -// Give crystals - either as a seam or just give them initially -// Lore to indicate the point of the level -// Disable flying vehicles (all vehicles but STT?) - const MOB_FARM = [ { name: "MobFarm.Water", @@ -151,9 +201,12 @@ const MOB_FARM = [ plan.fluid === Tile.WATER && plan.pearlRadius > 6 && plan.path.baseplates.length === 1 && + plan.intersects.some((_, i) => { + const p = cavern.plans[i]; + return !p.fluid && p.lakeSize >= 6; + }) && plan.intersects.reduce((r, _, i) => cavern.plans[i].fluid ? r + 1 : r, 0) <= 1 && - cavern.plans.reduce((r, p) => p.fluid ? r + 1 : r, 0) <= 5 && - 1, + 2, }, -] as const satisfies readonly Architect[]; +] as const satisfies readonly Architect[]; export default MOB_FARM; diff --git a/src/core/architects/utils/creature_spawners.ts b/src/core/architects/utils/creature_spawners.ts index 27d4560..f23bf40 100644 --- a/src/core/architects/utils/creature_spawners.ts +++ b/src/core/architects/utils/creature_spawners.ts @@ -12,8 +12,8 @@ import { Plan } from "../../models/plan"; import { PreprogrammedCavern } from "../../transformers/04_ephemera/03_preprogram"; import { getDiscoveryPoint } from "./discovery"; import { - check, eventChain, + EventChainLine, mkVars, scriptFragment, ScriptHelper, @@ -173,7 +173,6 @@ function creatureSpawnScript( const v = mkVars(`p${plan.id}${opts.creature.inspectAbbrev}Sp`, [ "arm", "doCooldown", - "doArm", "doTrip", "doSpawn", "hoardTrip", @@ -207,14 +206,18 @@ function creatureSpawnScript( scriptFragment( // Arm sh.declareInt(v.arm, ArmState.DISARMED), - !opts.armEvent && `${getArmTrigger(cavern, plan)}[${v.doArm}]`, - eventChain( - opts.armEvent ?? v.doArm, - opts.initialCooldown && - `wait:random(${opts.initialCooldown.min.toFixed(2)})(${opts.initialCooldown.max.toFixed(2)});`, - `${v.arm}=${ArmState.ARMED};`, - opts.tripOnArmed && `${v.doTrip};`, - ), + (() => { + const body: EventChainLine[] = [ + opts.initialCooldown && + `wait:random(${opts.initialCooldown.min.toFixed(2)})(${opts.initialCooldown.max.toFixed(2)});`, + `${v.arm}=${ArmState.ARMED};`, + opts.tripOnArmed && `${v.doTrip};`, + ]; + if (opts.armEvent) { + return eventChain(opts.armEvent, ...body); + } + return sh.trigger(getArmTrigger(cavern, plan), ...body); + })(), // Trip ...(opts.tripPoints ?? getTriggerPoints(cavern, plan)).map( @@ -235,11 +238,7 @@ function creatureSpawnScript( `((crystals<${opts.needCrystals.increment ? v.needCrystals : opts.needCrystals.base}))return;`, cavern.context.globalHostilesCooldown > 0 && `((${gCreatures.globalCooldown}>0))return;`, - check( - `${v.arm}==${ArmState.ARMED}`, - `${v.arm}=${ArmState.FIRE}`, - opts.reArmMode !== "none" && opts.tripOnArmed === 'always' && `${v.arm}=${v.doCooldown}`, - ), + `((${v.arm}==${ArmState.ARMED}))${v.arm}=${ArmState.FIRE};`, ), `when(${v.arm}==${ArmState.FIRE})[${v.doSpawn}]`, ), diff --git a/src/core/architects/utils/script.test.ts b/src/core/architects/utils/script.test.ts index 1840f17..8cb1aee 100644 --- a/src/core/architects/utils/script.test.ts +++ b/src/core/architects/utils/script.test.ts @@ -1,20 +1,23 @@ -import { escapeString } from "./script"; +import { sanitizeString } from "./script"; describe("escapeString", () => { it("handles the empty string", () => { - expect(escapeString("")).toBe(""); + expect(sanitizeString("")).toBe(""); }); it("handles a normal string", () => { - expect(escapeString("A landslide has occurred!")).toBe( + expect(sanitizeString("A landslide has occurred!")).toBe( "A landslide has occurred!", ); }); - it("escapes quotes", () => { - expect(escapeString('An "Energy Crystal" has been found!')).toBe( - 'An \\"Energy Crystal\\" has been found!', + it("removes quotes", () => { + expect(sanitizeString('An "Energy Crystal" has been found!')).toBe( + 'An Energy Crystal has been found!', ); }); it("removes backslashes", () => { - expect(escapeString("\\n\\n\\n\\")).toBe("nnn"); + expect(sanitizeString("\\n\\n\\n\\")).toBe("nnn"); + }); + it("removes newlines", () => { + expect(sanitizeString("one\ntwo\n three \n \n \n four")).toBe("one two three four"); }); }); diff --git a/src/core/architects/utils/script.ts b/src/core/architects/utils/script.ts index c0e2460..45d4bd0 100644 --- a/src/core/architects/utils/script.ts +++ b/src/core/architects/utils/script.ts @@ -36,19 +36,19 @@ export function scriptFragment(...rest: ScriptLine[]) { return rest.filter((s) => s).join("\n") as any; } -export function check(condition: string, ifTrue: string, ifFalse?: ScriptLine): EventChainLine { +export function check(condition: string, ifTrue: string, ifFalse?: string | Falsy): EventChainLine { if (ifFalse) { - return `((${condition}))${ifTrue};`; + return `((${condition}))[${ifTrue}][${ifFalse}];` } - return `((${condition}))[${ifTrue}][${ifFalse}];` + return `((${condition}))${ifTrue};`; } export function eventChain(name: string, ...rest: EventChainLine[]) { return `${name}::;\n${scriptFragment(...rest)}\n`; } -export function escapeString(s: string) { - return s.replace(/\\/g, "").replace(/"/g, '\\"'); +export function sanitizeString(s: string) { + return s.replace(/[\\"]+/g, "").replace(/\s*\n[\s\n]*/g, ' '); } type DieOrRng = @@ -124,7 +124,7 @@ export class ScriptHelperImpl implements ScriptHelper { }; strVal = value.pg.generate(rng, state as any, formatVars).text; } - return `string ${name}="${escapeString(strVal)}"`; + return `string ${name}="${sanitizeString(strVal)}"`; } /** @@ -132,7 +132,7 @@ export class ScriptHelperImpl implements ScriptHelper { */ trigger(condition: Trigger, ...rest: EventChainLine[]) { const lines = filterTruthy(rest); - if (lines.length === 1) { + if (lines.length === 1 && !lines.some(line => line.includes('\n'))) { return `${condition}[${lines[0].substring(0, lines[0].length - 1)}]`; } const name = `ec${this._uid++}`; diff --git a/src/core/common/prng.ts b/src/core/common/prng.ts index 538e0a2..b75b0f2 100644 --- a/src/core/common/prng.ts +++ b/src/core/common/prng.ts @@ -23,6 +23,7 @@ export class PseudorandomStream { return min + (this.mt() * (max - min)) / MAX_PLUS_ONE; } + // https://mathlets.org/mathlets/beta-distribution/ beta({ a, b, diff --git a/src/core/lore/graphs/completeness.test.ts b/src/core/lore/graphs/completeness.test.ts index d662450..360b97c 100644 --- a/src/core/lore/graphs/completeness.test.ts +++ b/src/core/lore/graphs/completeness.test.ts @@ -47,11 +47,12 @@ function expectCompletion(actual: PhraseGraph) { .then(skip, st("spawnHasErosion")) .then(skip, st("treasureCaveOne", "treasureCaveMany")) .then( - pg(skip, st("spawnIsNomadOne", "spawnIsNomadsTogether", "spawnIsMobFarm")).then( + pg(skip, st("spawnIsNomadOne", "spawnIsNomadsTogether", "spawnIsBlackout")).then( skip, st("findHq").then(skip, st("hqIsRuin")), ), st("spawnIsHq").then(skip, st("hqIsFixedComplete"), st("hqIsRuin")), + st("spawnIsMobFarm"), ) .then( skip, diff --git a/src/core/lore/graphs/events.ts b/src/core/lore/graphs/events.ts index fe0d9b7..e6e66e4 100644 --- a/src/core/lore/graphs/events.ts +++ b/src/core/lore/graphs/events.ts @@ -307,3 +307,27 @@ export const BLACKOUT_END = phraseGraph( .then(end); }, ); + +export const MOB_FARM_NO_LONGER_BLOCKING = phraseGraph( + "Mob Farm no longer blocking", + ({ pg, state, start, end, cut, skip }) => { + start + .then( + "With so many Energy Crystals removed, you should now have no " + + "issues teleporting in the other vehicles.", + ) + .then( + skip, + state("lostMinersOne").then( + "Use them to find that missing Rock Raider!" + ), + state("lostMinersTogether", "lostMinersApart").then( + "Use them to find those missing Rock Raiders!" + )) + .then(end); + }, +); + +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.`; \ No newline at end of file diff --git a/src/core/lore/graphs/premise.ts b/src/core/lore/graphs/premise.ts index aff4728..41e1f4d 100644 --- a/src/core/lore/graphs/premise.ts +++ b/src/core/lore/graphs/premise.ts @@ -40,10 +40,19 @@ const PREMISE = phraseGraph( const additionalHardship = (() => { const spawnHasErosion = state("spawnHasErosion").then( "we are dangerously close to a cavern full of lava", - "we are concerned about nearby lava flows that could engulf this cavern", - "you will need to keep an eye on the volcanic activity in this cavern to avoid being buried in lava", + "we are concerned about nearby lava flows that could engulf this " + + "cavern", + "you will need to keep an eye on the volcanic activity in this " + + "cavern to avoid being buried in lava", ); + const blackout = state("spawnIsBlackout").then( + "the unusual magnetic properties of the rock here might interfere " + + "with our equipment", + "there are unusual magnetic readings in this cavern and we're " + + "concerned about the effects that might have on our equipment" + ) + const hasMonstersTexts = pg( state("hasMonsters").then(skip, state("hasSlugs")), state("hasSlugs"), @@ -70,8 +79,8 @@ const PREMISE = phraseGraph( "you must make do with the buildings that are already constructed.", ); - spawnHasErosion.then(", and").then(hasMonstersTexts); - return pg(spawnHasErosion, hasMonstersTexts, hqIsFixedComplete.then(end)) + pg(spawnHasErosion, blackout.then(skip, state("spawnHasErosion"))).then(", and").then(hasMonstersTexts); + return pg(blackout, spawnHasErosion, hasMonstersTexts, hqIsFixedComplete.then(end)) .then(".") .then(end, hqIsFixedComplete); })(); @@ -160,6 +169,12 @@ const PREMISE = phraseGraph( ), "another cavern where we can continue our mining operations", ), + state("spawnIsBlackout").then( + "We found a cavern with unusual geomagnetic properties. We believe " + + "it will have plenty of Energy Crystals", + "We're sending you to a cavern deep within the planet where we've " + + "been picking up unusual magnetic readings", + ).then(skip, state("treasureCaveOne", "treasureCaveMany")), ) .then( pg(".").then(end), @@ -219,7 +234,9 @@ const PREMISE = phraseGraph( greeting.then(state('spawnIsMobFarm')).then( "We discovered this incredible cave with the abundance of Energy " + - "Crystals you now see before you." + "Crystals you now see before you.", + "As you can see, we have located a cave with an absurd number of " + + "Energy Crystals.", ).then( "We meant to teleport you onto that island, but something is " + "interfering with the signal.", @@ -227,7 +244,7 @@ const PREMISE = phraseGraph( "our teleporters.", ).then( "We are extremely limited in what vehicles we can send down to you, " + - "so you'll have to get the crystals some other way." + "so you'll have to get the crystals some other way.", ).then( skip, state('hasMonsters') ).then( @@ -236,8 +253,29 @@ const PREMISE = phraseGraph( skip, state('spawnHasErosion') ).then( skip, state('treasureCaveOne', 'treasureCaveMany') + ).then( + skip, + pg("\n\nThere's one more thing - ").then(skip, "you aren't the first to arrive here.").then( + state('lostMinersApart').then( + "Some of our Rock Raiders were scattered a bit further away from " + + "the island. By our readings, they seem to be in separate caverns " + + "nearby" + ), + state('lostMinersTogether').then( + "We already sent a team down here, but they failed to check in").then(skip, ". We believe they are stranded in a nearby cavern", + ), + state('lostMinersOne').then( + "One of our Rock Raiders was teleported to another cavern " + + "somewhere near here", + "One of our Rock Raiders didn't come down with the group. They " + + "should be somewhere nearby" + ), + ).then( + "and we're counting on you to rescue them!", + ". I know I can count on you to reach them.", + ".", + ) ).then(end); - // TODO: write copy for find hq / lost miners combos const negativeGreeting = pg( greeting, diff --git a/src/core/lore/lore.ts b/src/core/lore/lore.ts index 3974a8b..fed33d1 100644 --- a/src/core/lore/lore.ts +++ b/src/core/lore/lore.ts @@ -37,6 +37,7 @@ export type State = { readonly buildAndPowerGcMultiple: boolean; readonly hasAirLimit: boolean; readonly spawnIsMobFarm: boolean; + readonly spawnIsBlackout: boolean; }; export type FoundLostMinersState = State & { @@ -241,6 +242,7 @@ export class Lore { buildAndPowerGcMultiple: buildAndPowerGcCount > 1, hasAirLimit: !!cavern.oxygen, spawnIsMobFarm: anchor.metadata?.tag === 'mobFarm', + spawnIsBlackout: anchor.metadata?.tag === 'blackout', }; const enemies = filterTruthy([ diff --git a/src/core/transformers/02_masonry/02_brace.ts b/src/core/transformers/02_masonry/02_brace.ts index 84f6055..6bd3335 100644 --- a/src/core/transformers/02_masonry/02_brace.ts +++ b/src/core/transformers/02_masonry/02_brace.ts @@ -1,58 +1,97 @@ import { MutableGrid } from "../../common/grid"; import { RoughPlasticCavern } from "./01_rough"; import { Tile } from "../../models/tiles"; -import { Cardinal4, NSEW } from "../../common/geometry"; -import { getDiscoveryZones } from "../../models/discovery_zone"; +import { NSEW, offsetBy, Point, rotateAround, rotateLeft, rotateRight } from "../../common/geometry"; +import { DiscoveryZone, getDiscoveryZones } from "../../models/discovery_zone"; +import { filterTruthy } from "../../common/utils"; + + +/* + Each tile is part of four different possible 2x2 squares. + If the tile is floor, it does not need to be braced. + If the tile is part of at least one firm 2x2 square of wall, it does not need to be braced. + track the DZs that must be conditionally undiscovered for this to be open? + If it needs to be braced, + */ + export default function brace(cavern: RoughPlasticCavern): RoughPlasticCavern { const rng = cavern.dice.brace; const tiles = cavern.tiles.copy(); const discoveryZones = getDiscoveryZones(tiles); - const visited: MutableGrid = new MutableGrid(); - const queue: { x: number; y: number; facing: Cardinal4 | null }[] = - tiles.flatMap((_, x, y) => [ - { x, y, facing: null }, - ...NSEW.map((f) => ({ x: x + f[0], y: y + f[1], facing: null })), - ]); - while (queue.length) { - const { x, y, facing } = queue.pop()!; - if (!visited.get(x, y) && (tiles.get(x, y)?.isWall ?? true)) { - const neighbors = NSEW.map((f) => ({ - x: x + f[0], - y: y + f[1], - facing: f, - })); - const wallNeighbors = neighbors.filter( - ({ x, y }) => tiles.get(x, y)?.isWall ?? true, - ); + const done: MutableGrid = new MutableGrid(); - if (!wallNeighbors.length) { - const n = rng.uniformChoice(neighbors); - tiles.set(n.x, n.y, Tile.DIRT); - wallNeighbors.push(n); + function visit(pv: Point) { + if (done.get(...pv)) { + return; + } + if (!tiles.get(...pv)?.isWall) { + return; + } + // V marks the tile at point pv. The tile has four possible squares it can + // fit in. Look at them in this pattern, rotated to each of the four + // possible orientations: + // . W E + // A V D + // Z S . + const squares = rng.shuffle(NSEW).map( + ([owx, owy]) => { + const pw = offsetBy(pv, [owx, owy]); + const pa = offsetBy(pv, [owy, -owx]); + const ps = offsetBy(pv, [-owx, -owy]); + const pd = offsetBy(pv, [-owy, owx]); + const pe = offsetBy(pv, [owx - owy, owy + owx]) + const pz = offsetBy(pv, [-owx + owy, -owy - owx]) + const floors = [pw, pe, pd].reduce((r, p) => tiles.get(...p)?.isWall === false ? r + 1 : r, 0); + return {pw, pa, ps, pd, pe, pz, floors}; } - - const needsBrace = () => { - if (wallNeighbors.length === 2) { - const [a, b] = wallNeighbors; - return ( - (a.x === b.x || a.y === b.y) && - discoveryZones.get(a.x, a.y) === discoveryZones.get(b.x, b.y) - ); + ); + // Sort the squares by how many floor tiles they have so the most supported + // goes first. + squares.sort((a, b) => a.floors - b.floors); + for (const {pw, pa, ps, pd, pe, pz, floors} of squares) { + // All points are already walls - nothing to do. + if (floors === 0) { + [pv, pw, pe, pd].forEach(p => done.set(...p, true)); + return; + } + // Determine if this separates two discovery zones. If so, it doesn't + // need to be supported. + const dzs: DiscoveryZone[] = []; + [pw, pe, pd, ps, pz, pa].forEach( + p => { + if (tiles.get(...p)?.isWall === false) { + const dz = discoveryZones.get(...p)!; + dzs[dz.id] = dz; + } + } + ); + if (dzs.reduce((r) => r + 1, 0) > 1) { + const [d1, d2] = dzs.filter(() => true); + if (!d1.openOnSpawn || !d2.openOnSpawn) { + // V sits on the boundary between two different discovery zones. + // Because of the way DZs are calculated, it is not possible for there + // to be more than one DZ in either contiguious group (WED/SZA) so this + // must be actually bisecting. + done.set(...pv, true); + return; } - return wallNeighbors.length < 2; - }; - - if (needsBrace()) { - const [ox, oy] = facing || wallNeighbors[0].facing.map((v) => -v); - const n = { x: x - oy, y: y + ox, facing: [-oy, ox] as Cardinal4 }; - tiles.set(n.x, n.y, Tile.DIRT); - wallNeighbors.push(n); } - - visited.set(x, y, true); - queue.push(...wallNeighbors.filter(({ x, y }) => tiles.get(x, y))); + // This square must become wall. + [pw, pe, pd].forEach(p => { + if (tiles.get(...p)?.isWall === false) { + tiles.set(...p, Tile.DIRT); + } + done.set(...p, true); + }); + done.set(...pv, true); + return; } } + + const queue: Point[] = rng.shuffle(tiles.flatMap( + (_, x, y) => [[0,0], ...NSEW].map(([ox, oy]) => [x + ox, y + oy] satisfies Point) + )); + queue.forEach(visit); return { ...cavern, tiles }; }