feat: revamped WorldSeedr (#341)

## Overview

Drastically simplifies the WorldSeedr package by reducing the different
kinds of variance allowed. Adds unit tests too.

Creates a second example, InfiniteContributionsCalendar, to demonstrate
WorldSeedr usage.

### PR Checklist

-   [x] Fixes #182
-   [x] I have run this code to verify it works
-   [x] This PR includes unit tests for the code change
This commit is contained in:
Josh Goldberg
2022-09-08 11:14:19 +10:00
committed by GitHub
parent 2e580f4f02
commit 5ae2f59099
33 changed files with 2275 additions and 1163 deletions
+4 -1
View File
@@ -29,7 +29,10 @@
],
// Off-by-default rules we want on
"simple-import-sort/imports": "error"
"simple-import-sort/imports": "error",
// On-by-default rules we want off
"no-case-declarations": "off"
},
"root": true
}
@@ -0,0 +1,4 @@
dist/
lib/
test/
webpack.config.js
@@ -0,0 +1,2 @@
*.tsbuildinfo
node_modules/
@@ -0,0 +1,39 @@
<!-- Top -->
# InfiniteContributionsCalendar
[![Code Style: Prettier](https://img.shields.io/badge/code_style-prettier-brightgreen.svg)](https://prettier.io)
![TypeScript: Strict](https://img.shields.io/badge/typescript-strict-brightgreen.svg)
[![NPM version](https://badge.fury.io/js/infinite-contributions-calendar.svg)](http://badge.fury.io/js/infinite-contributions-calendar)
[![Join the chat at https://gitter.im/FullScreenShenanigans/community](https://badges.gitter.im/FullScreenShenanigans/community.svg)](https://gitter.im/FullScreenShenanigans/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Generates an infinite GitHub-like contributions calendar using EightBittr and WorldSeedr.
<!-- /Top -->
## Usage
Documentation coming soon™️!
<!-- Development -->
## Development
This repository is a portion of the [EightBittr monorepo](https://raw.githubusercontent.com/FullScreenShenanigans/EightBittr).
See its [docs/Development.md](../../docs/Development.md) for details on how to get started. 💖
### Running Tests
```shell
yarn run test
```
Tests are written in [Mocha](https://github.com/mochajs/mocha) and [Chai](https://github.com/chaijs/chai).
Their files are written using alongside source files under `src/` and named `*.test.ts?`.
Whenever you add, remove, or rename a `*.test.t*` file under `src/`, `watch` will re-run `yarn run test:setup` to regenerate the list of static test files in `test/index.html`.
You can open that file in a browser to debug through the tests, or run `yarn test:run` to run them in headless Chrome.
<!-- Maps -->
<!-- /Maps -->
<!-- /Development -->
@@ -0,0 +1,104 @@
{
"browser": "./lib/index.js",
"bugs": {
"url": "https://github.com/FullScreenShenanigans/EightBittr/issues"
},
"dependencies": {
"eightbittr": "^0.8.8",
"userwrappr": "^0.8.8",
"worldseedr": "^0.8.8"
},
"description": "Generates an infinite GitHub-like contributions calendar using EightBittr and WorldSeedr.",
"devDependencies": {
"@types/chai": "^4.3.3",
"@types/mocha": "^9.1.1",
"@types/sinon": "^10.0.13",
"@types/sinon-chai": "^3.2.8",
"chai": "^4.3.6",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"mocha": "^10.0.0",
"mocha-headless-chrome": "^4.0.0",
"preact": "^10.10.2",
"requirejs": "^2.3.6",
"shenanigans-manager": "^0.8.8",
"sinon": "^14.0.0",
"sinon-chai": "^3.7.0",
"ts-loader": "^9.3.1",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"license": "MIT",
"lint-staged": {
"**/*.{css,js,json,md,ts,tsx}": [
"prettier --ignore-path .prettierignore --write"
]
},
"name": "infinite-contributions-calendar",
"package": {
"type": "git",
"url": "ssh://git@github.com:FullScreenShenanigans/EightBittr.git"
},
"scripts": {
"clean": "rm -rf dist lib *.tsbuildinfo",
"compile": "tsc -b",
"dist": "webpack",
"format": "prettier --ignore-path .prettierignore --check \"./**/*.{css,js,json,md,ts,tsx}\"",
"format:write": "yarn format --write",
"hydrate": "yarn shenanigans-manager hydrate",
"link": "yarn link",
"lint": "eslint --ignore-path .eslintignore \"./src/**/*.{ts,tsx}\"",
"prepare": "husky install",
"publish:ci": "yarn shenanigans-manager publish-if-updated",
"test": "yarn run test:setup && yarn run test:run",
"test:run": "yarn mocha-headless-chrome --file test/index.html --polling 1000",
"test:setup": "yarn shenanigans-manager generate-tests"
},
"shenanigans": {
"dist": true,
"example": true,
"game": true,
"loading": {
"externals": [
{
"js": {
"dev": "preact/dist/preact.umd",
"prod": "preact/dist/preact.umd"
},
"name": "preact"
},
{
"js": {
"dev": "preact/hooks/dist/hooks.umd",
"prod": "preact/hooks/dist/hooks.umd"
},
"name": "preact/hooks"
},
{
"js": {
"dev": "preact/jsx-runtime/dist/jsxRuntime.umd",
"prod": "preact/jsx-runtime/dist/jsxRuntime.umd"
},
"name": "preact/jsx-runtime"
},
{
"js": {
"dev": "userwrappr/dist/UserWrappr-Delayed",
"prod": "userwrappr/dist/UserWrappr-Delayed"
},
"name": "UserWrappr-Delayed",
"shenanigansPackage": true
}
]
},
"name": "InfiniteContributionsCalendar",
"web": true
},
"types": "./lib/index.d.ts",
"version": "0.8.14"
}
@@ -0,0 +1,76 @@
import { factory, member } from "autofieldr";
import { Actor, EightBittr, EightBittrConstructorSettings } from "eightbittr";
import { GroupHoldr } from "groupholdr";
import { WorldSeedr } from "worldseedr";
import { createWorldSeeder } from "./creators/createWorldSeeder";
import { Graphics } from "./sections/Graphics";
import { Groups } from "./sections/Groups";
import { Objects } from "./sections/Objects";
/**
* Generates an infinite GitHub-like contributions calendar using EightBittr and WorldSeedr.
*/
export class InfiniteContributionsCalendar extends EightBittr {
/**
* Stores the visual appearance of Actors.
*/
@member(Graphics)
public readonly graphics: Graphics<this>;
/**
* Collection settings for Actor group names.
*/
@member(Groups)
public readonly groups: Groups<this>;
/**
* General storage abstraction for keyed containers of items.
*/
public readonly groupHolder: GroupHoldr<{
Squares: Actor;
Players: Actor;
Text: Actor;
}>;
/**
* Raw ObjectMakr factory settings.
*/
@member(Objects)
public readonly objects: Objects<this>;
/**
* Schema-driven pseudorandom recursive generation of possibilities.
*/
@factory(createWorldSeeder)
public readonly worldSeeder: WorldSeedr;
/**
* Initializes a new instance of the InfiniteContributionsCalendar class.
*
* @param settings Settings to be used for initialization.
*/
public constructor(settings: EightBittrConstructorSettings) {
super(settings);
this.quadsKeeper.resetQuadrants();
const height = roundUpTo(settings.height, 12);
const width = roundUpTo(settings.width, 12);
const results = this.worldSeeder.generate("Area", {
bottom: 0,
left: 0,
right: width,
top: height,
});
for (const result of results) {
this.actors.add(result.title, result.area.left, height - result.area.top);
}
}
}
function roundUpTo(amount: number, rounder: number) {
return Math.ceil(amount / rounder) * rounder;
}
@@ -0,0 +1,47 @@
import { InfiniteContributionsCalendar } from "InfiniteContributionsCalendar";
import { WorldSeedr } from "worldseedr";
export const createWorldSeeder = (game: InfiniteContributionsCalendar) =>
new WorldSeedr({
possibilities: {
Area: {
children: {
size: {
height: game.settings.height,
width: 10,
},
title: "Column",
type: "Possibility",
},
direction: "right",
repeat: Infinity,
size: {
height: game.settings.height,
width: game.settings.width,
},
spacing: 2,
},
Column: {
children: {
size: {
height: 10,
width: 10,
},
title: ["SquareL0", "SquareL1", "SquareL2", "SquareL3", "SquareL4"].map(
(value) => ({
probability: 20,
value,
})
),
type: "Result",
},
direction: "bottom",
repeat: Infinity,
size: {
height: game.settings.height,
width: 10,
},
spacing: 2,
},
},
});
@@ -0,0 +1,15 @@
import { UserWrappr } from "userwrappr";
export * from "./InfiniteContributionsCalendar";
import { createUserWrapprSettings } from "./interface/InterfaceSettings";
/**
* Creates a UserWrappr interface around a new InfiniteContributionsCalendar game.
*
* @param container HTML element to create within.
*/
export const createInfiniteContributionsCalendarInterface = async (
container: HTMLElement
): Promise<void> => {
return new UserWrappr(createUserWrapprSettings()).createDisplay(container);
};
@@ -0,0 +1,149 @@
import { GameWindow } from "eightbittr";
import { AbsoluteSizeSchema, OptionType, UserWrapprSettings } from "userwrappr";
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
/**
* Global scope around a game, such as a DOM window.
*/
export interface WrappingGameWindow extends GameWindow {
/**
* Game instance, once this has created it.
*/
FSS?: InfiniteContributionsCalendar;
}
export interface InterfaceSettingOverrides {
createGame?(size: AbsoluteSizeSchema): InfiniteContributionsCalendar;
gameWindow?: WrappingGameWindow;
}
/**
* Friendly name of the default game size.
*/
const defaultSize = "Full";
/**
* Sizes the game is allowed to be, keyed by friendly name.
*/
const sizes = {
[defaultSize]: {
width: "100%",
height: "100%",
},
};
/**
* Creates settings for an UserWrappr that will create and wrap a InfiniteContributionsCalendar instance.
*
* @param gameWindow Global scope around the game interface, if not the global window.
*/
export const createUserWrapprSettings = ({
createGame = (size: AbsoluteSizeSchema) => new InfiniteContributionsCalendar(size),
gameWindow = window,
}: InterfaceSettingOverrides = {}): UserWrapprSettings => {
/**
* Game instance, once this has created it.
*/
let game: InfiniteContributionsCalendar;
return {
defaultSize: sizes[defaultSize],
createContents: (size: AbsoluteSizeSchema) => {
gameWindow.FSS = game = createGame(size);
game.inputs.initializeGlobalPipes(gameWindow);
game.frameTicker.play();
return game.container;
},
menus: [
{
options: [
{
action: (): void => {
game.utilities.takeScreenshot(
`InfiniteContributionsCalendar ${Date.now()}`
);
},
title: "Screenshot",
type: OptionType.Action,
},
],
title: "Options",
},
],
styles: {
input: {
fontFamily: "Press Start",
minWidth: "117px",
padding: "3px",
},
inputButton: {
background: "#ffcc33",
fontFamily: "Press Start",
padding: "7px 3px",
},
inputButtonAction: {
padding: "11px 3px",
width: "100%",
},
inputButtonBoolean: {
padding: "7px 21px",
},
inputButtonOff: {
background: "#ccaa33",
},
inputSelect: {
minWidth: "35px",
padding: "3px 0",
},
option: {
alignItems: "center",
margin: "auto",
padding: "7px 0",
maxWidth: "calc(100% - 14px)",
},
options: {
left: "4px",
right: "4px",
width: "auto",
padding: "4px 3px 21px",
boxShadow: ["0 3px 7px black inset", "0 0 0 4px #99ccff", "0 0 14px black"].join(
", "
),
background: "#005599",
},
optionsList: {
margin: "0 0 21px",
},
menu: {
maxWidth: "385px",
minWidth: "280px",
padding: "7px",
zIndex: "1",
},
menusInnerArea: {
background: "black",
color: "white",
fontFamily: "Press Start",
transition: "700ms color",
},
menuTitle: {
fontSize: "16px",
},
menuTitleButton: {
alignItems: "center",
background: "none",
border: "none",
color: "white",
display: "flex",
fontFamily: "Press Start",
fontSize: "16px",
justifyContent: "center",
},
menuTitleButtonFake: {
color: "grey",
},
},
};
};
@@ -0,0 +1,9 @@
import { createInfiniteContributionsCalendarInterface } from "./index";
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const container = document.getElementById("game")!;
createInfiniteContributionsCalendarInterface(container).catch((error: Error): void => {
console.error("An error happened while trying to instantiate InfiniteContributionsCalendar!");
console.error("requirejs error:", error);
});
@@ -0,0 +1,31 @@
import { Graphics as EightBittrGraphics } from "eightbittr";
import { Palette } from "pixelrendr";
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
export class Graphics<
Game extends InfiniteContributionsCalendar
> extends EightBittrGraphics<Game> {
public readonly background = "white";
public readonly library = {
SquareL0: "0x18,0x180,0x18,0",
SquareL1: "0x28,0x280,0x28,0",
SquareL2: "0x38,0x380,0x38,0",
SquareL3: "0x48,0x480,0x48,0",
SquareL4: "0x58,0x580,0x58,0",
};
public readonly paletteDefault: Palette = [
[0, 0, 0, 0],
[255, 255, 255, 255],
// --color-calendar-graph-day-L1-bg: #9be9a8;
[155, 233, 168, 255],
// --color-calendar-graph-day-L2-bg: #40c463;
[64, 196, 99, 255],
// --color-calendar-graph-day-L3-bg: #30a14e;
[48, 161, 78, 255],
// --color-calendar-graph-day-L4-bg: #216e39;
[33, 110, 57, 255],
];
}
@@ -0,0 +1,7 @@
import { Groups as EightBittrGroups } from "eightbittr";
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
export class Groups<Game extends InfiniteContributionsCalendar> extends EightBittrGroups<Game> {
public readonly groupNames = ["Players", "Squares", "Text"];
}
@@ -0,0 +1,34 @@
import { Objects as ObjectsBase } from "eightbittr";
import { ClassInheritance, ClassProperties } from "objectmakr";
import { InfiniteContributionsCalendar } from "../InfiniteContributionsCalendar";
/**
* Raw ObjectMakr factory settings.
*/
export class Objects<Game extends InfiniteContributionsCalendar> extends ObjectsBase<Game> {
public readonly inheritance: ClassInheritance = {
Actor: {
Square: {
SquareL0: {},
SquareL1: {},
SquareL2: {},
SquareL3: {},
SquareL4: {},
},
},
};
public readonly onMake = "onMake";
public readonly properties: ClassProperties = {
Actor: {
onMake: this.game.actors.process.bind(this.game.actors),
},
Square: {
height: 10,
groupType: "Squares",
width: 10,
},
};
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"baseUrl": "./src",
"declaration": true,
"declarationMap": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"module": "amd",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "./lib",
"pretty": true,
"rootDir": "./src",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"strictFunctionTypes": false,
"strictPropertyInitialization": false,
"target": "ES2021"
},
"exclude": ["**/*.d.ts", "**/lib"],
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { member } from "autofieldr";
import { FilterContainer, Palette } from "pixelrendr";
import { FilterContainer, LibraryRaws, Palette } from "pixelrendr";
import { EightBittr } from "../EightBittr";
import { Actor } from "../types";
@@ -34,7 +34,7 @@ export class Graphics<Game extends EightBittr> extends Section<Game> {
/**
* A nested library of sprites to process.
*/
public readonly library?: any;
public readonly library?: LibraryRaws;
/**
* The default palette of colors to use for sprites.
-35
View File
@@ -146,41 +146,6 @@ export class NumberMakr {
return this.seed;
}
/**
* @returns The length of the state vector.
*/
public getStateLength(): number {
return this.stateLength;
}
/**
* @returns The length of the state vector.
*/
public getStatePeriod(): number {
return this.statePeriod;
}
/**
* @returns The length of the state vector.
*/
public getMatrixA(): number {
return this.matrixA;
}
/**
* @returns The length of the state vector.
*/
public getMaskUpper(): number {
return this.maskUpper;
}
/**
* @returns The length of the state vector.
*/
public getMaskLower(): number {
return this.maskLower;
}
/**
* Initializes state from a new seed.
*
+400 -1
View File
@@ -7,10 +7,409 @@
[![NPM version](https://badge.fury.io/js/worldseedr.svg)](http://badge.fury.io/js/worldseedr)
[![Join the chat at https://gitter.im/FullScreenShenanigans/community](https://badges.gitter.im/FullScreenShenanigans/community.svg)](https://gitter.im/FullScreenShenanigans/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Automates random, recursive generation of possibilities from JSON schemas.
Schema-driven pseudorandom recursive generation of possibilities.
<!-- /Top -->
## Usage
WorldSeedr allows generating descriptions of objects in a two-dimensional space based on probabilities in input schemas.
Think random map generation for video games, particularly rogue-likes.
```ts
const worldSeeder = new WorldSeedr({
possibilities: {
Stage: {
children: [
{
probability: 80,
value: {
size: 3,
title: "GoodActor",
type: "Result",
},
},
{
probability: 20,
value: {
size: 3,
title: "GreatActor",
type: "Result",
},
},
],
direction: "right",
repeat: Infinity,
size: {
height: 8,
width: 6,
},
},
},
random: ((values) => {
let index = -1;
return () => values[(index = (index + 1) % values.length)];
})([0.1, 0.9]),
});
worldSeeder.generate("Stage", { bottom: 0, left: 0, top: 8, right: 12 });
```
```ts
// Randomized results may look something like:
[
{ area: { left: 0, right: 3, bottom: 0, top: 8 }, title: "GoodActor" },
{ area: { left: 3, right: 6, bottom: 0, top: 8 }, title: "GreatActor" },
];
```
WorldSeedr's constructor takes in an object that may have two properties:
- `possibilities` _(required)_: Object mapping names of possibilities to their details
- `random` _(optional)_: A replacement for `Math.random`
### `possibilities`
Each Possibility represents a set of instructions for placing children along a line.
Each of the following properties of the `possibilities` object may be provided either as a single value or as an array of objects with a `probability` number between 0 and 100 and `value`.
Probability arrays will be chosen from at random, with weighting based on the probability.
For example, the `children` in this `Obstacle` Possibility have an 60% change of placing nothing, a 30% chance of placing two `"Enemy"`s, and a 10% chance of placing a `"DifficultEnemy"`:
```ts
new WorldSeedr({
possibilities: {
Obstacle: {
children: [
{
probability: 60,
value: [],
},
{
probability: 30,
value: [
{
size: 2,
title: "Enemy",
type: "Result",
},
{
size: 2,
title: "Enemy",
type: "Result",
},
],
},
{
probability: 10,
value: {
size: 4,
title: "DifficultEnemy",
type: "Result",
},
},
],
direction: "right",
size: {
height: 4,
width: 4,
},
},
},
});
```
#### `children`
An ordered array of descendants to place in the output space.
These each have a `type` property equal to either:
- `"Possibility"`: Indicating to recurse on another Possibility
- `"Result"`: Indicating the child should be directly returned as results
Both types of children must have a `title` property indicating the name of their specified output.
##### Possibility Children
It's common for Possibilities to recursively include other Possibilities, or even themselves.
WorldSeedr will call `.generate` on the nested Possibility within the space allocated for the child.
For example, given this WorldSeedr that defines a `"Dungeon"` Possibility as having a 50-50 chance of either recursing into a `"HappySpace"` Possibility or an `"UnhappyPlace"` Possibility:
```ts
const worldSeeder = new WorldSeedr({
possibilities: {
Dungeon: {
children: [
{
probability: 50,
value: {
size: 10,
title: "HappySpace",
type: "Possibility",
},
},
{
probability: 50,
value: {
size: 10,
title: "UnhappySpace",
type: "Possibility",
},
},
],
direction: "right",
repeat: 2,
size: {
height: 10,
width: 20,
},
},
HappySpace: {
children: [
{
probability: 70,
value: [],
},
{
probability: 30,
value: {
size: 10,
title: "Item",
type: "Result",
},
},
],
direction: "right",
size: {
height: 10,
width: 10,
},
},
UnhappySpace: {
children: [
{
probability: 80,
value: {
size: 10,
title: "EasyEnemy",
type: "Result",
},
},
{
probability: 20,
value: {
size: 10,
title: "HardEnemy",
type: "Result",
},
},
],
direction: "right",
size: {
height: 10,
width: 10,
},
},
},
});
```
...generated output might look something like:
```ts
[
{
// area: { ... }
title: "HardEnemy",
},
{
// area: { ... }
title: "Item",
},
];
```
##### Result Children
Result children may optionally include a `properties` object to be added to the results
For example, given this `children.value` that only adds `properties` to one of its two results:
```ts
[
{
// size: ...
title: "Scenery",
type: "Result",
},
{
// size: ...
properties: {
dialog: "Roar!",
},
title: "Enemy",
type: "Result",
},
];
```
...generated output might look something like:
```ts
[
{
// area: { ... },
title: "Scenery",
},
{
// area: { ... },
properties: {
dialog: "Roar!",
},
title: "Enemy",
},
];
```
Note that results children, like other properties, can come in three forms:
- Direct value: like `children: { ... }`
- Array of values: like `children: [ { ... }, { ... } ]`
- Array of value probabilities: like `children: [ { probability: 100, value: { ... }]`
#### `align`
Possibility children won't always take up all the space to their side (perpendicular to their direction).
By default, they align to `"stretch"`: taking up all available space to their side.
They can alternately be given an `align` property set to one of the directions to align to.
For example, given this Possibility snippet that aligns one result to the default `"stretch"`, one to `"bottom"`, and one to `"top"`:
```ts
{
children: [
{
size: {
height: 5,
width: 5,
},
title: "First",
type: "Result",
},
{
align: "bottom",
size: {
height: 5,
width: 5,
},
title: "Second",
type: "Result",
},
{
align: "top",
size: {
height: 5,
width: 5,
},
title: "Third",
type: "Result",
},
],
direction: "right",
size: {
height: 15,
width: 15,
}
}
```
...generated output in a 15x15 space might look like:
```ts
[
{
area: {
left: 0,
right: 5,
bottom: 0,
top: 15,
},
title: "First",
},
{
area: {
left: 5,
right: 10,
bottom: 0,
top: 15,
},
title: "Second",
},
{
area: {
left: 10,
right: 15,
bottom: 0,
top: 15,
},
title: "Third",
},
];
```
#### `direction`
Possibility children are placed along one of the cardinal directions.
The `direction` property may be one of:
- `"bottom"`: Start at the top edge of the available space, and go downwards
- `"left"`: Start at the right edge of the available space, and go to the left
- `"right"`: Start at the left edge of the available space, and go to the right
- `"top"`: Start at the bottom edge of the available space, and go upwards
#### `repeat`
How many times the children should be repeated.
It defaults to `1`, for only placing the children value(s) once.
> Tip: use `Infinity` to continuously repeat children as many times as allowed.
#### `size`
How much space this takes up, as an object containing `height` and `width`.
#### `spacing`
How much space should be between children.
It defaults to `0`, for no space between them.
The `spacing` property may be one of:
- A number: always use much space between children
- An object describing a range of random numbers:
- `max`: Maximum amount for the spacing
- `min`: Minimum amount for the spacing
- `roundTo`: Number unit to round to, if not 1 (no rounding)
- An array of objects containing `probability` and `value` numbers, where the values are the objects describing a range of random numbers
### `random`
Applications that use deterministic seeds to generate maps generally need to use their own random number generator instead of `Math.random()`.
See [NumberMakr](../numbermakr/README.md) for the recommended EightBittr package.
```ts
const numberMaker = new NumberMakr();
const worldSeeder = new WorldSeedr({
possibilities: {
/* ... */
},
random: () => numberMaker.random(),
});
```
### `generate`
<!-- Development -->
## Development
+1 -1
View File
@@ -7,7 +7,7 @@
"bugs": {
"url": "https://github.com/FullScreenShenanigans/EightBittr/issues"
},
"description": "Automates random, recursive generation of possibilities from JSON schemas.",
"description": "Schema-driven pseudorandom recursive generation of possibilities.",
"devDependencies": {
"@types/chai": "^4.3.3",
"@types/mocha": "^9.1.1",
@@ -0,0 +1,116 @@
import { expect } from "chai";
import { RandomChooser } from "./RandomChooser";
describe("RandomChooser", () => {
describe("chooseAmong", () => {
it("returns the only choice when there is only one", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0.99);
const value = "val";
// Act
const result = randomChooser.chooseAmong([{ probability: 100, value }]);
// Assert
expect(result).to.be.equal(value);
});
it("returns the first choice when its probability is the first reached", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0.5);
const value = "first";
// Act
const result = randomChooser.chooseAmong([
{ probability: 60, value },
{ probability: 40, value: "incorrect" },
]);
// Assert
expect(result).to.be.equal(value);
});
it("returns the second choice when its probability is the first reached", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0.5);
const value = "first";
// Act
const result = randomChooser.chooseAmong([
{ probability: 40, value: "incorrect" },
{ probability: 60, value },
]);
// Assert
expect(result).to.be.equal(value);
});
it("throws an error when the goal probability is never reached", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0.99);
const value = "val";
// Act
const act = () => randomChooser.chooseAmong([{ probability: 5, value }]);
// Assert
expect(act).to.throw("Choices only reached sum 5 out of goal 99.");
});
});
describe("chooseFixedOrRandom", () => {
it("returns the value when given a value", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0);
const value = "val";
// Act
const result = randomChooser.chooseFixedOrRandom(value);
// Assert
expect(result).to.be.equal(value);
});
it("returns a choice value when given choices", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0.5);
const value = "first";
// Act
const result = randomChooser.chooseFixedOrRandom([
{ probability: 40, value: "incorrect" },
{ probability: 60, value },
]);
// Assert
expect(result).to.be.equal(value);
});
});
describe("chooseFixedOrRandomOr", () => {
it("returns the default value when the value doesn't exist", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0);
const value = "val";
// Act
const result = randomChooser.chooseFixedOrRandomOr(undefined, value);
// Assert
expect(result).to.be.equal(value);
});
it("returns the value when given a value", () => {
// Arrange
const randomChooser = new RandomChooser(() => 0);
const value = "val";
// Act
const result = randomChooser.chooseFixedOrRandomOr(value, "incorrect");
// Assert
expect(result).to.be.equal(value);
});
});
});
+75
View File
@@ -0,0 +1,75 @@
import { FixedOrRandomized, RandomizedValue, RandomNumberGenerator } from "./types";
/**
* Chooses values from arrays of weighted random values.
*/
export class RandomChooser {
public constructor(
/**
* Random number generator that returns a decimal within [0, 1).
*/
private readonly random: RandomNumberGenerator
) {}
/**
* Chooses a value from an array of weighted random values.
*
* @param choices Weighted choices to pick from.
* @returns Random value from the choices.
*/
public chooseAmong<Value>(choices: RandomizedValue<Value>[]) {
const goal = this.randomBetween(0, 100);
let sum = 0;
for (const choice of choices) {
sum += choice.probability;
if (sum >= goal) {
return choice.value;
}
}
throw new Error(`Choices only reached sum ${sum} out of goal ${goal}.`);
}
/**
* Chooses a value from either just the value or an array of weighted random values.
*
* @param possibility The value or weighted choices to pick from.
* @returns Random value from the choices.
*/
public chooseFixedOrRandom<Value>(possibility: FixedOrRandomized<Value>) {
return isRandomizedPossibility<Value>(possibility)
? this.chooseAmong(possibility)
: possibility;
}
/**
* Chooses a value from an array of weighted random values, or a default if no values exist.
*
* @param possibility Weighted choices to pick from.
* @returns Random value from the choices, or the default value.
*/
public chooseFixedOrRandomOr<Value>(
possibility: FixedOrRandomized<Value> | undefined,
defaultValue: Value
) {
return possibility === undefined ? defaultValue : this.chooseFixedOrRandom(possibility);
}
/**
* Random number generator that returns a decimal within [min, max).
*
* @param min Minimum number to return within.
* @param max Maximum number to return within.
* @returns Number within [min, max] at random.
*/
public randomBetween(min: number, max: number) {
return Math.floor(this.random() * (max - min + 1)) + min;
}
}
function isRandomizedPossibility<Value>(
possibility: FixedOrRandomized<Value>
): possibility is RandomizedValue<Value>[] {
return Array.isArray(possibility) && "probability" in possibility[0];
}

Some files were not shown because too many files have changed in this diff Show More