├── src ├── hooch.ts ├── familiar │ ├── index.ts │ ├── experienceFamiliars.ts │ ├── constantValueFamiliars.ts │ ├── lib.ts │ ├── freeFightFamiliar.ts │ └── dropFamiliars.ts ├── modes │ ├── index.ts │ ├── capsule.ts │ ├── soup.ts │ ├── future.ts │ ├── skeleton.ts │ ├── rose.ts │ └── rock.ts ├── garboValue.ts ├── juneCleaver.ts ├── engine.ts ├── macro.ts ├── setup.ts ├── outfit.ts ├── lib.ts └── main.ts ├── .yarnrc.yml ├── .gitignore ├── prettier.config.mjs ├── babel.config.mjs ├── tsconfig.json ├── eslint.config.js ├── README.md ├── LICENSE ├── package.json └── .github └── workflows └── build.yml /src/hooch.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | KoLmafia/scripts/* 4 | node_modules 5 | 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions -------------------------------------------------------------------------------- /src/familiar/index.ts: -------------------------------------------------------------------------------- 1 | export { freeFightFamiliar } from "./freeFightFamiliar"; 2 | export { 3 | canOpenRedPresent, 4 | pocketProfessorLectures, 5 | timeToMeatify, 6 | } from "./lib"; 7 | export type { MenuOptions } from "./lib"; 8 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type import("prettier").Options */ 4 | export default { 5 | plugins: ["@trivago/prettier-plugin-sort-imports"], 6 | importOrder: ["", "^[~.]/"], 7 | importOrderSeparation: true, 8 | importOrderSortSpecifiers: true, 9 | }; 10 | -------------------------------------------------------------------------------- /babel.config.mjs: -------------------------------------------------------------------------------- 1 | export default function (api) { 2 | api.cache(true); 3 | return { 4 | exclude: [], 5 | presets: [ 6 | "@babel/preset-typescript", 7 | "@babel/preset-react", 8 | [ 9 | "@babel/preset-env", 10 | { 11 | targets: { rhino: "1.7.13" }, 12 | }, 13 | ], 14 | ], 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/modes/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | quest as skeletonQuest, 3 | targetItems as skeletonTargetItems, 4 | } from "./skeleton"; 5 | export { 6 | quest as capsuleQuest, 7 | targetItems as capsuleTargetItems, 8 | } from "./capsule"; 9 | export { 10 | quest as futureQuest, 11 | targetItems as futureTargetItems, 12 | } from "./future"; 13 | export { quest as rockQuest, targetItems as rockTargetItems } from "./rock"; 14 | export { quest as roseQuest, targetItems as roseTargetItems } from "./rose"; 15 | export { quest as soupQuest, targetItems as soupTargetItems } from "./soup"; 16 | -------------------------------------------------------------------------------- /src/garboValue.ts: -------------------------------------------------------------------------------- 1 | import { ValueFunctions, makeValue } from "garbo-lib"; 2 | import { Item } from "kolmafia"; 3 | import { $item } from "libram"; 4 | 5 | let _valueFunctions: ValueFunctions | undefined = undefined; 6 | function garboValueFunctions(): ValueFunctions { 7 | if (!_valueFunctions) { 8 | _valueFunctions = makeValue({ 9 | itemValues: new Map([[$item`fake hand`, 50000]]), 10 | }); 11 | } 12 | return _valueFunctions; 13 | } 14 | 15 | export function garboValue(item: Item): number { 16 | return garboValueFunctions().value(item); 17 | } 18 | 19 | export function garboAverageValue(...items: Item[]): number { 20 | return garboValueFunctions().averageValue(...items); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "noEmit": true /* Do not emit outputs. */, 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "declaration": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["ES2019"], 10 | "module": "commonjs", 11 | "noEmitOnError": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "pretty": true, 15 | "strict": true, 16 | "target": "esnext", 17 | "skipLibCheck": true 18 | }, 19 | "include": ["src/**/*.ts", "test/**/*.ts"], 20 | "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | } 22 | -------------------------------------------------------------------------------- /src/modes/capsule.ts: -------------------------------------------------------------------------------- 1 | import { ChronerQuest, ChronerStrategy } from "../engine"; 2 | import Macro from "../macro"; 3 | import { chooseQuestOutfit, ifHave } from "../outfit"; 4 | import { $item, $items, $location, getKramcoWandererChance } from "libram"; 5 | 6 | const location = $location`The Cave Before Time`; 7 | export const quest: ChronerQuest = { 8 | name: "Capsule", 9 | location, 10 | tasks: [ 11 | { 12 | name: "Chroner", 13 | completed: () => false, 14 | do: $location`The Cave Before Time`, 15 | outfit: () => { 16 | const sausageSpec = 17 | getKramcoWandererChance() >= 1 18 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 19 | : {}; 20 | return chooseQuestOutfit( 21 | { location, isFree: getKramcoWandererChance() >= 1 }, 22 | sausageSpec, 23 | ); 24 | }, 25 | combat: new ChronerStrategy(() => Macro.standardCombat()), 26 | sobriety: "either", 27 | }, 28 | ], 29 | }; 30 | 31 | export const targetItems = $items`twitching time capsule`; 32 | -------------------------------------------------------------------------------- /src/modes/soup.ts: -------------------------------------------------------------------------------- 1 | import { ChronerQuest, ChronerStrategy } from "../engine"; 2 | import Macro from "../macro"; 3 | import { chooseQuestOutfit, ifHave } from "../outfit"; 4 | import { $item, $items, $location, getKramcoWandererChance } from "libram"; 5 | 6 | const location = $location`The Primordial Stew`; 7 | 8 | export const quest: ChronerQuest = { 9 | name: "Soup", 10 | location, 11 | tasks: [ 12 | { 13 | name: "Soup", 14 | completed: () => false, 15 | do: location, 16 | outfit: () => { 17 | const sausageSpec = 18 | getKramcoWandererChance() >= 1 19 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 20 | : {}; 21 | return chooseQuestOutfit( 22 | { location, isFree: getKramcoWandererChance() >= 1 }, 23 | sausageSpec, 24 | ); 25 | }, 26 | combat: new ChronerStrategy(() => Macro.standardCombat()), 27 | sobriety: "either", 28 | }, 29 | ], 30 | }; 31 | 32 | export const targetItems = $items`flagellate flagon, messenger bag RNA, proto-proto-protozoa`; 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import prettier from "eslint-config-prettier"; 3 | import libram from "eslint-plugin-libram"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | js.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | { 10 | files: ["**/*.ts", "**/*.tsx"], 11 | plugins: { 12 | libram: libram, 13 | }, 14 | rules: { 15 | "block-scoped-var": "error", 16 | "eol-last": "error", 17 | eqeqeq: "error", 18 | "no-trailing-spaces": "error", 19 | "no-var": "error", 20 | "prefer-arrow-callback": "error", 21 | "prefer-const": "error", 22 | "prefer-template": "error", 23 | "no-unused-vars": "off", 24 | "@typescript-eslint/no-unused-vars": "error", 25 | "libram/verify-constants": "error", 26 | "no-restricted-syntax": [ 27 | "error", 28 | { 29 | selector: "TSEnumDeclaration:not([const=true])", 30 | message: "Don't declare non-const enums", 31 | }, 32 | ], 33 | }, 34 | }, 35 | prettier, 36 | ); 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | chrono logo 2 | 3 | **NOTE** Time Twitching Tower came back on November 28th of 2023. Yay! We don't know if this is still the best way to farm TTT. Boo! Use at your own risk 4 | 5 | **Chrono Collector** (also known as "chrono" is a script meant to help [Kingdom of Loathing](https://www.kingdomofloathing.com/) players efficiently farm Chroners within the Time Twitching Tower. 6 | 7 | To install, run the following command on an up-to-date [KolMafia](https://github.com/kolmafia/kolmafia) version: 8 | 9 | ``` 10 | git checkout loathers/chrono-collector release 11 | ``` 12 | 13 | To update, run `git update` or check the "Update installed Git projects on login" box within Mafia preferences. 14 | 15 | ## Running Chrono 16 | 17 | To run chrono, run the following command in the mafia GCLI: 18 | 19 | `chrono` 20 | 21 | You can specify the number of turns to run (use negative numbers for the number of turns remaining) with the turns argument. The following example will use 10 turns. 22 | 23 | `chrono turns=10` 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Patrick Stalcup 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/modes/future.ts: -------------------------------------------------------------------------------- 1 | import { ChronerQuest, ChronerStrategy } from "../engine"; 2 | import Macro from "../macro"; 3 | import { chooseQuestOutfit, ifHave } from "../outfit"; 4 | import { 5 | $item, 6 | $items, 7 | $location, 8 | $monster, 9 | getKramcoWandererChance, 10 | } from "libram"; 11 | 12 | const location = $location`The Home of The Future`; 13 | const monster = $monster`robot maid`; 14 | 15 | export const quest: ChronerQuest = { 16 | name: "Home of the Future", 17 | location, 18 | tasks: [ 19 | { 20 | name: "Core", 21 | completed: () => false, 22 | do: location, 23 | outfit: () => { 24 | const sausageSpec = 25 | getKramcoWandererChance() >= 1 26 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 27 | : {}; 28 | return chooseQuestOutfit( 29 | { location, isFree: getKramcoWandererChance() >= 1 }, 30 | sausageSpec, 31 | ); 32 | }, 33 | combat: new ChronerStrategy(() => 34 | Macro.step("pickpocket").seeMoreOf(monster).standardCombat(), 35 | ), 36 | sobriety: "either", 37 | }, 38 | ], 39 | }; 40 | 41 | export const targetItems = $items`housekeeping automa-core`; 42 | -------------------------------------------------------------------------------- /src/modes/skeleton.ts: -------------------------------------------------------------------------------- 1 | import { ChronerQuest, ChronerStrategy } from "../engine"; 2 | import Macro from "../macro"; 3 | import { chooseQuestOutfit, ifHave } from "../outfit"; 4 | import { $item, $items, $location, getKramcoWandererChance } from "libram"; 5 | 6 | const location = $location`No Man's And Also No Skeleton's Land`; 7 | 8 | const detector = $item`chocolate and nylons detector`; 9 | 10 | export const quest: ChronerQuest = { 11 | name: "Skeleton", 12 | location, 13 | tasks: [ 14 | { 15 | name: "No Man's and No Skeleton's Land", 16 | completed: () => false, 17 | do: location, 18 | acquire: [{ item: detector }], 19 | outfit: () => { 20 | const spec = 21 | getKramcoWandererChance() >= 1 22 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 23 | : ifHave("offhand", detector); 24 | return chooseQuestOutfit( 25 | { location, isFree: getKramcoWandererChance() >= 1 }, 26 | spec, 27 | ); 28 | }, 29 | combat: new ChronerStrategy(() => 30 | Macro.tryItem($item`Mayor Ghost's scissors`).standardCombat(true), 31 | ), 32 | sobriety: "either", 33 | }, 34 | ], 35 | }; 36 | 37 | export const targetItems = $items`ordnance magnet, confetti grenade, orphaned baby skeleton, chocolate rations, nice nylon stockings`; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chroner-collector", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "author": "Patrick Stalcup ", 6 | "homepage": "", 7 | "license": "MIT", 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "build": "node --no-warnings --loader ts-node/esm/transpile-only ./build.mjs", 11 | "watch": "yarn build --watch", 12 | "check": "tsc", 13 | "lint": "eslint src && prettier --check .", 14 | "format": "eslint src --fix && prettier --write .", 15 | "madge": "madge --circular . ./src/index.ts", 16 | "pre-commit": "lint-staged" 17 | }, 18 | "dependencies": { 19 | "core-js": "^3.44.0", 20 | "garbo-lib": "^1.0.2", 21 | "grimoire-kolmafia": "^0.3.33", 22 | "kolmafia": "^5.28584.0", 23 | "libram": "^0.11.6" 24 | }, 25 | "devDependencies": { 26 | "@babel/cli": "^7.28.0", 27 | "@babel/compat-data": "^7.28.0", 28 | "@babel/core": "^7.28.0", 29 | "@babel/preset-env": "^7.28.0", 30 | "@babel/preset-react": "^7.27.1", 31 | "@babel/preset-typescript": "^7.27.1", 32 | "@eslint/js": "^9.32.0", 33 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 34 | "@types/node": "^24.1.0", 35 | "copyfiles": "^2.4.1", 36 | "esbuild": "^0.25.8", 37 | "esbuild-plugin-babel": "^0.2.3", 38 | "eslint": "^9.32.0", 39 | "eslint-config-prettier": "^10.1.8", 40 | "eslint-plugin-libram": "^0.4.30", 41 | "lint-staged": "^16.1.2", 42 | "madge": "^8.0.0", 43 | "prettier": "^3.6.2", 44 | "ts-node": "^10.9.2", 45 | "typescript": "^5.8.3", 46 | "typescript-eslint": "^8.38.0" 47 | }, 48 | "packageManager": "yarn@4.9.2", 49 | "engines": { 50 | "node": ">=22.12.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/modes/rose.ts: -------------------------------------------------------------------------------- 1 | import { ChronerQuest, ChronerStrategy } from "../engine"; 2 | import Macro from "../macro"; 3 | import { chooseQuestOutfit, ifHave } from "../outfit"; 4 | import { myLocation } from "kolmafia"; 5 | import { 6 | $item, 7 | $items, 8 | $location, 9 | FloristFriar, 10 | getKramcoWandererChance, 11 | } from "libram"; 12 | 13 | const location = $location`Globe Theatre Main Stage`; 14 | let triedFlorist = false; 15 | export const quest: ChronerQuest = { 16 | name: "Rose", 17 | location, 18 | tasks: [ 19 | { 20 | name: "Flowers", 21 | ready: () => FloristFriar.have() && myLocation() === location, 22 | completed: () => FloristFriar.isFull() || triedFlorist, 23 | do: () => { 24 | const flowers = [ 25 | FloristFriar.ArcticMoss, 26 | FloristFriar.SpiderPlant, 27 | FloristFriar.BamBoo, 28 | ...FloristFriar.flowersAvailableFor(location), 29 | ]; 30 | for (const flower of flowers) flower.plant(); 31 | triedFlorist = true; 32 | }, 33 | sobriety: "either", 34 | }, 35 | { 36 | name: "Chroner", 37 | completed: () => false, 38 | do: $location`Globe Theatre Main Stage`, 39 | outfit: () => { 40 | const sausageSpec = 41 | getKramcoWandererChance() >= 1 42 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 43 | : {}; 44 | return chooseQuestOutfit( 45 | { location, isFree: getKramcoWandererChance() >= 1 }, 46 | sausageSpec, 47 | ); 48 | }, 49 | combat: new ChronerStrategy(() => Macro.standardCombat()), 50 | sobriety: "either", 51 | }, 52 | ], 53 | }; 54 | 55 | export const targetItems = $items`rose, red tulip, white tulip, blue tulip`; 56 | -------------------------------------------------------------------------------- /src/familiar/experienceFamiliars.ts: -------------------------------------------------------------------------------- 1 | import { Familiar } from "kolmafia"; 2 | import { 3 | $familiar, 4 | findLeprechaunMultiplier, 5 | get, 6 | have, 7 | propertyTypes, 8 | } from "libram"; 9 | 10 | import { GeneralFamiliar } from "./lib"; 11 | 12 | type ExperienceFamiliar = { 13 | familiar: Familiar; 14 | used: propertyTypes.BooleanProperty; 15 | useValue: number; 16 | }; 17 | 18 | const experienceFamiliars: ExperienceFamiliar[] = [ 19 | { 20 | familiar: $familiar`Pocket Professor`, 21 | used: "_thesisDelivered", 22 | useValue: 11 * get("valueOfAdventure"), 23 | }, 24 | { 25 | familiar: $familiar`Grey Goose`, 26 | used: "_meatifyMatterUsed", 27 | useValue: 15 ** 4, 28 | }, 29 | ]; 30 | 31 | function valueExperienceFamiliar({ 32 | familiar, 33 | useValue, 34 | }: ExperienceFamiliar): GeneralFamiliar { 35 | const currentExp = 36 | familiar.experience || (have($familiar`Shorter-Order Cook`) ? 100 : 0); 37 | const experienceNeeded = 400 - currentExp; 38 | const estimatedExperience = 3; 39 | return { 40 | familiar, 41 | expectedValue: useValue / (experienceNeeded / estimatedExperience), 42 | leprechaunMultiplier: findLeprechaunMultiplier(familiar), 43 | limit: "experience", 44 | }; 45 | } 46 | 47 | export default function getExperienceFamiliars(): GeneralFamiliar[] { 48 | return experienceFamiliars 49 | .filter( 50 | ({ used, familiar }) => 51 | have(familiar) && !get(used) && familiar.experience < 400, 52 | ) 53 | .map(valueExperienceFamiliar); 54 | } 55 | 56 | export function getExperienceFamiliarLimit(fam: Familiar): number { 57 | const target = experienceFamiliars.find(({ familiar }) => familiar === fam); 58 | if (!have(fam) || !target) return 0; 59 | 60 | return (400 - fam.experience) / 5; 61 | } 62 | -------------------------------------------------------------------------------- /src/juneCleaver.ts: -------------------------------------------------------------------------------- 1 | import { Item } from "kolmafia"; 2 | import { $item, JuneCleaver, get } from "libram"; 3 | 4 | import { garboValue } from "./garboValue"; 5 | import { maxBy } from "./lib"; 6 | 7 | export const juneCleaverChoiceValues = { 8 | 1467: { 9 | 1: 0, 10 | 2: 0, 11 | 3: 5 * get("valueOfAdventure"), 12 | }, 13 | 1468: { 1: 0, 2: 5, 3: 0 }, 14 | 1469: { 1: 0, 2: $item`Dad's brandy`, 3: 1500 }, 15 | 1470: { 1: 0, 2: $item`teacher's pen`, 3: 0 }, 16 | 1471: { 1: $item`savings bond`, 2: 250, 3: 0 }, 17 | 1472: { 18 | 1: $item`trampled ticket stub`, 19 | 2: $item`fire-roasted lake trout`, 20 | 3: 0, 21 | }, 22 | 1473: { 1: $item`gob of wet hair`, 2: 0, 3: 0 }, 23 | 1474: { 1: 0, 2: $item`guilty sprout`, 3: 0 }, 24 | 1475: { 1: $item`mother's necklace`, 2: 0, 3: 0 }, 25 | } as const; 26 | 27 | function valueJuneCleaverOption(result: Item | number): number { 28 | return result instanceof Item ? garboValue(result) : result; 29 | } 30 | 31 | export function bestJuneCleaverOption( 32 | id: (typeof JuneCleaver.choices)[number], 33 | ): 1 | 2 | 3 { 34 | const options = [1, 2, 3] as const; 35 | return maxBy(options, (option) => 36 | valueJuneCleaverOption(juneCleaverChoiceValues[id][option]), 37 | ); 38 | } 39 | 40 | let juneCleaverSkipChoices: (typeof JuneCleaver.choices)[number][] | null; 41 | function skipJuneCleaverChoices() { 42 | if (!juneCleaverSkipChoices) { 43 | juneCleaverSkipChoices = [...JuneCleaver.choices] 44 | .sort( 45 | (a, b) => 46 | valueJuneCleaverOption( 47 | juneCleaverChoiceValues[a][bestJuneCleaverOption(a)], 48 | ) - 49 | valueJuneCleaverOption( 50 | juneCleaverChoiceValues[b][bestJuneCleaverOption(b)], 51 | ), 52 | ) 53 | .splice(0, 3); 54 | } 55 | 56 | return juneCleaverSkipChoices; 57 | } 58 | 59 | export function shouldSkip( 60 | choice: (typeof JuneCleaver.choices)[number], 61 | ): boolean { 62 | return ( 63 | JuneCleaver.skipsRemaining() > 0 && 64 | skipJuneCleaverChoices().includes(choice) 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | env: 11 | node-version: ">=22.12.0" 12 | path: "KoLmafia" # The directory where your assets are generated, should match build.mjs 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Enable corepack 21 | run: corepack enable 22 | 23 | - name: Use Node.js ${{ env.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ env.node-version }} 27 | 28 | - name: Install Dependencies 29 | run: yarn install --immutable 30 | 31 | - name: Build 32 | run: yarn run build 33 | 34 | lint: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | 39 | - name: Enable corepack 40 | run: corepack enable 41 | 42 | - name: Use Node.js ${{ env.node-version }} 43 | uses: actions/setup-node@v3 44 | with: 45 | node-version: ${{ env.node-version }} 46 | 47 | - name: Install Dependencies 48 | run: yarn install --immutable 49 | 50 | - name: ESLint & Prettier 51 | run: yarn run lint 52 | 53 | push: 54 | runs-on: ubuntu-latest 55 | needs: [test, lint] 56 | if: github.ref == 'refs/heads/main' 57 | steps: 58 | - uses: actions/checkout@v3 59 | 60 | - name: Enable corepack 61 | run: corepack enable 62 | 63 | - name: Use Node.js ${{ env.node-version }} 64 | uses: actions/setup-node@v3 65 | with: 66 | node-version: ${{ env.node-version }} 67 | 68 | - name: Install Dependencies 69 | run: yarn install --immutable 70 | 71 | - name: Build 72 | run: yarn run build 73 | 74 | - name: Push to Release 75 | uses: s0/git-publish-subdir-action@develop 76 | env: 77 | REPO: self 78 | BRANCH: release 79 | FOLDER: ${{ env.path }} 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | MESSAGE: "Build: ({sha}) {msg}" 82 | SKIP_EMPTY_COMMITS: true 83 | -------------------------------------------------------------------------------- /src/familiar/constantValueFamiliars.ts: -------------------------------------------------------------------------------- 1 | import { garboAverageValue, garboValue } from "../garboValue"; 2 | import { Familiar, familiarWeight, weightAdjustment } from "kolmafia"; 3 | import { 4 | $effect, 5 | $familiar, 6 | $item, 7 | $items, 8 | $location, 9 | Robortender, 10 | findLeprechaunMultiplier, 11 | get, 12 | have, 13 | } from "libram"; 14 | 15 | import { GeneralFamiliar, MenuOptions } from "./lib"; 16 | 17 | type ConstantValueFamiliar = { 18 | familiar: Familiar; 19 | value: (options: MenuOptions) => number; 20 | }; 21 | 22 | const standardFamiliars: ConstantValueFamiliar[] = [ 23 | { 24 | familiar: $familiar`Obtuse Angel`, 25 | value: () => 0.02 * garboValue($item`time's arrow`), 26 | }, 27 | { 28 | familiar: $familiar`Stocking Mimic`, 29 | value: () => 30 | garboAverageValue(...$items`Polka Pop, BitterSweetTarts, Piddles`) / 6 + 31 | (1 / 3 + (have($effect`Jingle Jangle Jingle`) ? 0.1 : 0)) * 32 | (familiarWeight($familiar`Stocking Mimic`) + weightAdjustment()), 33 | }, 34 | { 35 | familiar: $familiar`Shorter-Order Cook`, 36 | value: () => 37 | garboAverageValue( 38 | ...$items`short beer, short stack of pancakes, short stick of butter, short glass of water, short white`, 39 | ) / 11, 40 | }, 41 | { 42 | familiar: $familiar`Robortender`, 43 | value: () => 44 | garboValue($item`elemental sugarcube`) / 5 + 45 | (Robortender.currentDrinks().includes($item`Feliz Navidad`) 46 | ? get("garbo_felizValue", 0) * 0.25 47 | : 0) + 48 | (Robortender.currentDrinks().includes($item`Newark`) 49 | ? get("garbo_newarkValue", 0) * 0.25 50 | : 0), 51 | }, 52 | { 53 | familiar: $familiar`Twitching Space Critter`, 54 | 55 | // Item is ludicrously overvalued and incredibly low-volume. 56 | // We can remove this cap once the price reaches a lower equilibrium 57 | // we probably won't, but we can. 58 | value: () => Math.min(garboValue($item`twitching space egg`) * 0.0002, 690), 59 | }, 60 | { 61 | familiar: $familiar`Hobo Monkey`, 62 | value: () => 75, 63 | }, 64 | { 65 | familiar: $familiar`Red-Nosed Snapper`, 66 | value: ({ location }) => 67 | location === $location`Globe Theatre Main Stage` 68 | ? garboValue($item`human musk`) / 11 69 | : 0, 70 | }, 71 | { 72 | familiar: $familiar`Mosquito`, 73 | // Acts as default familiar. 74 | // Extra roses when using an attacking familiar and everyone has this one 75 | value: () => 1, 76 | }, 77 | ]; 78 | 79 | export default function getConstantValueFamiliars( 80 | options: MenuOptions = {}, 81 | ): GeneralFamiliar[] { 82 | return standardFamiliars 83 | .filter(({ familiar }) => have(familiar)) 84 | .map(({ familiar, value }) => ({ 85 | familiar, 86 | expectedValue: value(options), 87 | leprechaunMultiplier: findLeprechaunMultiplier(familiar), 88 | limit: "none", 89 | })); 90 | } 91 | -------------------------------------------------------------------------------- /src/familiar/lib.ts: -------------------------------------------------------------------------------- 1 | import { sober } from "../lib"; 2 | import { 3 | Familiar, 4 | Location, 5 | familiarWeight, 6 | myAdventures, 7 | totalTurnsPlayed, 8 | weightAdjustment, 9 | } from "kolmafia"; 10 | import { $effect, $familiar, $item, get, have } from "libram"; 11 | 12 | export type GeneralFamiliar = { 13 | familiar: Familiar; 14 | expectedValue: number; 15 | leprechaunMultiplier: number; 16 | limit: "drops" | "experience" | "none" | "special"; 17 | }; 18 | 19 | export function timeToMeatify(): boolean { 20 | if (!have($familiar`Grey Goose`) || get("_meatifyMatterUsed") || !sober()) { 21 | return false; 22 | } else if ($familiar`Grey Goose`.experience >= 400) return true; 23 | else if (myAdventures() > 50) return false; 24 | 25 | // Check Wanderers 26 | const totalTurns = totalTurnsPlayed(); 27 | const baseMeat = have($item`SongBoom™ BoomBox`) ? 275 : 250; 28 | const usingLatte = 29 | have($item`latte lovers member's mug`) && 30 | get("latteModifier").split(",").includes("Meat Drop: 40"); 31 | 32 | const nextProtonicGhost = have($item`protonic accelerator pack`) 33 | ? Math.max(1, get("nextParanormalActivity") - totalTurns) 34 | : Infinity; 35 | const nextVoteMonster = 36 | have($item`"I Voted!" sticker`) && get("_voteFreeFights") < 3 37 | ? Math.max(0, ((totalTurns % 11) - 1) % 11) 38 | : Infinity; 39 | const nextVoidMonster = 40 | have($item`cursed magnifying glass`) && 41 | get("_voidFreeFights") < 5 && 42 | get("valueOfFreeFight", 2000) / 13 > baseMeat * (usingLatte ? 0.75 : 0.6) 43 | ? -get("cursedMagnifyingGlassCount") % 13 44 | : Infinity; 45 | 46 | // If any of the above are 0, then 47 | // (1) We should be fighting a free fight 48 | // (2) We meatify if Grey Goose is sufficiently heavy and we don't have another free wanderer in our remaining turns 49 | 50 | const freeFightNow = 51 | get("questPAGhost") !== "unstarted" || 52 | nextVoteMonster === 0 || 53 | nextVoidMonster === 0; 54 | const delay = Math.min( 55 | nextProtonicGhost, 56 | nextVoteMonster === 0 57 | ? get("_voteFreeFights") < 2 58 | ? 11 59 | : Infinity 60 | : nextVoteMonster, 61 | nextVoidMonster === 0 ? 13 : nextVoidMonster, 62 | ); 63 | 64 | if (delay < myAdventures()) return false; 65 | // We can wait for the next free fight 66 | else if (freeFightNow || $familiar`Grey Goose`.experience >= 121) return true; 67 | 68 | return false; 69 | } 70 | 71 | export function pocketProfessorLectures(): number { 72 | return ( 73 | 2 + 74 | Math.ceil( 75 | Math.sqrt( 76 | familiarWeight($familiar`Pocket Professor`) + weightAdjustment(), 77 | ), 78 | ) 79 | ); 80 | } 81 | 82 | export function canOpenRedPresent(): boolean { 83 | return ( 84 | have($familiar`Crimbo Shrub`) && 85 | !have($effect`Everything Looks Red`) && 86 | get("shrubGifts") === "meat" && 87 | sober() 88 | ); 89 | } 90 | 91 | export type MenuOptions = { 92 | canChooseMacro?: boolean; 93 | location?: Location; 94 | extraFamiliars?: GeneralFamiliar[]; 95 | includeExperienceFamiliars?: boolean; 96 | allowAttackFamiliars?: boolean; 97 | }; 98 | -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | import { CombatStrategy, Engine, Outfit, Quest, Task } from "grimoire-kolmafia"; 2 | import { 3 | Item, 4 | Location, 5 | bjornifyFamiliar, 6 | enthroneFamiliar, 7 | equippedAmount, 8 | setAutoAttack, 9 | } from "kolmafia"; 10 | import { 11 | $item, 12 | $slot, 13 | CrownOfThrones, 14 | JuneCleaver, 15 | PropertiesManager, 16 | get, 17 | sum, 18 | sumNumbers, 19 | } from "libram"; 20 | 21 | import { garboAverageValue, garboValue } from "./garboValue"; 22 | import { bestJuneCleaverOption, shouldSkip } from "./juneCleaver"; 23 | import { printd, sober } from "./lib"; 24 | import Macro from "./macro"; 25 | 26 | export type ChronerTask = Task & { 27 | sobriety: "sober" | "drunk" | "either"; 28 | forced?: boolean; 29 | }; 30 | 31 | export type ChronerQuest = Quest & { 32 | location: Location; 33 | }; 34 | 35 | const introAdventures = ["The Cave Before Time"]; 36 | export class ChronerStrategy extends CombatStrategy { 37 | constructor(macro: () => Macro) { 38 | super(); 39 | this.macro(macro).autoattack(macro); 40 | } 41 | } 42 | 43 | function dropsValueFunction(drops: Item[] | Map): number { 44 | return Array.isArray(drops) 45 | ? garboAverageValue(...drops) 46 | : sum( 47 | [...drops.entries()], 48 | ([item, quantity]) => quantity * garboValue(item), 49 | ) / sumNumbers([...drops.values()]); 50 | } 51 | 52 | CrownOfThrones.createRiderMode("default", { dropsValueFunction }); 53 | const chooseRider = () => CrownOfThrones.pickRider("default"); 54 | export class ChronerEngine extends Engine { 55 | available(task: ChronerTask): boolean { 56 | const sobriety = 57 | task.sobriety === "either" || 58 | (sober() && task.sobriety === "sober") || 59 | (!sober() && task.sobriety === "drunk"); 60 | 61 | if (task.forced) { 62 | return sobriety && get("noncombatForcerActive") && super.available(task); 63 | } 64 | return sobriety && super.available(task); 65 | } 66 | 67 | createOutfit(task: ChronerTask): Outfit { 68 | const outfit = super.createOutfit(task); 69 | if (outfit.equips.get($slot`hat`) === $item`Crown of Thrones`) { 70 | const choice = chooseRider(); 71 | if (choice) enthroneFamiliar(choice.familiar); 72 | } else if (outfit.equips.get($slot`back`) === $item`Buddy Bjorn`) { 73 | const choice = chooseRider(); 74 | if (choice) bjornifyFamiliar(choice.familiar); 75 | } 76 | return outfit; 77 | } 78 | 79 | setChoices(task: ChronerTask, manager: PropertiesManager): void { 80 | super.setChoices(task, manager); 81 | if (equippedAmount($item`June cleaver`) > 0) { 82 | this.propertyManager.setChoices( 83 | Object.fromEntries( 84 | JuneCleaver.choices.map((choice) => [ 85 | choice, 86 | shouldSkip(choice) ? 4 : bestJuneCleaverOption(choice), 87 | ]), 88 | ), 89 | ); 90 | } 91 | } 92 | 93 | shouldRepeatAdv(task: ChronerTask): boolean { 94 | if (["Poetic Justice", "Lost and Found"].includes(get("lastEncounter"))) { 95 | printd("Skipping repeating Adventure despite free NC (beaten up)"); 96 | return false; 97 | } 98 | if (introAdventures.includes(get("lastEncounter"))) { 99 | printd(`Hit Intro adventure ${get("lastEncounter")} which is a free NC`); 100 | return true; 101 | } 102 | return super.shouldRepeatAdv(task); 103 | } 104 | 105 | print() { 106 | printd(`Task List:`); 107 | for (const task of this.tasks) { 108 | printd(`${task.name}: available:${this.available(task)}`); 109 | } 110 | } 111 | 112 | destruct(): void { 113 | super.destruct(); 114 | setAutoAttack(0); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/familiar/freeFightFamiliar.ts: -------------------------------------------------------------------------------- 1 | import { canOpenRedPresent } from "."; 2 | import { garboValue } from "../garboValue"; 3 | import { sober } from "../lib"; 4 | import { Familiar, familiarWeight } from "kolmafia"; 5 | import { $familiar, $item, $location, clamp, get, have } from "libram"; 6 | 7 | import getConstantValueFamiliars from "./constantValueFamiliars"; 8 | import getDropFamiliars from "./dropFamiliars"; 9 | import getExperienceFamiliars from "./experienceFamiliars"; 10 | import { GeneralFamiliar, MenuOptions, timeToMeatify } from "./lib"; 11 | 12 | const DEFAULT_MENU_OPTIONS = { 13 | canChooseMacro: true, 14 | location: $location`none`, 15 | extraFamiliars: [], 16 | includeExperienceFamiliars: true, 17 | allowAttackFamiliars: true, 18 | }; 19 | export function menu(options: MenuOptions = {}): GeneralFamiliar[] { 20 | const { 21 | includeExperienceFamiliars, 22 | canChooseMacro, 23 | location, 24 | extraFamiliars, 25 | allowAttackFamiliars, 26 | } = { 27 | ...DEFAULT_MENU_OPTIONS, 28 | ...options, 29 | }; 30 | const familiarMenu: GeneralFamiliar[] = [ 31 | ...getConstantValueFamiliars(), 32 | ...getDropFamiliars(), 33 | ...(includeExperienceFamiliars ? getExperienceFamiliars() : []), 34 | ...extraFamiliars, 35 | { 36 | familiar: $familiar.none, 37 | expectedValue: 0, 38 | leprechaunMultiplier: 0, 39 | limit: "none", 40 | }, 41 | ]; 42 | 43 | if (canChooseMacro && sober()) { 44 | if (timeToMeatify()) { 45 | familiarMenu.push({ 46 | familiar: $familiar`Grey Goose`, 47 | expectedValue: 48 | (Math.max(familiarWeight($familiar`Grey Goose`) - 5), 0) ** 4, 49 | leprechaunMultiplier: 0, 50 | limit: "experience", 51 | }); 52 | } 53 | 54 | if (canOpenRedPresent()) { 55 | familiarMenu.push({ 56 | familiar: $familiar`Crimbo Shrub`, 57 | expectedValue: 2500, 58 | leprechaunMultiplier: 0, 59 | limit: "special", 60 | }); 61 | } 62 | 63 | if ( 64 | location.zone === "Dinseylandfill" && 65 | have($familiar`Space Jellyfish`) 66 | ) { 67 | familiarMenu.push({ 68 | familiar: $familiar`Space Jellyfish`, 69 | expectedValue: 70 | garboValue($item`stench jelly`) / 71 | (get("_spaceJellyfishDrops") < 5 72 | ? get("_spaceJellyfishDrops") + 1 73 | : 20), 74 | leprechaunMultiplier: 0, 75 | limit: "special", 76 | }); 77 | } 78 | } 79 | 80 | if (!allowAttackFamiliars) { 81 | return familiarMenu.filter( 82 | (fam) => !(fam.familiar.physicalDamage || fam.familiar.elementalDamage), 83 | ); 84 | } 85 | 86 | return familiarMenu; 87 | } 88 | 89 | export function getAllJellyfishDrops(): { 90 | expectedValue: number; 91 | turnsAtValue: number; 92 | }[] { 93 | if (!have($familiar`Space Jellyfish`)) 94 | return [{ expectedValue: 0, turnsAtValue: 0 }]; 95 | 96 | const current = get("_spaceJellyfishDrops"); 97 | const returnValue = []; 98 | 99 | for ( 100 | let dropNumber = clamp(current + 1, 0, 6); 101 | dropNumber <= 6; 102 | dropNumber++ 103 | ) { 104 | returnValue.push({ 105 | expectedValue: 106 | garboValue($item`stench jelly`) / (dropNumber > 5 ? 20 : dropNumber), 107 | turnsAtValue: dropNumber > 5 ? Infinity : dropNumber, 108 | }); 109 | } 110 | 111 | return returnValue; 112 | } 113 | 114 | export function freeFightFamiliarData( 115 | options: MenuOptions = {}, 116 | ): GeneralFamiliar { 117 | const compareFamiliars = (a: GeneralFamiliar, b: GeneralFamiliar) => { 118 | if (a.expectedValue === b.expectedValue) { 119 | return a.leprechaunMultiplier > b.leprechaunMultiplier ? a : b; 120 | } 121 | return a.expectedValue > b.expectedValue ? a : b; 122 | }; 123 | 124 | return menu(options).reduce(compareFamiliars); 125 | } 126 | 127 | export function freeFightFamiliar(options: MenuOptions = {}): Familiar { 128 | return freeFightFamiliarData(options).familiar; 129 | } 130 | -------------------------------------------------------------------------------- /src/macro.ts: -------------------------------------------------------------------------------- 1 | import { Item, Monster, Skill, haveEquipped, myFamiliar } from "kolmafia"; 2 | import { 3 | $familiar, 4 | $item, 5 | $items, 6 | $monster, 7 | $skill, 8 | SongBoom, 9 | StrictMacro, 10 | get, 11 | have, 12 | } from "libram"; 13 | 14 | import { canOpenRedPresent, timeToMeatify } from "./familiar"; 15 | import { shouldRedigitize } from "./lib"; 16 | 17 | export default class Macro extends StrictMacro { 18 | tryHaveSkill(skill: Skill): this { 19 | return this.externalIf(have(skill), Macro.trySkill(skill)); 20 | } 21 | 22 | static tryHaveSkill(skill: Skill): Macro { 23 | return new Macro().tryHaveSkill(skill); 24 | } 25 | 26 | tryHaveItem(item: Item): this { 27 | return this.externalIf(have(item), Macro.tryItem(item)); 28 | } 29 | 30 | static seeMoreOf(monster: Monster): Macro { 31 | return new Macro().seeMoreOf(monster); 32 | } 33 | 34 | seeMoreOf(monster: Monster): this { 35 | return this.if_( 36 | monster, 37 | Macro.externalIf( 38 | get("olfactedMonster") !== monster && get("_olfactionsUsed") < 3, 39 | Macro.trySkill($skill`Transcendent Olfaction`), 40 | ).externalIf( 41 | get("_gallapagosMonster") !== monster, 42 | Macro.trySkill($skill`Gallapagosian Mating Call`), 43 | ), 44 | ); 45 | } 46 | 47 | static tryHaveItem(item: Item): Macro { 48 | return new Macro().tryHaveItem(item); 49 | } 50 | 51 | redigitize(): this { 52 | return this.externalIf( 53 | shouldRedigitize(), 54 | Macro.if_( 55 | get("_sourceTerminalDigitizeMonster") ?? $monster.none, 56 | Macro.skill($skill`Digitize`), 57 | ), 58 | ); 59 | } 60 | 61 | static redigitize(): Macro { 62 | return new Macro().redigitize(); 63 | } 64 | 65 | doItems(): this { 66 | const steps = new Macro(); 67 | const items = 68 | $items`train whistle, Rain-Doh blue balls, Time-Spinner, Rain-Doh indigo cup, HOA citation pad, porquoise-handled sixgun`.filter( 69 | (i) => have(i), 70 | ); 71 | if (items.length) { 72 | if (!have($skill`Ambidextrous Funkslinging`)) { 73 | for (const item of items) steps.tryItem(item); 74 | } else { 75 | for (let i = 0; i <= items.length; i += 2) { 76 | const chunk = items.slice(i, i + 2); 77 | if (chunk.length === 2) steps.tryItem(chunk as [Item, Item]); 78 | else steps.tryItem(...chunk); 79 | } 80 | } 81 | } else { 82 | steps.tryHaveItem($item`seal tooth`); 83 | } 84 | return this.step(steps); 85 | } 86 | 87 | static doItems(): Macro { 88 | return new Macro().doItems(); 89 | } 90 | 91 | getRocks(): this { 92 | return this.externalIf( 93 | myFamiliar() === $familiar`Grey Goose` && 94 | $familiar`Grey Goose`.experience >= 36, 95 | Macro.trySkill($skill`Emit Matter Duplicating Drones`), 96 | ).externalIf( 97 | haveEquipped($item`pro skateboard`), 98 | Macro.trySkill($skill`Do an epic McTwist!`), 99 | ); 100 | } 101 | 102 | static getRocks(): Macro { 103 | return new Macro().getRocks(); 104 | } 105 | 106 | spikes(): this { 107 | return this.trySkill($skill`Launch spikolodon spikes`); 108 | } 109 | 110 | static spikes(): Macro { 111 | return new Macro().spikes(); 112 | } 113 | 114 | standardCombat(delevelFirst = false): this { 115 | return this.externalIf( 116 | delevelFirst, 117 | Macro.trySkill($skill`Curse of Weaksauce`).doItems(), 118 | ) 119 | .externalIf( 120 | canOpenRedPresent() && myFamiliar() === $familiar`Crimbo Shrub`, 121 | Macro.trySkill($skill`Open a Big Red Present`), 122 | ) 123 | .externalIf( 124 | timeToMeatify() && myFamiliar() === $familiar`Grey Goose`, 125 | Macro.trySkill($skill`Meatify Matter`), 126 | ) 127 | .externalIf( 128 | get("cosmicBowlingBallReturnCombats") < 1, 129 | Macro.trySkill($skill`Bowl Straight Up`), 130 | ) 131 | .tryHaveSkill($skill`Summon Mayfly Swarm`) 132 | .externalIf( 133 | SongBoom.song() === "Total Eclipse of Your Meat", 134 | Macro.tryHaveSkill($skill`Sing Along`), 135 | ) 136 | .tryHaveSkill($skill`Extract`) 137 | .tryHaveSkill($skill`Micrometeorite`) 138 | .externalIf(!delevelFirst, Macro.doItems()) 139 | .tryHaveSkill($skill`Nantlers`) 140 | .tryHaveSkill($skill`Nanoshock`) 141 | .tryHaveSkill($skill`Audioclasm`) 142 | .attack() 143 | .repeat(); 144 | } 145 | 146 | static standardCombat(): Macro { 147 | return new Macro().standardCombat(); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/setup.ts: -------------------------------------------------------------------------------- 1 | import { Quest } from "grimoire-kolmafia"; 2 | import { 3 | Item, 4 | create, 5 | getWorkshed, 6 | itemAmount, 7 | myHp, 8 | myMaxhp, 9 | putCloset, 10 | totalTurnsPlayed, 11 | useSkill, 12 | } from "kolmafia"; 13 | import { 14 | $effect, 15 | $effects, 16 | $familiar, 17 | $item, 18 | $locations, 19 | $phylum, 20 | $skill, 21 | AutumnAton, 22 | Snapper, 23 | SongBoom, 24 | get, 25 | have, 26 | uneffect, 27 | } from "libram"; 28 | 29 | import { ChronerTask } from "./engine"; 30 | import { CMCEnvironment, args, countEnvironment, tryGetCMCItem } from "./lib"; 31 | 32 | const poisons = $effects`Hardly Poisoned at All, A Little Bit Poisoned, Somewhat Poisoned, Really Quite Poisoned, Majorly Poisoned`; 33 | function cmcTarget(): { item: Item; environment: CMCEnvironment } { 34 | if (args.mode === "rose") { 35 | return { 36 | item: $item`Extrovermectin™`, 37 | environment: "i", 38 | }; 39 | } else { 40 | return { 41 | item: $item`Breathitin™`, 42 | environment: "u", 43 | }; 44 | } 45 | } 46 | 47 | export const setup: Quest = { 48 | name: "Setup", 49 | tasks: [ 50 | { 51 | name: "Beaten Up", 52 | completed: () => !have($effect`Beaten Up`), 53 | do: () => { 54 | if ( 55 | ["Poetic Justice", "Lost and Found"].includes(get("lastEncounter")) 56 | ) { 57 | uneffect($effect`Beaten Up`); 58 | } 59 | if (have($effect`Beaten Up`)) { 60 | throw "Got beaten up for no discernable reason!"; 61 | } 62 | }, 63 | sobriety: "either", 64 | }, 65 | { 66 | name: "Disco Nap", 67 | ready: () => 68 | have($skill`Disco Nap`) && have($skill`Adventurer of Leisure`), 69 | completed: () => poisons.every((e) => !have(e)), 70 | do: () => useSkill($skill`Disco Nap`), 71 | sobriety: "either", 72 | }, 73 | { 74 | name: "Antidote", 75 | completed: () => poisons.every((e) => !have(e)), 76 | do: () => poisons.forEach((e) => uneffect(e)), 77 | sobriety: "either", 78 | }, 79 | { 80 | name: "Recover", 81 | ready: () => have($skill`Cannelloni Cocoon`), 82 | completed: () => myHp() / myMaxhp() >= 0.5, 83 | do: () => { 84 | useSkill($skill`Cannelloni Cocoon`); 85 | }, 86 | sobriety: "either", 87 | }, 88 | { 89 | name: "Recover Failed", 90 | completed: () => myHp() / myMaxhp() >= 0.5, 91 | do: () => { 92 | throw "Unable to heal above 50% HP, heal yourself!"; 93 | }, 94 | sobriety: "either", 95 | }, 96 | { 97 | name: "Kgnee", 98 | ready: () => !get("_gnomePart"), 99 | completed: () => 100 | !have($familiar`Reagnimated Gnome`) || 101 | have($item`gnomish housemaid's kgnee`), 102 | do: (): void => { 103 | create($item`gnomish housemaid's kgnee`); 104 | }, 105 | sobriety: "sober", 106 | }, 107 | { 108 | name: "Closet Sand Dollars", 109 | completed: () => itemAmount($item`sand dollar`) === 0, 110 | do: () => putCloset(itemAmount($item`sand dollar`), $item`sand dollar`), 111 | sobriety: "either", 112 | }, 113 | { 114 | name: "Closet Hobo Nickels", 115 | completed: () => 116 | itemAmount($item`hobo nickel`) === 0 || 117 | (!have($familiar`Hobo Monkey`) && !have($item`hobo nickel`, 1000)), 118 | do: () => putCloset(itemAmount($item`hobo nickel`), $item`hobo nickel`), 119 | sobriety: "either", 120 | }, 121 | { 122 | name: "Snapper", 123 | completed: () => Snapper.getTrackedPhylum() === $phylum`dude`, 124 | do: () => Snapper.trackPhylum($phylum`dude`), 125 | ready: () => Snapper.have(), 126 | sobriety: "either", 127 | }, 128 | { 129 | name: "Autumn-Aton", 130 | completed: () => AutumnAton.currentlyIn() !== null, 131 | do: (): void => { 132 | AutumnAton.sendTo( 133 | $locations`The Post-Mall, Moonshiners' Woods, The Cave Before Time, The Sleazy Back Alley`, 134 | ); 135 | }, 136 | ready: () => AutumnAton.available(), 137 | sobriety: "either", 138 | }, 139 | { 140 | name: "Cold Medicine Cabinent", 141 | completed: () => 142 | getWorkshed() !== $item`cold medicine cabinet` || 143 | totalTurnsPlayed() < get("_nextColdMedicineConsult") || 144 | get("_coldMedicineConsults") >= 5 || 145 | countEnvironment(cmcTarget().environment) <= 10, 146 | do: () => tryGetCMCItem(cmcTarget().item), 147 | sobriety: "either", 148 | }, 149 | { 150 | name: "Boombox", 151 | completed: () => 152 | !SongBoom.have() || 153 | SongBoom.song() === "Food Vibrations" || 154 | SongBoom.songChangesLeft() === 0, 155 | do: () => SongBoom.setSong("Food Vibrations"), 156 | sobriety: "either", 157 | }, 158 | ], 159 | }; 160 | -------------------------------------------------------------------------------- /src/familiar/dropFamiliars.ts: -------------------------------------------------------------------------------- 1 | import { garboValue } from "../garboValue"; 2 | import { Familiar, Item } from "kolmafia"; 3 | import { 4 | $familiar, 5 | $item, 6 | findLeprechaunMultiplier, 7 | get, 8 | have, 9 | propertyTypes, 10 | } from "libram"; 11 | 12 | import { GeneralFamiliar } from "./lib"; 13 | 14 | type StandardDropFamiliar = { 15 | familiar: Familiar; 16 | expected: number[]; 17 | drop: Item; 18 | pref: propertyTypes.NumericProperty; 19 | additionalValue?: () => number; 20 | }; 21 | 22 | function valueStandardDropFamiliar({ 23 | familiar, 24 | expected, 25 | drop, 26 | pref, 27 | additionalValue, 28 | }: StandardDropFamiliar): GeneralFamiliar { 29 | const expectedTurns = expected[get(pref)] || Infinity; 30 | const expectedValue = 31 | garboValue(drop) / expectedTurns + (additionalValue?.() ?? 0); 32 | return { 33 | familiar, 34 | expectedValue, 35 | leprechaunMultiplier: findLeprechaunMultiplier(familiar), 36 | limit: "drops", 37 | }; 38 | } 39 | 40 | const rotatingFamiliars: StandardDropFamiliar[] = [ 41 | { 42 | familiar: $familiar`Fist Turkey`, 43 | expected: [3.91, 4.52, 4.52, 5.29, 5.29], 44 | drop: $item`Ambitious Turkey`, 45 | pref: "_turkeyBooze", 46 | }, 47 | { 48 | familiar: $familiar`Llama Lama`, 49 | expected: [3.42, 3.91, 4.52, 5.29, 5.29], 50 | drop: $item`llama lama gong`, 51 | pref: "_gongDrops", 52 | }, 53 | { 54 | familiar: $familiar`Astral Badger`, 55 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 56 | drop: $item`astral mushroom`, 57 | pref: "_astralDrops", 58 | }, 59 | { 60 | familiar: $familiar`Li'l Xenomorph`, 61 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 62 | drop: $item`transporter transponder`, 63 | pref: "_transponderDrops", 64 | }, 65 | { 66 | familiar: $familiar`Rogue Program`, 67 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 68 | drop: $item`Game Grid token`, 69 | pref: "_tokenDrops", 70 | }, 71 | { 72 | familiar: $familiar`Bloovian Groose`, 73 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 74 | drop: $item`groose grease`, 75 | pref: "_grooseDrops", 76 | }, 77 | { 78 | familiar: $familiar`Baby Sandworm`, 79 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 80 | drop: $item`agua de vida`, 81 | pref: "_aguaDrops", 82 | }, 83 | { 84 | familiar: $familiar`Green Pixie`, 85 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 86 | drop: $item`tiny bottle of absinthe`, 87 | pref: "_absintheDrops", 88 | }, 89 | { 90 | familiar: $familiar`Blavious Kloop`, 91 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 92 | drop: $item`devilish folio`, 93 | pref: "_kloopDrops", 94 | }, 95 | { 96 | familiar: $familiar`Galloping Grill`, 97 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 98 | drop: $item`hot ashes`, 99 | pref: "_hotAshesDrops", 100 | }, 101 | { 102 | familiar: $familiar`Grim Brother`, 103 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 104 | drop: $item`grim fairy tale`, 105 | pref: "_grimFairyTaleDrops", 106 | }, 107 | { 108 | familiar: $familiar`Golden Monkey`, 109 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 110 | drop: $item`powdered gold`, 111 | pref: "_powderedGoldDrops", 112 | }, 113 | { 114 | familiar: $familiar`Unconscious Collective`, 115 | expected: [3.03, 3.42, 3.91, 4.52, 5.29], 116 | drop: $item`Unconscious Collective Dream Jar`, 117 | pref: "_dreamJarDrops", 118 | }, 119 | { 120 | familiar: $familiar`Ms. Puck Man`, 121 | expected: Array($familiar`Ms. Puck Man`.dropsLimit).fill(12.85), 122 | drop: $item`power pill`, 123 | pref: "_powerPillDrops", 124 | additionalValue: () => garboValue($item`yellow pixel`), 125 | }, 126 | { 127 | familiar: $familiar`Puck Man`, 128 | expected: Array($familiar`Puck Man`.dropsLimit).fill(12.85), 129 | drop: $item`power pill`, 130 | pref: "_powerPillDrops", 131 | additionalValue: () => garboValue($item`yellow pixel`), 132 | }, 133 | { 134 | familiar: $familiar`Adventurous Spelunker`, 135 | expected: [7.0], 136 | drop: $item`Tales of Spelunking`, 137 | pref: "_spelunkingTalesDrops", 138 | }, 139 | { 140 | familiar: $familiar`Angry Jung Man`, 141 | expected: [30.0], 142 | drop: $item`psychoanalytic jar`, 143 | pref: "_jungDrops", 144 | }, 145 | { 146 | familiar: $familiar`Grimstone Golem`, 147 | expected: [45.0], 148 | drop: $item`grimstone mask`, 149 | pref: "_grimstoneMaskDrops", 150 | }, 151 | ]; 152 | 153 | export default function getDropFamiliars(): GeneralFamiliar[] { 154 | return rotatingFamiliars 155 | .map(valueStandardDropFamiliar) 156 | .filter( 157 | ({ familiar, expectedValue, leprechaunMultiplier }) => 158 | have(familiar) && (expectedValue || leprechaunMultiplier), 159 | ); 160 | } 161 | 162 | export function getAllDrops( 163 | fam: Familiar, 164 | ): { expectedValue: number; expectedTurns: number }[] { 165 | const target = rotatingFamiliars.find(({ familiar }) => familiar === fam); 166 | if (!have(fam) || !target) return []; 167 | 168 | const current = get(target.pref); 169 | const returnValue = []; 170 | 171 | for (let i = current; i < target.expected.length; i++) { 172 | const turns = target.expected[i]; 173 | returnValue.push({ 174 | expectedValue: 175 | garboValue(target.drop) / turns + (target.additionalValue?.() ?? 0), 176 | expectedTurns: turns, 177 | }); 178 | } 179 | 180 | return returnValue; 181 | } 182 | -------------------------------------------------------------------------------- /src/outfit.ts: -------------------------------------------------------------------------------- 1 | import { OutfitSlot, OutfitSpec } from "grimoire-kolmafia"; 2 | import { 3 | Familiar, 4 | Item, 5 | Location, 6 | canEquip, 7 | canInteract, 8 | itemAmount, 9 | totalTurnsPlayed, 10 | } from "kolmafia"; 11 | import { 12 | $familiar, 13 | $familiars, 14 | $item, 15 | $items, 16 | $location, 17 | get, 18 | getKramcoWandererChance, 19 | have, 20 | sumNumbers, 21 | } from "libram"; 22 | 23 | import { MenuOptions, freeFightFamiliar } from "./familiar"; 24 | import { garboAverageValue, garboValue } from "./garboValue"; 25 | import { args, maxBy, realmAvailable, sober } from "./lib"; 26 | 27 | export function ifHave( 28 | slot: OutfitSlot, 29 | item: Item, 30 | condition?: () => boolean, 31 | ): OutfitSpec { 32 | return have(item) && canEquip(item) && (condition?.() ?? true) 33 | ? Object.fromEntries([[slot, item]]) 34 | : {}; 35 | } 36 | 37 | function mergeSpecs(...outfits: OutfitSpec[]): OutfitSpec { 38 | return outfits.reduce((current, next) => ({ ...next, ...current }), {}); 39 | } 40 | 41 | const chooseFamiliar = (options: MenuOptions = {}): Familiar => 42 | canInteract() && sober() 43 | ? ($familiars`Reagnimated Gnome, Temporal Riftlet`.find((f) => have(f)) ?? 44 | freeFightFamiliar(options)) 45 | : freeFightFamiliar(options); 46 | 47 | type TaskOptions = { location: Location; isFree?: boolean }; 48 | export function chooseQuestOutfit( 49 | { location, isFree }: TaskOptions, 50 | ...outfits: OutfitSpec[] 51 | ): OutfitSpec { 52 | const mergedInputSpec = mergeSpecs(...outfits); 53 | const familiar = mergedInputSpec.familiar ?? chooseFamiliar({ location }); 54 | const famEquip = 55 | equipmentFamiliars.get(familiar) ?? 56 | (!( 57 | location === $location`Globe Theatre Main Stage` && 58 | !(familiar.elementalDamage || familiar.physicalDamage) 59 | ) 60 | ? $item`tiny stillsuit` 61 | : $item`oversized fish scaler`); 62 | 63 | const freeChance = [ 64 | { i: $item`Kramco Sausage-o-Matic™`, p: getKramcoWandererChance() }, 65 | { i: $item`carnivorous potted plant`, p: 0.04 }, 66 | { 67 | i: $item`cursed magnifying glass`, 68 | p: get("_voidFreeFights") < 5 ? 1 / 13 : 0, 69 | }, 70 | ].filter(({ i }) => have(i) && canEquip(i)); 71 | 72 | const offhands = freeChance.length 73 | ? { offhand: maxBy(freeChance, "p").i } 74 | : {}; 75 | 76 | const weapons = mergeSpecs( 77 | ifHave("weapon", $item`June cleaver`), 78 | ifHave("weapon", $item`Fourth of May Cosplay Saber`), 79 | ); 80 | 81 | const backs = mergeSpecs( 82 | ifHave( 83 | "back", 84 | $item`protonic accelerator pack`, 85 | () => 86 | get("questPAGhost") === "unstarted" && 87 | get("nextParanormalActivity") <= totalTurnsPlayed() && 88 | sober(), 89 | ), 90 | ifHave("back", $item`Time Cloak`), 91 | ); 92 | 93 | const spec = mergeSpecs( 94 | mergedInputSpec, 95 | ifHave("hat", $item`Crown of Thrones`), 96 | offhands, 97 | weapons, 98 | backs, 99 | { familiar }, 100 | ifHave("famequip", famEquip), 101 | ifHave( 102 | "pants", 103 | $item`designer sweatpants`, 104 | () => 25 * get("_sweatOutSomeBoozeUsed") + get("sweat") < 75, 105 | ), 106 | ); 107 | 108 | const bestAccessories = getBestAccessories(isFree); 109 | for (let i = 0; i < 3; i++) { 110 | const accessory = bestAccessories[i]; 111 | if (!accessory) break; 112 | spec[`acc${i + 1}` as OutfitSlot] = accessory; 113 | } 114 | const mergedSpec = mergeSpecs(...outfits, spec); 115 | 116 | if (!sober()) { 117 | mergedSpec.offhand = $item`Drunkula's wineglass`; 118 | } 119 | 120 | if ( 121 | !have($item`Crown of Thrones`) && 122 | have($item`Buddy Bjorn`) && 123 | !("back" in mergedSpec) 124 | ) { 125 | mergedSpec.back = $item`Buddy Bjorn`; 126 | } 127 | mergedSpec.modifier = 128 | $familiars`Reagnimated Gnome, Temporal Riftlet`.includes(familiar) 129 | ? "Familiar Weight" 130 | : "Item Drop"; 131 | return mergedSpec; 132 | } 133 | 134 | const equipmentFamiliars = new Map([ 135 | [$familiar`Reagnimated Gnome`, $item`gnomish housemaid's kgnee`], 136 | [$familiar`Shorter-Order Cook`, $item`blue plate`], 137 | [$familiar`Stocking Mimic`, $item`bag of many confections`], 138 | ]); 139 | 140 | function luckyGoldRing() { 141 | // Volcoino has a low drop rate which isn't accounted for here 142 | // Overestimating until it drops is probably fine, don't @ me 143 | const dropValues = [ 144 | 100, // 80 - 120 meat 145 | ...[ 146 | itemAmount($item`hobo nickel`) > 0 ? 100 : 0, // This should be closeted 147 | itemAmount($item`sand dollar`) > 0 ? garboValue($item`sand dollar`) : 0, // This should be closeted 148 | itemAmount($item`Freddy Kruegerand`) > 0 149 | ? garboValue($item`Freddy Kruegerand`) 150 | : 0, 151 | realmAvailable("sleaze") ? garboValue($item`Beach Buck`) : 0, 152 | realmAvailable("spooky") ? garboValue($item`Coinspiracy`) : 0, 153 | realmAvailable("stench") ? garboValue($item`FunFunds™`) : 0, 154 | realmAvailable("hot") && !get("_luckyGoldRingVolcoino") 155 | ? garboValue($item`Volcoino`) 156 | : 0, 157 | realmAvailable("cold") ? garboValue($item`Wal-Mart gift certificate`) : 0, 158 | realmAvailable("fantasy") ? garboValue($item`Rubee™`) : 0, 159 | ].filter((value) => value > 0), 160 | ]; 161 | 162 | // Items drop every ~10 turns 163 | return sumNumbers(dropValues) / dropValues.length / 10; 164 | } 165 | 166 | const accessories = new Map number>([ 167 | [ 168 | $item`mafia thumb ring`, 169 | (isFree?: boolean) => 170 | !isFree ? (1 / 0.96 - 1) * get("valueOfAdventure") : 0, 171 | ], 172 | // 18.2 expected turns per drug 173 | // https://kol.coldfront.net/thekolwiki/index.php/Time-twitching_toolbelt 174 | [ 175 | $item`time-twitching toolbelt`, 176 | () => 177 | garboAverageValue( 178 | ...$items`future drug: Muscularactum, future drug: Smartinex, future drug: Coolscaline`, 179 | ) / 18.2, 180 | ], 181 | [ 182 | $item`mayfly bait necklace`, 183 | () => (get("_mayflySummons") < 30 ? 0.5 * garboValue($item`rose`) : 0), 184 | ], 185 | [$item`lucky gold ring`, luckyGoldRing], 186 | [$item`Mr. Screege's spectacles`, () => 180], 187 | [$item`Mr. Cheeng's spectacles`, () => 220], 188 | [ 189 | $item`pro skateboard`, 190 | () => (args.mode === "rock" && get("_questCaveDan", 0) === 5 ? 10000 : 0), 191 | ], 192 | ]); 193 | 194 | function getBestAccessories(isFree?: boolean) { 195 | return Array.from(accessories.entries()) 196 | .filter(([item]) => have(item) && canEquip(item)) 197 | .map( 198 | ([item, valueFunction]) => 199 | [item, valueFunction(isFree)] as [Item, number], 200 | ) 201 | .sort(([, a], [, b]) => b - a) 202 | .map(([item]) => item) 203 | .splice(0, 3); 204 | } 205 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import { Args } from "grimoire-kolmafia"; 2 | import { 3 | Item, 4 | Skill, 5 | descToItem, 6 | inebrietyLimit, 7 | isDarkMode, 8 | mpCost, 9 | myAdventures, 10 | myFamiliar, 11 | myHp, 12 | myInebriety, 13 | myMaxhp, 14 | myMaxmp, 15 | myMp, 16 | print, 17 | runChoice, 18 | sessionStorage, 19 | totalFreeRests, 20 | use, 21 | useSkill, 22 | visitUrl, 23 | } from "kolmafia"; 24 | import { $familiar, $item, SourceTerminal, get, have } from "libram"; 25 | 26 | /** 27 | * Find the best element of an array, where "best" is defined by some given criteria. 28 | * @param array The array to traverse and find the best element of. 29 | * @param optimizer Either a key on the objects we're looking at that corresponds to numerical values, or a function for mapping these objects to numbers. Essentially, some way of assigning value to the elements of the array. 30 | * @param reverse Make this true to find the worst element of the array, and false to find the best. Defaults to false. 31 | */ 32 | export function maxBy( 33 | array: T[] | readonly T[], 34 | optimizer: (element: T) => number, 35 | reverse?: boolean, 36 | ): T; 37 | export function maxBy< 38 | S extends string | number | symbol, 39 | T extends { [x in S]: number }, 40 | >(array: T[] | readonly T[], key: S, reverse?: boolean): T; 41 | export function maxBy< 42 | S extends string | number | symbol, 43 | T extends { [x in S]: number }, 44 | >( 45 | array: T[] | readonly T[], 46 | optimizer: ((element: T) => number) | S, 47 | reverse = false, 48 | ): T { 49 | if (typeof optimizer === "function") { 50 | return maxBy( 51 | array.map((key) => ({ key, value: optimizer(key) })), 52 | "value", 53 | reverse, 54 | ).key; 55 | } else { 56 | return array.reduce((a, b) => 57 | a[optimizer] > b[optimizer] !== reverse ? a : b, 58 | ); 59 | } 60 | } 61 | 62 | export function shouldRedigitize(): boolean { 63 | const digitizesLeft = SourceTerminal.getDigitizeUsesRemaining(); 64 | const monsterCount = SourceTerminal.getDigitizeMonsterCount() + 1; 65 | // triangular number * 10 - 3 66 | const digitizeAdventuresUsed = monsterCount * (monsterCount + 1) * 5 - 3; 67 | // Redigitize if fewer adventures than this digitize usage. 68 | return ( 69 | SourceTerminal.have() && 70 | SourceTerminal.canDigitize() && 71 | myAdventures() / 0.96 < digitizesLeft * digitizeAdventuresUsed 72 | ); 73 | } 74 | 75 | const HIGHLIGHT = isDarkMode() ? "yellow" : "blue"; 76 | export function printh(message: string) { 77 | print(message, HIGHLIGHT); 78 | } 79 | 80 | export function printd(message: string) { 81 | if (args.debug) { 82 | print(message, HIGHLIGHT); 83 | } 84 | } 85 | 86 | export function sober() { 87 | return ( 88 | myInebriety() <= 89 | inebrietyLimit() + (myFamiliar() === $familiar`Stooper` ? -1 : 0) 90 | ); 91 | } 92 | 93 | export const args = Args.create("chrono", "A script for farming chroner", { 94 | turns: Args.number({ 95 | help: "The number of turns to run (use negative numbers for the number of turns remaining)", 96 | default: Infinity, 97 | }), 98 | mode: Args.string({ 99 | options: [ 100 | ["capsule", "Farm Time Capsules from the Cave Before Time"], 101 | ["future", "Farm... something from the Automated Future"], 102 | ["rock", "Get Caveman Dan's Favorite Rock - duped as much as possible"], 103 | ["rose", "Farm Roses from The Main Stage"], 104 | ["skeleton", "Farm rares from skeletal fascists"], 105 | ["soup", "Farm soup ingredients from the Primordial Stew"], 106 | ], 107 | default: "rose", 108 | }), 109 | debug: Args.flag({ 110 | help: "Turn on debug printing", 111 | default: false, 112 | }), 113 | }); 114 | 115 | function getCMCChoices(): { [choice: string]: number } { 116 | const options = visitUrl("campground.php?action=workshed"); 117 | let i = 0; 118 | let match; 119 | const entries: [string, number][] = []; 120 | 121 | const regexp = /descitem\((\d+)\)/g; 122 | while ((match = regexp.exec(options)) !== null) { 123 | entries.push([`${descToItem(match[1])}`, ++i]); 124 | } 125 | return Object.fromEntries(entries); 126 | } 127 | 128 | export function tryGetCMCItem(item: Item): void { 129 | const choice = getCMCChoices()[`${item}`]; 130 | if (choice) { 131 | runChoice(choice); 132 | } 133 | } 134 | 135 | export type CMCEnvironment = "u" | "i"; 136 | export function countEnvironment(environment: CMCEnvironment): number { 137 | return get("lastCombatEnvironments") 138 | .split("") 139 | .filter((e) => e === environment).length; 140 | } 141 | 142 | export type RealmType = 143 | | "spooky" 144 | | "stench" 145 | | "hot" 146 | | "cold" 147 | | "sleaze" 148 | | "fantasy" 149 | | "pirate"; 150 | export function realmAvailable(identifier: RealmType): boolean { 151 | if (identifier === "fantasy") { 152 | return get(`_frToday`) || get(`frAlways`); 153 | } else if (identifier === "pirate") { 154 | return get(`_prToday`) || get(`prAlways`); 155 | } 156 | return ( 157 | get(`_${identifier}AirportToday`, false) || 158 | get(`${identifier}AirportAlways`, false) 159 | ); 160 | } 161 | 162 | export function freeRest(): boolean { 163 | if (get("timesRested") >= totalFreeRests()) return false; 164 | 165 | if (myHp() >= myMaxhp() && myMp() >= myMaxmp()) { 166 | if (have($item`awful poetry journal`)) { 167 | use($item`awful poetry journal`); 168 | } else { 169 | // burn some mp so that we can rest 170 | const bestSkill = maxBy( 171 | Skill.all().filter((sk) => have(sk) && mpCost(sk) >= 1), 172 | (sk) => -mpCost(sk), 173 | ); // are there any other skills that cost mana which we should blacklist? 174 | // Facial expressions? But this usually won't be an issue since all *NORMAL* classes have access to a level1 1mp skill 175 | useSkill(bestSkill); 176 | } 177 | } 178 | 179 | if (get("chateauAvailable")) { 180 | visitUrl("place.php?whichplace=chateau&action=chateau_restlabelfree"); 181 | } else if (get("getawayCampsiteUnlocked")) { 182 | visitUrl("place.php?whichplace=campaway&action=campaway_tentclick"); 183 | } else { 184 | visitUrl("campground.php?action=rest"); 185 | } 186 | 187 | return true; 188 | } 189 | 190 | export function freeRestsLeft(): boolean { 191 | return get("timesRested") >= totalFreeRests(); 192 | } 193 | 194 | export function getBestAutomatedFutureSide() { 195 | const stored = sessionStorage.getItem("automatedFutureBest"); 196 | if (stored) return stored; 197 | 198 | const page = visitUrl("place.php?whichplace=twitch"); 199 | const springbros = Number( 200 | page.match(/title='(-?\d+)' href=adventure.php\?snarfblat=581/)?.[1] ?? "0", 201 | ); 202 | const boltsmann = Number( 203 | page.match(/title='(-?\d+)' href=adventure.php\?snarfblat=582/)?.[1] ?? "0", 204 | ); 205 | const best = springbros > boltsmann ? "springbros" : "boltsmann"; 206 | sessionStorage.setItem("automatedFutureBest", best); 207 | return best; 208 | } 209 | -------------------------------------------------------------------------------- /src/modes/rock.ts: -------------------------------------------------------------------------------- 1 | import { ChronerQuest, ChronerStrategy } from "../engine.js"; 2 | import Macro from "../macro.js"; 3 | import { chooseQuestOutfit, ifHave } from "../outfit.js"; 4 | import { 5 | familiarWeight, 6 | handlingChoice, 7 | myAscensions, 8 | runChoice, 9 | toUrl, 10 | use, 11 | visitUrl, 12 | } from "kolmafia"; 13 | import { 14 | $familiar, 15 | $item, 16 | $items, 17 | $location, 18 | get, 19 | getKramcoWandererChance, 20 | set, 21 | } from "libram"; 22 | 23 | const location = $location`The Cave Before Time`; 24 | export const quest: ChronerQuest = { 25 | name: "Get Rock", 26 | location, 27 | tasks: [ 28 | { 29 | name: "Set properties after ascension", 30 | ready: () => get("lastCaveDanPropertyReset", 0) !== myAscensions(), 31 | completed: () => 32 | myAscensions() <= get("lastCaveDanDefeat", 0) || 33 | get("questCaveDan", 0) === 0, 34 | do: () => { 35 | set("questCaveDan", 0); 36 | set("lastCaveDanPropertyReset", myAscensions()); 37 | }, 38 | sobriety: "either", 39 | }, 40 | { 41 | name: "Inital Visit to Dan's Cave", 42 | completed: () => 43 | get("questCaveDan", 0) > 0 || 44 | get("lastCaveDanDefeat", 0) >= myAscensions(), 45 | do: () => { 46 | visitUrl("place.php?whichplace=twitch&action=twitch_dancave1"); 47 | set("questCaveDan", 1); 48 | }, 49 | sobriety: "sober", 50 | }, 51 | { 52 | name: "Play Rock^3 with Ook the Mook", 53 | after: ["Inital Visit to Dan's Cave"], 54 | completed: () => 55 | get("questCaveDan", 0) > 1 || 56 | get("lastCaveDanDefeat", 0) >= myAscensions(), 57 | do: () => { 58 | visitUrl(toUrl(location)); 59 | for (const choiceValue of [3, 1, 2, 1, 2]) { 60 | runChoice(choiceValue); 61 | } 62 | if (get("lastEncounter") === "Ook the Mook") { 63 | set("questCaveDan", 2); 64 | } 65 | }, 66 | outfit: () => { 67 | const sausageSpec = 68 | getKramcoWandererChance() >= 1 69 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 70 | : {}; 71 | return chooseQuestOutfit( 72 | { location, isFree: getKramcoWandererChance() >= 1 }, 73 | sausageSpec, 74 | ); 75 | }, 76 | combat: new ChronerStrategy(() => Macro.standardCombat()), 77 | forced: true, 78 | sobriety: "sober", 79 | }, 80 | { 81 | name: "Teach Ook about Paper", 82 | after: ["Play Rock^3 with Ook the Mook"], 83 | completed: () => 84 | get("questCaveDan", 0) > 2 || 85 | get("lastCaveDanDefeat", 0) >= myAscensions(), 86 | do: () => { 87 | visitUrl(toUrl(location)); 88 | for (const choiceValue of [3, 1, 1, 2]) { 89 | runChoice(choiceValue); 90 | } 91 | /* if (handlingChoice()) { 92 | runChoice(3); // 955 - Go towards noise 93 | runChoice(1); // 954 - Ask About Password 94 | runChoice(1); // 954 - Offer to Trade 95 | runChoice(2); // 954 - Teach Secret of Paper 96 | } */ 97 | if (get("lastEncounter") === "Ook the Mook") { 98 | set("questCaveDan", 3); 99 | } 100 | }, 101 | outfit: () => { 102 | const sausageSpec = 103 | getKramcoWandererChance() >= 1 104 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 105 | : {}; 106 | return chooseQuestOutfit( 107 | { location, isFree: getKramcoWandererChance() >= 1 }, 108 | sausageSpec, 109 | ); 110 | }, 111 | combat: new ChronerStrategy(() => Macro.standardCombat()), 112 | forced: true, 113 | sobriety: "sober", 114 | }, 115 | { 116 | name: "Teach Ook about Scissors", 117 | after: ["Teach Ook about Paper"], 118 | completed: () => 119 | get("questCaveDan", 0) > 3 || 120 | get("lastCaveDanDefeat", 0) >= myAscensions(), 121 | do: () => { 122 | visitUrl(toUrl(location)); 123 | for (const choiceValue of [3, 1, 1, 2]) { 124 | runChoice(choiceValue); 125 | } 126 | if (get("lastEncounter") === "Ook the Mook") { 127 | set("questCaveDan", 4); 128 | } 129 | }, 130 | outfit: () => { 131 | const sausageSpec = 132 | getKramcoWandererChance() >= 1 133 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 134 | : {}; 135 | return chooseQuestOutfit( 136 | { location, isFree: getKramcoWandererChance() >= 1 }, 137 | sausageSpec, 138 | ); 139 | }, 140 | combat: new ChronerStrategy(() => Macro.standardCombat()), 141 | forced: true, 142 | sobriety: "sober", 143 | }, 144 | { 145 | name: "Play RoShamBo to obtain Password", 146 | after: ["Teach Ook about Scissors"], 147 | completed: () => 148 | get("questCaveDan", 0) > 4 || 149 | get("lastCaveDanDefeat", 0) >= myAscensions(), 150 | do: () => { 151 | visitUrl(toUrl(location)); 152 | if (handlingChoice()) { 153 | runChoice(3); // 955 - Go towards noise 154 | runChoice(1); // 954 - Ask About Password 155 | runChoice(2); // 954 - Game of Chance 156 | while (handlingChoice()) { 157 | runChoice(1); // 954 - Alternating between picking Rock and continuing to play until you win. 158 | } 159 | } 160 | if (get("lastEncounter") === "Ook the Mook") { 161 | set("questCaveDan", 5); 162 | } 163 | }, 164 | outfit: () => { 165 | const sausageSpec = 166 | getKramcoWandererChance() >= 1 167 | ? ifHave("offhand", $item`Kramco Sausage-o-Matic™`) 168 | : {}; 169 | return chooseQuestOutfit( 170 | { location, isFree: getKramcoWandererChance() >= 1 }, 171 | sausageSpec, 172 | ); 173 | }, 174 | combat: new ChronerStrategy(() => Macro.standardCombat()), 175 | forced: true, 176 | sobriety: "sober", 177 | }, 178 | { 179 | name: "Charge Goose", 180 | after: ["Play RoShamBo to obtain Password"], 181 | completed: () => 182 | familiarWeight($familiar`Grey Goose`) >= 7 || 183 | get("questCaveDan", 0) > 5 || 184 | get("lastCaveDanDefeat", 0) >= myAscensions(), 185 | do: () => { 186 | use($item`Ghost Dog Chow`); 187 | }, 188 | outfit: { familiar: $familiar`Grey Goose` }, 189 | sobriety: "sober", 190 | limit: { tries: 5 }, 191 | }, 192 | { 193 | name: "Fight CaveDan", 194 | after: ["Charge Goose", "Play RoShamBo to obtain Password"], 195 | completed: () => 196 | get("questCaveDan", 0) > 5 || 197 | get("lastCaveDanDefeat", 0) >= myAscensions(), 198 | do: () => { 199 | visitUrl("place.php?whichplace=twitch&action=twitch_dancave3"); 200 | set("questCaveDan", 6); 201 | set("lastCaveDanDefeat", myAscensions()); 202 | }, 203 | outfit: () => { 204 | return chooseQuestOutfit( 205 | { location }, 206 | { familiar: $familiar`Grey Goose`, acc1: $items`pro skateboard` }, 207 | ); 208 | }, 209 | combat: new ChronerStrategy(() => Macro.getRocks().standardCombat()), 210 | sobriety: "sober", 211 | }, 212 | ], 213 | }; 214 | 215 | export const targetItems = $items`Caveman Dan's favorite rock`; 216 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Args, Quest, getTasks } from "grimoire-kolmafia"; 2 | import { 3 | adv1, 4 | canAdventure, 5 | cliExecute, 6 | myAdventures, 7 | myAscensions, 8 | myClass, 9 | myTurncount, 10 | print, 11 | totalTurnsPlayed, 12 | use, 13 | useSkill, 14 | visitUrl, 15 | } from "kolmafia"; 16 | import { 17 | $class, 18 | $effect, 19 | $item, 20 | $items, 21 | $location, 22 | $monsters, 23 | $skill, 24 | AsdonMartin, 25 | CinchoDeMayo, 26 | Counter, 27 | Session, 28 | get, 29 | have, 30 | sinceKolmafiaRevision, 31 | withProperty, 32 | } from "libram"; 33 | 34 | import { 35 | ChronerEngine, 36 | ChronerQuest, 37 | ChronerStrategy, 38 | ChronerTask, 39 | } from "./engine"; 40 | import { args, getBestAutomatedFutureSide, printh } from "./lib"; 41 | import Macro from "./macro"; 42 | import * as modes from "./modes"; 43 | import { chooseQuestOutfit } from "./outfit"; 44 | import { setup } from "./setup"; 45 | 46 | const completed = () => { 47 | const turncount = myTurncount(); 48 | return args.turns > 0 49 | ? () => myTurncount() - turncount >= args.turns || myAdventures() === 0 50 | : () => myAdventures() === -args.turns; 51 | }; 52 | 53 | function getQuest(): ChronerQuest { 54 | switch (args.mode) { 55 | case "capsule": 56 | return { ...modes.capsuleQuest, completed: completed() }; 57 | case "future": 58 | return { ...modes.futureQuest, completed: completed() }; 59 | case "rock": 60 | return { ...modes.rockQuest, completed: completed() }; 61 | case "rose": 62 | return { ...modes.roseQuest, completed: completed() }; 63 | case "skeleton": 64 | return { ...modes.skeletonQuest, completed: completed() }; 65 | case "soup": 66 | return { ...modes.soupQuest, completed: completed() }; 67 | default: 68 | throw "Unrecognized mode"; 69 | } 70 | } 71 | 72 | const targetItems = [ 73 | ...$items`Chroner`, 74 | ...modes.capsuleTargetItems, 75 | ...modes.futureTargetItems, 76 | ...modes.rockTargetItems, 77 | ...modes.roseTargetItems, 78 | ...modes.skeletonTargetItems, 79 | ...modes.soupTargetItems, 80 | ]; 81 | 82 | export function main(command?: string) { 83 | Args.fill(args, command); 84 | 85 | if (args.help) { 86 | Args.showHelp(args); 87 | return; 88 | } 89 | 90 | sinceKolmafiaRevision(27668); 91 | 92 | let digitizes = -1; 93 | const yrTarget = $location`The Cave Before Time`; 94 | 95 | const quest = getQuest(); 96 | const global: Quest = { 97 | name: "Global", 98 | completed: completed(), 99 | tasks: [ 100 | { 101 | name: "Check Access", 102 | completed: () => get("timeTowerAvailable"), 103 | do: () => { 104 | visitUrl("place.php?whichplace=twitch"); 105 | if (!get("timeTowerAvailable")) { 106 | throw "The Time-Twitching Tower is currently unavailable"; 107 | } 108 | }, 109 | sobriety: "either", 110 | }, 111 | { 112 | name: "Grey You Attack Skill", 113 | completed: () => 114 | have($skill`Nantlers`) || 115 | have($skill`Nanoshock`) || 116 | have($skill`Audioclasm`), 117 | do: $location`The Haunted Storage Room`, 118 | ready: () => 119 | myClass() === $class`Grey Goo` && 120 | canAdventure($location`The Haunted Storage Room`), 121 | combat: new ChronerStrategy(() => Macro.standardCombat()), 122 | sobriety: "sober", 123 | choices: { 886: 6 }, 124 | }, 125 | { 126 | name: "Clara's Bell", 127 | completed: () => 128 | !have($item`Clara's bell`) || 129 | get("_claraBellUsed") || 130 | get("noncombatForcerActive"), 131 | do: () => { 132 | use($item`Clara's bell`); 133 | }, 134 | sobriety: "either", 135 | }, 136 | { 137 | name: "Fiesta Exit", 138 | ready: () => CinchoDeMayo.totalAvailableCinch() > 60, 139 | completed: () => get("noncombatForcerActive"), 140 | do: () => { 141 | const turns = totalTurnsPlayed(); 142 | while (CinchoDeMayo.currentCinch() < 60) { 143 | cliExecute("rest"); 144 | if (totalTurnsPlayed() > turns) break; 145 | } 146 | useSkill(1, $skill`Cincho: Fiesta Exit`); 147 | }, 148 | sobriety: "either", 149 | }, 150 | { 151 | name: "Proton Ghost", 152 | ready: () => 153 | have($item`protonic accelerator pack`) && 154 | get("questPAGhost") !== "unstarted" && 155 | !!get("ghostLocation"), 156 | do: (): void => { 157 | const location = get("ghostLocation"); 158 | if (location) { 159 | adv1(location, 0, ""); 160 | } else { 161 | throw "Could not determine Proton Ghost location!"; 162 | } 163 | }, 164 | outfit: () => 165 | chooseQuestOutfit( 166 | { location: quest.location, isFree: true }, 167 | { 168 | back: $item`protonic accelerator pack`, 169 | avoid: 170 | get("ghostLocation") === $location`The Icy Peak` 171 | ? $items`Great Wolf's beastly trousers` 172 | : [], 173 | }, 174 | ), 175 | completed: () => get("questPAGhost") === "unstarted", 176 | combat: new ChronerStrategy(() => 177 | Macro.trySkill($skill`Sing Along`) 178 | .trySkill($skill`Shoot Ghost`) 179 | .trySkill($skill`Shoot Ghost`) 180 | .trySkill($skill`Shoot Ghost`) 181 | .trySkill($skill`Trap Ghost`), 182 | ), 183 | sobriety: "sober", 184 | }, 185 | { 186 | name: "Vote Wanderer", 187 | ready: () => 188 | have($item`"I Voted!" sticker`) && 189 | totalTurnsPlayed() % 11 === 1 && 190 | get("lastVoteMonsterTurn") < totalTurnsPlayed() && 191 | get("_voteFreeFights") < 3, 192 | do: (): void => { 193 | adv1(quest.location, -1, ""); 194 | }, 195 | outfit: () => 196 | chooseQuestOutfit( 197 | { location: quest.location, isFree: true }, 198 | { acc3: $item`"I Voted!" sticker` }, 199 | ), 200 | completed: () => get("lastVoteMonsterTurn") === totalTurnsPlayed(), 201 | combat: new ChronerStrategy(() => Macro.redigitize().standardCombat()), 202 | sobriety: "either", 203 | }, 204 | { 205 | name: "Digitize Wanderer", 206 | ready: () => Counter.get("Digitize") <= 0, 207 | outfit: () => 208 | chooseQuestOutfit({ 209 | location: quest.location, 210 | isFree: get("_sourceTerminalDigitizeMonster")?.attributes.includes( 211 | "FREE", 212 | ), 213 | }), 214 | completed: () => 215 | get("_sourceTerminalDigitizeMonsterCount") !== digitizes, 216 | do: () => { 217 | adv1(quest.location, -1, ""); 218 | digitizes = get("_sourceTerminalDigitizeMonsterCount"); 219 | }, 220 | combat: new ChronerStrategy(() => Macro.redigitize().standardCombat()), 221 | sobriety: "either", 222 | }, 223 | { 224 | name: "Void Monster", 225 | ready: () => 226 | have($item`cursed magnifying glass`) && 227 | get("cursedMagnifyingGlassCount") === 13, 228 | completed: () => get("_voidFreeFights") >= 5, 229 | outfit: () => 230 | chooseQuestOutfit( 231 | { location: quest.location, isFree: true }, 232 | { offhand: $item`cursed magnifying glass` }, 233 | ), 234 | do: quest.location, 235 | sobriety: "sober", 236 | combat: new ChronerStrategy(() => Macro.standardCombat()), 237 | }, 238 | { 239 | name: "Work in the Future", 240 | ready: () => 241 | have($item`Spring Bros. ID badge`) && have($item`Boltsmann ID badge`), 242 | completed: () => get("_automatedFutureManufactures") >= 11, 243 | do: () => 244 | getBestAutomatedFutureSide() === "springbros" 245 | ? $location`Spring Bros. Solenoids` 246 | : $location`Boltsmann Bearings`, 247 | outfit: () => ({ 248 | acc1: 249 | getBestAutomatedFutureSide() === "springbros" 250 | ? $item`Spring Bros. ID badge` 251 | : $item`Boltsmann ID badge`, 252 | }), 253 | choices: { 254 | 1512: 1, 255 | 1513: 1, 256 | }, 257 | sobriety: "either", 258 | }, 259 | { 260 | name: "Time Capsule", 261 | ready: () => 262 | args.mode !== "rock" || 263 | get("_questCaveDan", 0) > 4 || 264 | get("lastCaveDanDefeat", 0) >= myAscensions(), 265 | do: () => { 266 | const turns = totalTurnsPlayed(); 267 | adv1($location`The Cave Before Time`, 0, ""); 268 | if ( 269 | totalTurnsPlayed() > turns && 270 | get("lastEncounter") !== "Time Cave. Period." 271 | ) { 272 | throw "We expected to force the NC"; 273 | } 274 | }, 275 | forced: true, 276 | sobriety: "either", 277 | completed: () => false, 278 | choices: { 955: 2 }, 279 | combat: new ChronerStrategy(() => Macro.standardCombat()), 280 | }, 281 | { 282 | name: "Spikolodon Spikes", 283 | ready: () => 284 | have($item`Jurassic Parka`) && 285 | have($skill`Torso Awareness`) && 286 | get("_spikolodonSpikeUses") < 5, 287 | outfit: () => 288 | chooseQuestOutfit( 289 | { location: quest.location }, 290 | { 291 | shirt: $item`Jurassic Parka`, 292 | }, 293 | ), 294 | do: quest.location, 295 | completed: () => get("noncombatForcerActive"), 296 | prepare: () => cliExecute("parka spikolodon"), 297 | combat: new ChronerStrategy(() => Macro.spikes().standardCombat()), 298 | sobriety: "sober", 299 | }, 300 | { 301 | name: "Bowling Ball Run", 302 | ready: () => 303 | get("cosmicBowlingBallReturnCombats") < 1 && 304 | get("hasCosmicBowlingBall") && 305 | !get("noncombatForcerActive"), 306 | do: $location`The Cave Before Time`, 307 | sobriety: "sober", 308 | completed: () => false, 309 | combat: new ChronerStrategy(() => { 310 | const romance = get("romanticTarget"); 311 | const freeMonsters = $monsters`sausage goblin`; 312 | if (romance?.attributes.includes("FREE")) freeMonsters.push(romance); 313 | return Macro.if_(freeMonsters, Macro.standardCombat()) 314 | .tryHaveSkill($skill`Curse of Weaksauce`) 315 | .trySkill($skill`Bowl a Curveball`) 316 | .abort(); 317 | }), 318 | }, 319 | { 320 | name: "Asdon Bumper", 321 | ready: () => AsdonMartin.installed() && !get("noncombatForcerActive"), 322 | completed: () => 323 | get("banishedMonsters").includes("Spring-Loaded Front Bumper"), 324 | sobriety: "sober", 325 | do: $location`The Cave Before Time`, 326 | combat: new ChronerStrategy(() => { 327 | const romance = get("romanticTarget"); 328 | const freeMonsters = $monsters`sausage goblin`; 329 | if (romance?.attributes.includes("FREE")) freeMonsters.push(romance); 330 | return Macro.if_(freeMonsters, Macro.standardCombat()) 331 | .skill($skill`Asdon Martin: Spring-Loaded Front Bumper`) 332 | .abort(); 333 | }), 334 | prepare: () => AsdonMartin.fillTo(50), 335 | }, 336 | { 337 | name: "Asdon Missile", 338 | ready: () => AsdonMartin.installed() && !get("noncombatForcerActive"), 339 | completed: () => get("_missileLauncherUsed"), 340 | combat: new ChronerStrategy(() => { 341 | const romance = get("romanticTarget"); 342 | const freeMonsters = $monsters`sausage goblin`; 343 | if (romance?.attributes.includes("FREE")) freeMonsters.push(romance); 344 | return Macro.if_(freeMonsters, Macro.standardCombat()) 345 | .tryHaveSkill($skill`Summon Mayfly Swarm`) 346 | .skill($skill`Asdon Martin: Missile Launcher`) 347 | .abort(); 348 | }), 349 | outfit: () => chooseQuestOutfit({ location: yrTarget, isFree: true }), 350 | prepare: () => AsdonMartin.fillTo(100), 351 | do: yrTarget, 352 | sobriety: "sober", 353 | }, 354 | { 355 | name: "Spit Jurassic Acid", 356 | completed: () => have($effect`Everything Looks Yellow`), 357 | ready: () => 358 | have($item`Jurassic Parka`) && 359 | have($skill`Torso Awareness`) && 360 | !get("noncombatForcerActive"), 361 | outfit: () => 362 | chooseQuestOutfit( 363 | { location: yrTarget, isFree: true }, 364 | { shirt: $item`Jurassic Parka` }, 365 | ), 366 | prepare: () => cliExecute("parka dilophosaur"), 367 | do: yrTarget, 368 | combat: new ChronerStrategy(() => { 369 | const romance = get("romanticTarget"); 370 | const freeMonsters = $monsters`sausage goblin`; 371 | if (romance?.attributes.includes("FREE")) freeMonsters.push(romance); 372 | return Macro.if_(freeMonsters, Macro.standardCombat()) 373 | .tryHaveSkill($skill`Summon Mayfly Swarm`) 374 | .skill($skill`Spit jurassic acid`) 375 | .abort(); 376 | }), 377 | sobriety: "sober", 378 | }, 379 | ], 380 | }; 381 | 382 | const engine = new ChronerEngine(getTasks([setup, global, quest])); 383 | engine.print(); 384 | 385 | const sessionStart = Session.current(); 386 | 387 | withProperty("recoveryScript", "", () => { 388 | try { 389 | engine.run(); 390 | } finally { 391 | engine.destruct(); 392 | } 393 | }); 394 | 395 | const sessionResults = Session.current().diff(sessionStart); 396 | 397 | printh(`SESSION RESULTS:`); 398 | for (const [item, count] of sessionResults.items.entries()) { 399 | const fn = targetItems.includes(item) ? printh : print; 400 | fn(`ITEM ${item} QTY ${count}`); 401 | } 402 | } 403 | --------------------------------------------------------------------------------