From e4b401f97ffda5b5c89282e0ef533baae1161fbd Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Sat, 17 Jul 2021 14:39:19 -0400 Subject: [PATCH] Add a 'creature_frequency_multiplier' TOML property. (#207) Fixes #191. Currently the implementation is fairly naive, in that a really large value for `creature_frequency_multiplier` will not be handled terribly efficiently, but we can optimize later if needed. --- assets/symbols/_template.toml | 7 +++++++ assets/symbols/wing_angel.toml | 4 +++- assets/symbols/wing_angel2.toml | 4 +++- assets/symbols/wing_bat.toml | 4 +++- assets/symbols/wing_butterfly.toml | 3 ++- assets/symbols/wing_egypt.toml | 4 +++- lib/distribution.test.ts | 11 +++++++++++ lib/distribution.ts | 27 ++++++++++++++++++++++++++ lib/pages/creature-page.tsx | 13 ++++++++++--- lib/svg-symbol-metadata.test.ts | 25 ++++++++++++++++++++++++ lib/svg-symbol-metadata.ts | 31 ++++++++++++++++++++++++++++++ lib/svg-symbol.tsx | 2 +- lib/vocabulary.test.ts | 19 ++++++++++++++++++ package.json | 5 ++--- 14 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 lib/distribution.test.ts create mode 100644 lib/distribution.ts create mode 100644 lib/vocabulary.test.ts diff --git a/assets/symbols/_template.toml b/assets/symbols/_template.toml index 4f5cc1c..44fcdb3 100644 --- a/assets/symbols/_template.toml +++ b/assets/symbols/_template.toml @@ -45,3 +45,10 @@ never_flip_attachments = false # If true, symbol may be used as large background shape in some compositions. background = false + +# Setting this to a positive integer (whole number) will multiply the +# likelihood that this symbol will be chosen from a random selection of symbols +# by the given amount when this symbol is used in a creature. For example, +# setting it to 2 will make it twice as likely to be chosen, setting it to 5 +# will make it five times more likely, and so on. +creature_frequency_multiplier = 1 diff --git a/assets/symbols/wing_angel.toml b/assets/symbols/wing_angel.toml index 6a7a178..105e3ab 100644 --- a/assets/symbols/wing_angel.toml +++ b/assets/symbols/wing_angel.toml @@ -1,4 +1,6 @@ never_be_nested = true -attach_to = ["leg", "arm", "horn"] \ No newline at end of file +attach_to = ["leg", "arm", "horn"] + +creature_frequency_multiplier = 40 \ No newline at end of file diff --git a/assets/symbols/wing_angel2.toml b/assets/symbols/wing_angel2.toml index 78d7ac5..824f5d8 100644 --- a/assets/symbols/wing_angel2.toml +++ b/assets/symbols/wing_angel2.toml @@ -4,4 +4,6 @@ never_be_nested = true rotate_clockwise = true -attach_to = ["leg", "arm", "horn"] \ No newline at end of file +attach_to = ["leg", "arm", "horn"] + +creature_frequency_multiplier = 50 \ No newline at end of file diff --git a/assets/symbols/wing_bat.toml b/assets/symbols/wing_bat.toml index 6a7a178..da859fd 100644 --- a/assets/symbols/wing_bat.toml +++ b/assets/symbols/wing_bat.toml @@ -1,4 +1,6 @@ never_be_nested = true -attach_to = ["leg", "arm", "horn"] \ No newline at end of file +attach_to = ["leg", "arm", "horn"] + +creature_frequency_multiplier = 50 \ No newline at end of file diff --git a/assets/symbols/wing_butterfly.toml b/assets/symbols/wing_butterfly.toml index 833fa42..055d675 100644 --- a/assets/symbols/wing_butterfly.toml +++ b/assets/symbols/wing_butterfly.toml @@ -4,4 +4,5 @@ rotate_clockwise = true never_be_nested = true -attach_to = ["tail", "leg", "arm", "horn", "crown"] \ No newline at end of file +attach_to = ["tail", "leg", "arm", "horn", "crown"] + diff --git a/assets/symbols/wing_egypt.toml b/assets/symbols/wing_egypt.toml index 6a7a178..105e3ab 100644 --- a/assets/symbols/wing_egypt.toml +++ b/assets/symbols/wing_egypt.toml @@ -1,4 +1,6 @@ never_be_nested = true -attach_to = ["leg", "arm", "horn"] \ No newline at end of file +attach_to = ["leg", "arm", "horn"] + +creature_frequency_multiplier = 40 \ No newline at end of file diff --git a/lib/distribution.test.ts b/lib/distribution.test.ts new file mode 100644 index 0000000..618b0a1 --- /dev/null +++ b/lib/distribution.test.ts @@ -0,0 +1,11 @@ +import { createDistribution } from "./distribution"; + +test("createDistribution() works", () => { + type Thing = { name: string; freq: number }; + const one: Thing = { name: "one", freq: 1 }; + const two: Thing = { name: "two", freq: 2 }; + + const dist = createDistribution([one, two], (thing) => thing.freq); + + expect(dist).toEqual([one, two, two]); +}); diff --git a/lib/distribution.ts b/lib/distribution.ts new file mode 100644 index 0000000..ca0c0d4 --- /dev/null +++ b/lib/distribution.ts @@ -0,0 +1,27 @@ +/** + * Return a probability distribution of the given Array. + * + * Each item may be repeated more than once in the return value, + * making it more likely for the item to be randomly chosen. + * + * @param items The list of items to create a distribution from. + * + * @param getFrequencyMultiplier A function that takes an item + * and returns a positive integer specifying how many times + * it should be included in the distribution. + */ +export function createDistribution( + items: T[], + getFrequencyMultiplier: (item: T) => number +) { + const result: T[] = []; + + for (let item of items) { + const freq = getFrequencyMultiplier(item); + for (let i = 0; i < freq; i++) { + result.push(item); + } + } + + return result; +} diff --git a/lib/pages/creature-page.tsx b/lib/pages/creature-page.tsx index 983a505..80fa8a5 100644 --- a/lib/pages/creature-page.tsx +++ b/lib/pages/creature-page.tsx @@ -32,6 +32,7 @@ import { import { Page } from "../page"; import { RandomizerWidget } from "../randomizer-widget"; import { VocabularyWidget } from "../vocabulary-widget"; +import { createDistribution } from "../distribution"; /** * The minimum number of attachment points that any symbol used as the main body @@ -41,8 +42,14 @@ import { VocabularyWidget } from "../vocabulary-widget"; */ const MIN_ROOT_ATTACHMENT_POINTS = 2; +const getFilteredDistribution = (predicate: (item: SvgSymbolData) => boolean) => + createDistribution( + SvgVocabulary.items.filter(predicate), + (s) => s.meta?.creature_frequency_multiplier ?? 1 + ); + /** Symbols that can be the "root" (i.e., main body) of a creature. */ -const ROOT_SYMBOLS = SvgVocabulary.items.filter( +const ROOT_SYMBOLS = getFilteredDistribution( (data) => data.meta?.always_be_nested !== true && Array.from(iterAttachmentPoints(data.specs || {})).length >= @@ -61,7 +68,7 @@ const ATTACHMENT_SYMBOLS: AttachmentSymbolMap = (() => { const result = {} as AttachmentSymbolMap; for (let type of ATTACHMENT_POINT_TYPES) { - result[type] = SvgVocabulary.items.filter((data) => { + result[type] = getFilteredDistribution((data) => { const { meta } = data; if (type === "wildcard") { @@ -91,7 +98,7 @@ const ATTACHMENT_SYMBOLS: AttachmentSymbolMap = (() => { })(); /** Symbols that can be nested within any part of a creature. */ -const NESTED_SYMBOLS = SvgVocabulary.items.filter( +const NESTED_SYMBOLS = getFilteredDistribution( // Since we don't currently support recursive nesting, ignore anything that // wants nested children. (data) => diff --git a/lib/svg-symbol-metadata.test.ts b/lib/svg-symbol-metadata.test.ts index 2c28609..6cdadb1 100644 --- a/lib/svg-symbol-metadata.test.ts +++ b/lib/svg-symbol-metadata.test.ts @@ -3,6 +3,7 @@ import fs from "fs"; import toml from "toml"; import { validateAttachTo, + validateFrequencyMultiplier, validateSvgSymbolMetadata, } from "./svg-symbol-metadata"; import { withMockConsoleLog } from "./test-util"; @@ -77,3 +78,27 @@ describe("validateAttachTo()", () => { ); }); }); + +describe("validateFrequencyMultiplier()", () => { + it("works", () => { + expect(validateFrequencyMultiplier(5)).toBe(5); + }); + + it("enforces minimum value", () => { + withMockConsoleLog((mockLog) => { + expect(validateFrequencyMultiplier(-1)).toBe(1); + expect(mockLog).toHaveBeenCalledWith( + "Frequency multiplier is less than minimum of 1." + ); + }); + }); + + it("ignores garbage values", () => { + withMockConsoleLog((mockLog) => { + expect(validateFrequencyMultiplier("barf")).toBeUndefined; + expect(mockLog).toHaveBeenCalledWith( + 'Frequency multiplier "barf" is not a number.' + ); + }); + }); +}); diff --git a/lib/svg-symbol-metadata.ts b/lib/svg-symbol-metadata.ts index 7259039..79d7dfc 100644 --- a/lib/svg-symbol-metadata.ts +++ b/lib/svg-symbol-metadata.ts @@ -62,6 +62,15 @@ export type SvgSymbolMetadata = SvgSymbolMetadataBooleans & { * be able to attach to any symbol. */ attach_to?: AttachmentPointType[]; + + /** + * Setting this to a positive integer (whole number) will multiply the + * likelihood that this symbol will be chosen from a random selection of symbols + * by the given amount when this symbol is used in a creature. For example, + * setting it to 2 will make it twice as likely to be chosen, setting it to 5 + * will make it five times more likely, and so on. + */ + creature_frequency_multiplier?: number; }; export function validateSvgSymbolMetadata(obj: any): { @@ -81,6 +90,10 @@ export function validateSvgSymbolMetadata(obj: any): { metadata[key] = value; } else if (key === "attach_to") { metadata.attach_to = validateAttachTo(obj[key]); + } else if (key === "creature_frequency_multiplier") { + metadata.creature_frequency_multiplier = validateFrequencyMultiplier( + obj[key] + ); } else { unknownProperties.push(key); } @@ -88,6 +101,24 @@ export function validateSvgSymbolMetadata(obj: any): { return { metadata, unknownProperties }; } +const MIN_FREQUENCY_MULTIPLIER = 1; + +export function validateFrequencyMultiplier( + value: unknown +): number | undefined { + if (typeof value === "number") { + if (value < MIN_FREQUENCY_MULTIPLIER) { + console.log( + `Frequency multiplier is less than minimum of ${MIN_FREQUENCY_MULTIPLIER}.` + ); + return MIN_FREQUENCY_MULTIPLIER; + } + return Math.floor(value); + } + console.log(`Frequency multiplier "${value}" is not a number.`); + return undefined; +} + export function validateAttachTo(value: unknown): AttachmentPointType[] { if (!Array.isArray(value)) { throw new Error( diff --git a/lib/svg-symbol.tsx b/lib/svg-symbol.tsx index 84a5fc8..438fed9 100644 --- a/lib/svg-symbol.tsx +++ b/lib/svg-symbol.tsx @@ -3,7 +3,7 @@ import { SVGProps } from "react"; import { BBox } from "../vendor/bezier-js"; import { FILL_REPLACEMENT_COLOR, STROKE_REPLACEMENT_COLOR } from "./colors"; import { AttachmentPointType, PointWithNormal, Specs } from "./specs"; -import type { SvgSymbolMetadata } from "./svg-symbol-metadata"; +import { SvgSymbolMetadata } from "./svg-symbol-metadata"; import { UniqueIdMap, URL_FUNC_TO_ANCHOR_RE, diff --git a/lib/vocabulary.test.ts b/lib/vocabulary.test.ts new file mode 100644 index 0000000..f5f4166 --- /dev/null +++ b/lib/vocabulary.test.ts @@ -0,0 +1,19 @@ +import { Vocabulary } from "./vocabulary"; + +describe("Vocabulary", () => { + type Thing = { name: string; freq: number }; + const one: Thing = { name: "one", freq: 1 }; + const two: Thing = { name: "two", freq: 2 }; + + const v = new Vocabulary([one, two]); + + it("gets items", () => { + expect(v.items).toEqual([one, two]); + expect(v.get("one")).toBe(one); + expect(v.get("two")).toBe(two); + }); + + it("raises exception on items not found", () => { + expect(() => v.get("boop")).toThrow('Unable to find the item "boop"!'); + }); +}); diff --git a/package.json b/package.json index 60d18b6..81255d6 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,8 @@ "moduleNameMapper": { "\\.(css)$": "/__mocks__/css-mock.js" }, - "testPathIgnorePatterns": [ - "dist", - ".cache" + "collectCoverageFrom": [ + "lib/**/*.{ts,tsx}" ] }, "dependencies": {