diff --git a/client/src/components/Monsters.tsx b/client/src/components/Monsters.tsx new file mode 100644 index 00000000..868811da --- /dev/null +++ b/client/src/components/Monsters.tsx @@ -0,0 +1,87 @@ +import { Text, Tooltip } from "@chakra-ui/react"; +import { + appearanceRates, + getLocationMonsters, + isBanished, + Location, + Monster, + trackCopyCount, + trackIgnoreQueue, +} from "kolmafia"; +import { $monster, getBanishedMonsters, sum } from "libram"; + +import { separate } from "../util/text"; + +export interface MonstersLineProps { + location: Location; + target: Monster | Monster[]; +} + +const Monsters: React.FC = ({ location, target }) => { + const targets = Array.isArray(target) ? target : [target]; + const monsters = Object.keys(getLocationMonsters(location)).map((name) => + Monster.get(name), + ); + const appearingMonsters = monsters.filter( + (monster) => + monster !== $monster`none` && + appearanceRates(location)[monster.name] !== 0, + ); + const queue = location.combatQueue + .split("; ") + .map((name) => Monster.get(name)); + + const monsterCopies = appearingMonsters.map((monster) => { + // TODO: Do banishes cancel out all copies? Or just Olfaction? + const copies = isBanished(monster) ? 0 : 1 + trackCopyCount(monster); + const reject = !trackIgnoreQueue(monster); + const copiesWithQueue = + (reject && queue.includes(monster) ? 0.25 : 1) * copies; + return { monster, copiesWithQueue }; + }); + + const totalCopiesWithQueue = sum( + monsterCopies, + ({ copiesWithQueue }) => copiesWithQueue, + ); + const monsterFrequency = monsterCopies.map( + ({ monster, copiesWithQueue }) => ({ + monster, + frequency: copiesWithQueue / totalCopiesWithQueue, + }), + ); + + monsterFrequency.sort(({ monster: x }, { monster: y }) => + targets.includes(x) ? -1 : targets.includes(y) ? 1 : 0, + ); + + return ( + <> + Monsters:{" "} + {separate( + monsterFrequency.map(({ monster, frequency }) => { + const text = `${monster.name} (${queue.includes(monster) ? "Q " : ""}${(100 * frequency).toFixed(0)}%)`; + const banisher = [...getBanishedMonsters().entries()].find( + ([, m]) => m === monster, + )?.[0]; + return targets.includes(monster) ? ( + {text} + ) : banisher ? ( + + + {text} + + + ) : ( + text + ); + }), + ", ", + monsterFrequency.map(({ monster }) => monster.id), + )} + . + + ); +}; + +export default Monsters; diff --git a/client/src/sections/QuestSection.tsx b/client/src/sections/QuestSection.tsx index d4e5d566..bfc79bbd 100644 --- a/client/src/sections/QuestSection.tsx +++ b/client/src/sections/QuestSection.tsx @@ -22,9 +22,9 @@ const QuestSection = () => { !get("kingLiberated") && myPath() !== $path`Community Service`; return (
+ {showStandardQuests && ( <> - diff --git a/client/src/sections/ResourceSection.tsx b/client/src/sections/ResourceSection.tsx index 920d2311..8fd07d6d 100644 --- a/client/src/sections/ResourceSection.tsx +++ b/client/src/sections/ResourceSection.tsx @@ -43,12 +43,15 @@ import MiniKiwi from "./resources/2024/MiniKiwi"; import RomanCandelabra from "./resources/2024/RomanCandelabra"; import SpringShoes from "./resources/2024/SpringShoes"; import TearawayPants from "./resources/2024/TearawayPants"; +import Banishes from "./resources/Banishes"; import FreeFights from "./resources/FreeFights"; // TODO: Organize by functionality, not release. const ResourceSection = () => (
+ + {/* 2020 */} diff --git a/client/src/sections/quests/Level7.tsx b/client/src/sections/quests/Level7.tsx index bb84a4c5..5e6f4402 100644 --- a/client/src/sections/quests/Level7.tsx +++ b/client/src/sections/quests/Level7.tsx @@ -12,9 +12,11 @@ import { itemDropModifier, monsterLevelAdjustment, } from "kolmafia"; -import { $item, get, have, questStep } from "libram"; +import { $item, $location, $monster, get, have, questStep } from "libram"; +import { ReactNode } from "react"; import Line from "../../components/Line"; +import Monsters from "../../components/Monsters"; import QuestTile from "../../components/QuestTile"; import Tile from "../../components/Tile"; import { NagPriority } from "../../contexts/NagContext"; @@ -33,7 +35,7 @@ const getZoneDisplay = ( zone: string, evil: number, quickInfo: string, - zoneStrategy: string[], + zoneStrategy: ReactNode[], ): JSX.Element | undefined => { if (evil > 0) { return ( @@ -43,8 +45,10 @@ const getZoneDisplay = ( {evil > 25 ? ( - {zoneStrategy.map((strat) => ( - {strat} + {zoneStrategy.map((strat, index) => ( + + {strat} + ))} ) : ( @@ -117,6 +121,11 @@ const Level7 = () => { ])} {getZoneDisplay("Niche", nicheEvil, "sniff dirty old lihc, banish", [ "banish all but dirty old lihc", + // TODO: Something wrong with this... + , ])} {getZoneDisplay("Cranny", crannyEvil, "+ML, -combat", [ `~${Math.max(3, Math.sqrt(monsterLevelAdjustment())).toFixed( diff --git a/client/src/sections/quests/Level8.tsx b/client/src/sections/quests/Level8.tsx index b9d2b464..159d75c9 100644 --- a/client/src/sections/quests/Level8.tsx +++ b/client/src/sections/quests/Level8.tsx @@ -1,7 +1,8 @@ import { itemAmount, numericModifier, toItem } from "kolmafia"; -import { $item, $location, get, have, questStep } from "libram"; +import { $item, $location, $monster, get, have, questStep } from "libram"; import Line from "../../components/Line"; +import Monsters from "../../components/Monsters"; import QuestTile from "../../components/QuestTile"; import faxLikes from "../../util/faxLikes"; import { atStep, Step } from "../../util/quest"; @@ -62,6 +63,12 @@ const Level8: React.FC = () => { )} . + {goatCheese < 3 && ( + + )} {ore < 3 && faxLikes.length > 0 && ( Could use {commaOr(faxLikes())} for a mountain man. )} diff --git a/client/src/sections/quests/Manor.tsx b/client/src/sections/quests/Manor.tsx index ab7103cd..eb3c87bb 100644 --- a/client/src/sections/quests/Manor.tsx +++ b/client/src/sections/quests/Manor.tsx @@ -1,7 +1,8 @@ -import { ListItem, UnorderedList } from "@chakra-ui/react"; +import { ListItem, Text, UnorderedList } from "@chakra-ui/react"; import { combatRateModifier, equippedAmount, + getCounter, haveEffect, haveEquipped, inBadMoon, @@ -12,41 +13,81 @@ import { myTurncount, numericModifier, } from "kolmafia"; -import { $effect, $item, $location, $path, get, have } from "libram"; +import { + $effect, + $item, + $location, + $monster, + $path, + $skill, + get, + have, +} from "libram"; import Line from "../../components/Line"; import MainLink from "../../components/MainLink"; +import Monsters from "../../components/Monsters"; import QuestTile from "../../components/QuestTile"; +import { haveUnrestricted } from "../../util/available"; import { inventoryLink, parentPlaceLink } from "../../util/links"; import { questFinished } from "../../util/quest"; -import { plural } from "../../util/text"; +import { commaAnd, commaOr, plural, truthy } from "../../util/text"; -const Manor: React.FC = () => { +const HauntedKitchen: React.FC = () => { const kitchen = $location`The Haunted Kitchen`; - const billiards = $location`The Haunted Billiards Room`; - const library = $location`The Haunted Library`; - const ballroom = $location`The Haunted Ballroom`; - - const billiardsKey = $item`Spookyraven billiards room key`; - const libraryKey = $item`[7302]Spookyraven library key`; - - const ballroomDelay = 5 - ballroom.turnsSpent; + const hotResistance = Math.min(numericModifier("Hot Resistance"), 9); + const stenchResistance = Math.min(numericModifier("Stench Resistance"), 9); + const drawersPerTurn = + 1 + Math.max(hotResistance / 6, 0) + Math.max(stenchResistance / 6, 0); + const drawersNeeded = Math.max(0, 21 - get("manorDrawerCount")); + const kitchenTurns = Math.ceil(drawersNeeded / drawersPerTurn) + 1; - const needBallroomSongSet = - myPath() === $path`Gelatinous Noob` || - inBadMoon() || - (get("lastQuartetAscension") < myAscensions() && - myTurncount() < 200 && - combatRateModifier() >= -25 && - ballroomDelay > 0); + // TODO: Pull out and use for Desert/8-Bit too. + const vhs = getCounter("Spooky VHS tape"); + const wandererSources = truthy([ + vhs >= 0 && `VHS tape in ${plural(vhs, "turn")}`, + haveUnrestricted($item`2002 Mr. Store Catalog`) && `later VHS tapes`, + haveUnrestricted($item`cursed magnifying glass`) && "void wanderers", + haveUnrestricted($skill`Just the Facts`) && + (get("_monsterHabitatsRecalled") < 3 || + get("_monsterHabitatsFightsLeft") > 0) && + "habitats", + ]); - const ballroomProbablyOpen = - ballroom.turnsSpent > 0 || questFinished("questM21Dance"); + return ( + <> + + Adventure in the Haunted Kitchen to unlock the Billiards Room. + + {wandererSources.length > 0 && ( + Place {commaOr(wandererSources)} for free progress. + )} + {hotResistance < 9 || + (stenchResistance < 9 && ( + + Run{" "} + {commaAnd([ + hotResistance < 9 && `${9 - hotResistance} more hot resistance`, + stenchResistance < 9 && + `${9 - stenchResistance} more stench resistance`, + ])}{" "} + to search faster. + + ))} + + {drawersPerTurn.toFixed(1)} drawers per turn.{" "} + {hotResistance >= 9 && stenchResistance >= 9 ? "" : "~"} + {plural(drawersNeeded, "drawer")} ({plural(kitchenTurns, "turn")}) left. + + {inebrietyLimit() > 10 && myInebriety() < 10 && ( + Try not to drink past ten, the billiards room is next. + )} + + ); +}; - const secondFloorProbablyOpen = - get("lastSecondFloorUnlock") >= myAscensions() || - questFinished("questM20Necklace") || - have($item`ghost of a necklace`); +const HauntedBilliardsRoom: React.FC = () => { + const billiards = $location`The Haunted Billiards Room`; const poolSkill = get("poolSkill") + numericModifier("Pool Skill"); const theoreticalHiddenPoolSkill = @@ -56,149 +97,135 @@ const Manor: React.FC = () => { theoreticalHiddenPoolSkill + Math.min(Math.floor(2 * Math.sqrt(get("poolSharkCount"))), 10); - const hotResistance = Math.min(numericModifier("Hot Resistance"), 9); - const stenchResistance = Math.min(numericModifier("Stench Resistance"), 9); - const drawersPerTurn = - 1 + Math.max(hotResistance / 6, 0) + Math.max(stenchResistance / 6, 0); - const drawersNeeded = Math.max(0, 21 - get("manorDrawerCount")); - const kitchenTurns = Math.ceil(drawersNeeded / drawersPerTurn) + 1; + return ( + <> + + Adventure in the Haunted Billiards Room to unlock the Library. + + + Train pool skill via -combat. Need 14 up to 18 total pool skill. Have ~ + {estimatedPoolSkill} pool skill. + + {estimatedPoolSkill < 18 && ( + + {myInebriety() < 10 && inebrietyLimit() >= 10 && ( + Drink to 10 inebrierty for +pool skill. + )} + {have($item`Staff of Ed, almost`) && ( + Untinker the Staff of Ed, almost. + )} + {have($item`[7964]Staff of Fats`) && + !haveEquipped($item`[7964]Staff of Fats`) && ( + Equip the Staff of Fats for +pool skill. + )} + {!have($item`pool cue`) && Find pool cue.} + {have($item`pool cue`) && + !equippedAmount($item`pool cue`) && + myPath() !== $path`Gelatinous Noob` && ( + + + Equip pool cue for +pool skill. + + + )} + {!haveEffect($effect`Chalky Hand`) && + have($item`handful of hand chalk`) && ( + + + Use handful of hand chalk for +pool skill and faster pool + skill training. + + + )} + + )} + + ); +}; + +const HauntedLibrary: React.FC = () => { + const library = $location`The Haunted Library`; const gnasirProgress = get("gnasirProgress"); const needKillingJar = !(gnasirProgress & 4); + return ( + <> + + Adventure in the Library to unlock the second floor. + + + Defeat{" "} + + {plural(5 - get("writingDesksDefeated", 0), "more writing desk")} + {" "} + to acquire a necklace. + + + + + {!have($item`killing jar`) && + get("desertExploration") < 100 && + needKillingJar && ( + + Try to acquire a killing jar to speed up the desert later. 10% drop + from banshee librarian. Use +900% item drop, YR, or pickpocket + mechanism. + + )} + + ); +}; + +const SecondFloor: React.FC = () => { const shoes = $item`Lady Spookyraven's dancing shoes`; const puff = $item`Lady Spookyraven's powder puff`; const gown = $item`Lady Spookyraven's finest gown`; - return ( - - {have($item`telegram from Lady Spookyraven`) && ( - - Read telegram from Lady Spookyraven - - )} - {!have(billiardsKey) && ( + return ( + <> + {get("questM21Dance") !== "finished" && ( // TODO: More detail here. <> - - Adventure in the Haunted Kitchen to unlock the Billiards Room. - - - Run {9 - hotResistance} more hot resistance and{" "} - {9 - stenchResistance} more stench resistance to search faster. - - - {drawersPerTurn.toFixed(1)} drawers searched per turn.{" "} - {hotResistance >= 9 && stenchResistance >= 9 ? "" : "~"} - {kitchenTurns} turns remaining, {drawersNeeded} drawers remaining. - - {inebrietyLimit() > 10 && myInebriety() < 10 && ( - Try not to drink past ten, the billiards room is next. + {!have(shoes) && ( + + Find Lady Spookyraven's dancing shoes in the Gallery. + )} - - )} - - {have(billiardsKey) && !have(libraryKey) && ( - <> - - Adventure in the Haunted Billiards Room to unlock the Library - - - Train pool skill via -combat. Need 14 up to 18 total pool skill. - Have ~{estimatedPoolSkill} pool skill. - - {estimatedPoolSkill < 18 && ( - - {myInebriety() < 10 && inebrietyLimit() >= 10 && ( - Drink to 10 inebrierty for +pool skill. - )} - {have($item`Staff of Ed, almost`) && ( - Untinker the Staff of Ed, almost. - )} - {have($item`[7964]Staff of Fats`) && - !haveEquipped($item`[7964]Staff of Fats`) && ( - Equip the Staff of Fats for +pool skill. - )} - {!have($item`pool cue`) && Find pool cue.} - {have($item`pool cue`) && - !equippedAmount($item`pool cue`) && - myPath() !== $path`Gelatinous Noob` && ( - - - Equip pool cue for +pool skill. - - - )} - {!haveEffect($effect`Chalky Hand`) && - have($item`handful of hand chalk`) && ( - - - Use handful of hand chalk for +pool skill and faster pool - skill training. - - - )} - + {!have(puff) && ( + + Find Lady Spookyraven's powder puff in the Bathroom. + )} - - )} - - {have(libraryKey) && !secondFloorProbablyOpen && ( - <> - - Adventure in the Library to unlock the second floor. - - - Defeat{" "} - {plural(5 - get("writingDesksDefeated", 0), "more writing desk")} to - acquire a necklace. - - {!have($item`killing jar`) && - get("desertExploration") < 100 && - needKillingJar && ( - - Try to acquire a killing jar to speed up the desert later. 10% - drop from banshee librarian. Use +900% item drop, YR, or - pickpocket mechanism. - - )} - - )} - - {secondFloorProbablyOpen && !ballroomProbablyOpen && ( - <> - {get("questM21Dance") !== "finished" && ( // TODO: More detail here. - <> - {!have(shoes) && ( - - Find Lady Spookyraven's dancing shoes in the Gallery. - - )} - {!have(puff) && ( - - Find Lady Spookyraven's powder puff in the Bathroom. - - )} - {!have(gown) && ( - - Find Lady Spookyraven's finest gown in the Bedroom. - - )} - {have(shoes) && have(puff) && have(gown) && ( - - Dance with Lady Spookyraven in the Haunted Ballroom. - - )} - + {!have(gown) && ( + + Find Lady Spookyraven's finest gown in the Bedroom. + + )} + {have(shoes) && have(puff) && have(gown) && ( + + Dance with Lady Spookyraven in the Haunted Ballroom. + )} )} + + ); +}; + +const HauntedBallroom: React.FC = () => { + const ballroom = $location`The Haunted Ballroom`; + const ballroomDelay = 5 - ballroom.turnsSpent; + const needBallroomSongSet = + get("lastQuartetAscension") < myAscensions() && + (myPath() === $path`Gelatinous Noob` || + inBadMoon() || + (myTurncount() < 200 && + combatRateModifier() >= -25 && + ballroomDelay > 0)); - {ballroomProbablyOpen && needBallroomSongSet && ( + return ( + <> + {needBallroomSongSet && ( <> Possibly set -combat ballroom song. @@ -209,12 +236,41 @@ const Manor: React.FC = () => { )} - {ballroomDelay > 0 && get("questL11Manor") !== "finished" && ( Burn {plural(ballroomDelay, "turn")} of delay in the Ballroom. )} + + ); +}; + +const Manor: React.FC = () => { + const billiardsKey = $item`Spookyraven billiards room key`; + const libraryKey = $item`[7302]Spookyraven library key`; + const ballroom = $location`The Haunted Ballroom`; + const ballroomProbablyOpen = + ballroom.turnsSpent > 0 || questFinished("questM21Dance"); + const secondFloorProbablyOpen = + get("lastSecondFloorUnlock") >= myAscensions() || + questFinished("questM20Necklace") || + have($item`ghost of a necklace`); + + return ( + + {have($item`telegram from Lady Spookyraven`) && ( + + Read telegram from Lady Spookyraven. + + )} + {!have(billiardsKey) && } + {have(billiardsKey) && !have(libraryKey) && } + {have(libraryKey) && !secondFloorProbablyOpen && } + {secondFloorProbablyOpen && !ballroomProbablyOpen && } + {ballroomProbablyOpen && } ); }; diff --git a/client/src/sections/resources/2023/SITCourseCertificate.tsx b/client/src/sections/resources/2023/SITCourseCertificate.tsx index 1c896d52..ea8f594f 100644 --- a/client/src/sections/resources/2023/SITCourseCertificate.tsx +++ b/client/src/sections/resources/2023/SITCourseCertificate.tsx @@ -9,6 +9,15 @@ import useNag from "../../../hooks/useNag"; import { haveUnrestricted } from "../../../util/available"; import { inRun } from "../../../util/quest"; +const MISC_PHRASES = [ + "Don't play hooky!", + "You already paid for it.", + "This one time in college...", + "Bright college days, oh, carefree days that fly.", + "No child of mine is leaving here without a degree!", + "Make like a tree and leaf (through your papers).", +]; + const SITCertificate = () => { const sitCertificate = $item`S.I.T. Course Completion Certificate`; const haveSit = haveUnrestricted(sitCertificate); @@ -22,17 +31,10 @@ const SITCertificate = () => { const hasAnySkill = havePsychogeologist || haveInsectologist || haveCryptobotanist; - const miscPhrases = [ - "Don't play hooky!", - "You already paid for it.", - "This one time in college...", - "Bright college days, oh, carefree days that fly.", - "No child of mine is leaving here without a degree!", - "Make like a tree and leaf (through your papers).", - ]; - - const randomPhrase = - miscPhrases[Math.floor(Math.random() * miscPhrases.length)]; + const randomPhrase = useMemo( + () => MISC_PHRASES[Math.floor(Math.random() * MISC_PHRASES.length)], + [], + ); const subtitle: ReactNode = useMemo(() => { if (havePsychogeologist) { diff --git a/client/src/sections/resources/Banishes.tsx b/client/src/sections/resources/Banishes.tsx new file mode 100644 index 00000000..dd19369e --- /dev/null +++ b/client/src/sections/resources/Banishes.tsx @@ -0,0 +1,26 @@ +import { Text } from "@chakra-ui/react"; + +import Line from "../../components/Line"; +import Tile from "../../components/Tile"; +import { activeBanishes } from "../../util/banish"; + +const Banishes: React.FC = () => { + const banishes = activeBanishes(); + return ( + + {banishes.map((banish, index) => ( + + {banish.banishedMonster.name}:{" "} + {banish.banishSource} ( + {banish.banishTurnLength === -1 + ? "Until Rollover" + : `${banish.banishTurnLength} turns`} + ) + + ))} + {banishes.length === 0 && No active banishes.} + + ); +}; + +export default Banishes; diff --git a/client/src/util/banish.ts b/client/src/util/banish.ts new file mode 100644 index 00000000..4cd3f169 --- /dev/null +++ b/client/src/util/banish.ts @@ -0,0 +1,128 @@ +import { + getProperty, + haveEffect, + isUnrestricted, + Monster, + toMonster, +} from "kolmafia"; +import { $effect, $item, get } from "libram"; + +export interface Banish { + banishedMonster: Monster; + banishSource: string; + turnBanished: number; + banishTurnLength: number; + customResetConditions: string; +} + +export const banishSourceLength: Record = { + "banishing shout": -1, + "batter up!": -1, + chatterboxing: 20, + "classy monkey": 20, + "cocktail napkin": 20, + "crystal skull": 20, + deathchucks: -1, + "dirty stinkbomb": -1, + "divine champagne popper": 5, + "harold's bell": 20, + "howl of the alpha": -1, + "ice house": -1, + "louder than bomb": 20, + nanorhino: -1, + pantsgiving: 30, + "peel out": -1, + "pulled indigo taffy": 40, + "smoke grenade": 20, + "spooky music box mechanism": -1, + "staff of the standalone cheese": -1, + "stinky cheese eye": 10, + "thunder clap": 40, + "v for vivala mask": 10, + "replica v for vivala mask": 10, + "walk away from explosion": 30, + "tennis ball": 30, + "curse of vacation": -1, + "ice hotel bell": -1, + 'bundle of "fragrant" herbs': -1, + snokebomb: 30, + beancannon: -1, + "licorice rope": -1, + "kgb tranquilizer dart": 20, + "breathe out": 20, + "daily affirmation: be a mind master": 80, + "spring-loaded front bumper": 30, + "mafia middle finger ring": 60, + "throw latte on opponent": 30, + "tryptophan dart": -1, + "baleful howl": -1, + "reflex hammer": 30, + "saber force": 30, + "human musk": -1, + "ultra smash": -1, + "b. l. a. r. t. spray (wide)": -1, + "system sweep": -1, + "feel hatred": 50, + "show your boring familiar pictures": 100, + "bowl a curveball": 5, + "patriotic screech": 100, + "roar like a lion": 30, + "monkey slap": -1, + "spring kick": -1, +}; + +export function activeBanishes(): Banish[] { + const banishedMonstersString = getProperty("banishedMonsters"); + const banishedMonstersSplit = banishedMonstersString.split(":"); + const banishes: Banish[] = []; + + for (let i = 0; i < banishedMonstersSplit.length; i += 3) { + if ( + banishedMonstersSplit[i] && + banishedMonstersSplit[i + 1] && + banishedMonstersSplit[i + 2] + ) { + const banish: Banish = { + banishedMonster: toMonster(banishedMonstersSplit[i]), + banishSource: banishedMonstersSplit[i + 1], + turnBanished: parseInt(banishedMonstersSplit[i + 2]), + banishTurnLength: 0, + customResetConditions: "", + }; + + const banishSource = banish.banishSource.toLowerCase(); + if (banishSourceLength[banishSource] !== undefined) { + banish.banishTurnLength = banishSourceLength[banishSource]; + } + if (banishSource === "bowl a curveball") { + banish.banishTurnLength = get("cosmicBowlingBallReturnCombats"); + } + if (banishSource === "roar like a lion") { + banish.banishTurnLength = haveEffect($effect`Hear Me Roar`); + } + if ( + [ + "batter up!", + "deathchucks", + "dirty stinkbomb", + "nanorhino", + "spooky music box mechanism", + "ice hotel bell", + "beancannon", + "monkey slap", + ].includes(banishSource) + ) { + banish.customResetConditions = "rollover"; + } + if ( + banishSource === "ice house" && + (!isUnrestricted($item`ice house`) || get("inBadMoon")) + ) { + continue; + } + banishes.push(banish); + } + } + + return banishes; +} diff --git a/client/src/util/text.tsx b/client/src/util/text.tsx index 64ecf623..76c23e42 100644 --- a/client/src/util/text.tsx +++ b/client/src/util/text.tsx @@ -27,6 +27,36 @@ export function plural( return `${count} ${pluralJustDesc(count, description, descriptionPlural)}`; } +export function separate( + values: string[] | ReactNode[] | AnyIdentified[], + separator: string, + keys?: string[] | number[], +) { + values = values.map((x) => (isIdentified(x) ? x.identifierString : x)); + // Show only truthy values. + values = truthy(values); + if (values.length === 0) return ""; + else if (values.length >= 1) { + if (values.every((value) => typeof value === "string")) { + return values.join(separator); + } else { + return ( + <> + {values.slice(0, -1).map((value, index) => ( + + {value} + {separator} + + ))} + {values[values.length - 1]} + + ); + } + } +} + export function commaList( values: string[] | ReactNode[] | AnyIdentified[], connector: string,