feat: initial commit

This commit is contained in:
Naomi Carrigan 2024-09-26 11:37:00 -07:00
commit 1339b63378
86 changed files with 12036 additions and 0 deletions

73
.eslintrc.json Normal file
View File

@ -0,0 +1,73 @@
{
"env": {
"es2020": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"plugin:jsdoc/recommended",
"plugin:import/recommended",
"plugin:import/typescript"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "jsdoc", "import"],
"rules": {
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double", { "allowTemplateLiterals": true }],
"semi": ["error", "always"],
"prefer-const": "error",
"eqeqeq": ["error", "always"],
"curly": ["error"],
"require-atomic-updates": ["error"],
"no-var": ["error"],
"camelcase": ["error"],
"init-declarations": ["error", "always"],
"require-await": ["error"],
"no-param-reassign": ["error"],
"jsdoc/require-jsdoc": [
"error",
{
"require": {
"ArrowFunctionExpression": true,
"ClassDeclaration": true,
"ClassExpression": true,
"FunctionDeclaration": true,
"FunctionExpression": true,
"MethodDefinition": true
},
"publicOnly": true
}
],
"jsdoc/require-description-complete-sentence": "error",
"import/first": "error",
"import/exports-last": "error",
"import/newline-after-import": "error",
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type",
"unknown"
],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
}
}

8
.gitattributes vendored Normal file
View File

@ -0,0 +1,8 @@
# Auto detect text files and perform LF normalization
* text eol=LF
*.ts text
*.spec.ts text
# Ignore binary files >:(
*.png binary
*.jpg binary

38
.github/workflows/node-ci.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Node.js CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
ci:
name: Lint / Build / Test
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- name: Checkout Source Files
uses: actions/checkout@v2
- name: Use Node.js v${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: npm ci
- name: Lint Source Files
run: npm run lint
- name: Verify Build
run: npm run build
- name: Run Tests
run: npm run test

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/node_modules/
.env
/prod/

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"endOfLine": "lf",
"useTabs": false,
"singleQuote": false
}

6
Readme.md Normal file
View File

@ -0,0 +1,6 @@
This is a bot that does Tingle things.
## Dice Roller
* **^roll \< XdY \>** Roll dice of Y sides X times
* ^roll 1d20
* ^roll 3d4

7722
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "tinglebot",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"lint": "eslint src --max-warnings 0",
"start": "node -r dotenv/config prod/index.js",
"test": "ts-mocha -u tdd tests/**/*.spec.ts"
},
"author": "",
"license": "ISC",
"engines": {
"node": "16.11.0",
"npm": "8.0.0"
},
"devDependencies": {
"@types/chai": "^4.3.0",
"@types/mocha": "^9.1.0",
"@types/node-schedule": "^1.3.2",
"@types/sharp": "^0.29.5",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
"chai": "^4.3.6",
"eslint": "^8.8.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsdoc": "^37.7.1",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^9.2.0",
"prettier": "^2.5.1",
"ts-mocha": "^9.0.2",
"typescript": "^4.5.5"
},
"dependencies": {
"@discordjs/builders": "^0.12.0",
"@discordjs/rest": "^0.3.0",
"@sentry/integrations": "^6.17.7",
"@sentry/node": "^6.17.7",
"discord.js": "^13.6.0",
"dotenv": "^16.0.0",
"node-schedule": "^2.1.0",
"sharp": "^0.30.1",
"uuid": "^8.3.2",
"winston": "^3.6.0"
}
}

File diff suppressed because it is too large Load Diff

6
sample.env Normal file
View File

@ -0,0 +1,6 @@
DISCORD_TOKEN=""
HOME_GUILD_ID=""
RUDANIA_ID=""
INARIKO_ID=""
VHINTL_ID=""
DEBUG_HOOK=""

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
src/assets/overlay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

BIN
src/assets/seasons/fall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,6 @@
import { forecast } from "./forecast";
/**
* TODO: Migrate this to an automated import.
*/
export const CommandList = [forecast];

36
src/commands/forecast.ts Normal file
View File

@ -0,0 +1,36 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import { ForecastChoices } from "../config/ForecastChoices";
import { Command } from "../interfaces/commands/Command";
import { RegionName } from "../interfaces/weather/names/RegionName";
import { generateWeatherEmbed } from "../modules/generateWeatherEmbed";
import { getWeatherForecast } from "../modules/getWeatherForecast";
import { errorHandler } from "../utils/errorHandler";
export const forecast: Command = {
data: new SlashCommandBuilder()
.setName("forecast")
.setDescription("Get the weather forecast for a specific region.")
.addStringOption((option) =>
option
.setName("region")
.setDescription("The region to get a forecast for.")
.setRequired(true)
.addChoices(ForecastChoices)
),
run: async (interaction, CACHE) => {
try {
await interaction.deferReply();
const region = interaction.options.getString(
"region",
true
) as RegionName;
const forecast = CACHE[region] || getWeatherForecast(region);
const response = await generateWeatherEmbed(forecast);
await interaction.editReply(response);
} catch (err) {
const response = await errorHandler(err, "forecast command");
await interaction.editReply(response);
}
},
};

View File

@ -0,0 +1,7 @@
import { RegionName } from "../interfaces/weather/names/RegionName";
export const ForecastChoices: [string, RegionName][] = [
["Rudania", "Rudania"],
["Inariko", "Inariko"],
["Vhintl", "Vhintl"],
];

View File

@ -0,0 +1,3 @@
import { IntentsString } from "discord.js";
export const IntentOptions: IntentsString[] = ["GUILDS"];

View File

@ -0,0 +1,163 @@
import { Precipitation } from "../../interfaces/weather/Precipitation";
export const precipitations: Precipitation[] = [
{
name: "Blizzard",
temps: ["Cold", "Freezing", "Frigid"],
winds: ["Strong", "Gale", "Storm", "Hurricane"],
emote: "❄️",
},
{
name: "Cinder Storm",
temps: "any",
winds: ["Strong", "Gale", "Storm", "Hurricane"],
emote: "🔥",
},
{
name: "Cloudy",
temps: "any",
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "☁️",
},
{
name: "Fog",
temps: "any",
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌫️",
},
{
name: "Hail",
temps: "any",
winds: "any",
emote: "☁️🧊",
},
{
name: "Heat Lightning",
temps: ["Warm", "Hot", "Scorching", "Heat Wave"],
winds: "any",
emote: "🌡️⚡",
},
{
name: "Heavy Rain",
temps: [
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌧️",
},
{
name: "Heavy Snow",
temps: ["Chilly", "Cold", "Freezing", "Frigid"],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌨️",
},
{
name: "Light Rain",
temps: [
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "☔",
},
{
name: "Light Snow",
temps: ["Chilly", "Cold", "Freezing", "Frigid"],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌨️",
},
{
name: "Partly Cloudy",
temps: "any",
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "⛅",
},
{
name: "Rain",
temps: [
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌧️",
},
{
name: "Rainbow",
temps: "any",
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌈",
},
{
name: "Sleet",
temps: ["Brisk", "Chilly"],
winds: "any",
emote: "☁️🧊",
},
{
name: "Snow",
temps: ["Chilly", "Cold", "Freezing", "Frigid"],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌨️",
},
{
name: "Sun Shower",
temps: [
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: ["Gale", "Strong", "Fresh", "Moderate", "Breeze", "Calm"],
emote: "🌦️",
},
{
name: "Sunny",
temps: "any",
winds: "any",
emote: "☀️",
},
{
name: "Thundersnow",
temps: ["Chilly", "Cold", "Freezing", "Frigid"],
winds: "any",
emote: "🌨️⚡",
},
{
name: "Thunderstorm",
temps: [
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: "any",
emote: "⛈️",
},
];

View File

@ -0,0 +1,154 @@
import { RegionRestriction } from "../../../interfaces/weather/regions/RegionRestriction";
export const inarikoSeasons: RegionRestriction[] = [
{
season: "Winter",
temps: ["Frigid", "Freezing", "Cold", "Chilly", "Brisk"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Blizzard",
"Cloudy",
"Fog",
"Hail",
"Heavy Rain",
"Heavy Snow",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Sleet",
"Snow",
"Sunny",
"Thundersnow",
"Thunderstorm",
],
special: ["Avalanche", "Meteor Shower", "Blight Rain"],
},
{
season: "Spring",
temps: ["Chilly", "Brisk", "Cool", "Mild", "Perfect", "Warm"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heavy Rain",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Snow",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Blight Rain",
],
},
{
season: "Summer",
temps: ["Mild", "Perfect", "Warm", "Hot", "Scorching"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heat Lightning",
"Heavy Rain",
"Light Rain",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Blight Rain",
],
},
{
season: "Fall",
temps: ["Cold", "Chilly", "Brisk", "Cool", "Mild", "Perfect"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Blizzard",
"Cloudy",
"Fog",
"Hail",
"Heat Lightning",
"Heavy Rain",
"Heavy Snow",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Snow",
"Sunny",
"Thundersnow",
"Thunderstorm",
],
special: [
"Avalanche",
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Blight Rain",
],
},
];

View File

@ -0,0 +1,153 @@
import { RegionRestriction } from "../../../interfaces/weather/regions/RegionRestriction";
export const rudaniaSeasons: RegionRestriction[] = [
{
season: "Winter",
temps: ["Cold", "Chilly", "Brisk", "Cool", "Mild", "Perfect"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heavy Rain",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Snow",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: ["Flood", "Meteor Shower", "Rock Slide", "Blight Rain"],
},
{
season: "Spring",
temps: ["Brisk", "Cool", "Mild", "Perfect", "Warm", "Hot", "Scorching"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cinder Storm",
"Cloudy",
"Fog",
"Hail",
"Heavy Rain",
"Light Rain",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Drought",
"Fairy Circle",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Blight Rain",
],
},
{
season: "Summer",
temps: ["Mild", "Perfect", "Warm", "Hot", "Scorching", "Heat Wave"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cinder Storm",
"Cloudy",
"Fog",
"Hail",
"Heat Lightning",
"Heavy Rain",
"Light Rain",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Drought",
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Rock Slide",
"Blight Rain",
],
},
{
season: "Fall",
temps: ["Cold", "Chilly", "Brisk", "Cool", "Mild", "Perfect"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cinder Storm",
"Cloudy",
"Fog",
"Hail",
"Heat Lightning",
"Heavy Rain",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Drought",
"Fairy Circle",
"Flower Bloom",
"Meteor Shower",
"Muggy",
"Rock Slide",
"Blight Rain",
],
},
];

View File

@ -0,0 +1,147 @@
import { RegionRestriction } from "../../../interfaces/weather/regions/RegionRestriction";
export const vhintlSeasons: RegionRestriction[] = [
{
season: "Winter",
temps: ["Cold", "Chilly", "Brisk", "Cool", "Mild"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heavy Rain",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Sleet",
"Snow",
"Sunny",
"Thundersnow",
"Thunderstorm",
],
special: ["Fairy Circle", "Meteor Shower", "Blight Rain"],
},
{
season: "Spring",
temps: ["Chilly", "Brisk", "Cool", "Mild", "Perfect", "Warm", "Hot"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heat Lightning",
"Heavy Rain",
"Light Rain",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Blight Rain",
],
},
{
season: "Summer",
temps: ["Mild", "Perfect", "Warm", "Hot", "Scorching", "Heat Wave"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heat Lightning",
"Heavy Rain",
"Light Rain",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sun Shower",
"Sunny",
"Thunderstorm",
],
special: [
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Blight Rain",
],
},
{
season: "Fall",
temps: ["Cold", "Chilly", "Brisk", "Cool", "Mild", "Perfect"],
wind: [
"Calm",
"Breeze",
"Moderate",
"Fresh",
"Strong",
"Gale",
"Storm",
"Hurricane",
],
precipitation: [
"Cloudy",
"Fog",
"Hail",
"Heavy Rain",
"Light Rain",
"Light Snow",
"Partly Cloudy",
"Rain",
"Rainbow",
"Sleet",
"Sunny",
"Thundersnow",
"Thunderstorm",
],
special: [
"Fairy Circle",
"Flood",
"Flower Bloom",
"Jubilee",
"Meteor Shower",
"Muggy",
"Blight Rain",
],
},
];

View File

@ -0,0 +1,114 @@
import { Special } from "../../interfaces/weather/Special";
export const specials: Special[] = [
{
name: "Avalanche",
temps: ["Chilly", "Cold", "Freezing", "Frigid"],
winds: "any",
precipitations: ["Snow"],
description:
"There has been an avalanche and some roads are blocked! Travel to and from this village today is impossible.",
emote: "🏔️",
},
{
name: "Blight Rain",
temps: [
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: "any",
precipitations: ["Rain"],
description:
"Blighted rain falls from the sky, staining the ground and creating sickly maroon-tinged puddles... if you roll for gathering today you must also `/tableroll blightrain` to see if you get infected! If you miss doing this roll with your gather, blighting will be automatic.",
emote: "🌧️🧿",
},
{
name: "Drought",
temps: ["Scorching", "Heat Wave"],
winds: "any",
precipitations: ["Sunny"],
description:
"A drought has dried up the smaller vegetation surrounding the village... any plants or mushrooms rolled today are found dead and will not be gathered.",
emote: "🌵",
},
{
name: "Fairy Circle",
temps: "any",
winds: "any",
precipitations: "any",
description:
"Fairy circles have popped up all over Hyrule! All residents and visitors may use `/tableroll fairycircle` to gather mushrooms today!",
emote: "🍄",
},
{
name: "Flood",
temps: [
"Cold",
"Chilly",
"Brisk",
"Cool",
"Mild",
"Perfect",
"Warm",
"Hot",
"Scorching",
"Heat Wave",
],
winds: "any",
precipitations: ["Rain"],
description:
"There has been a Flood! Travelling to and from this village is impossible today due to the danger.",
emote: "🌊",
},
{
name: "Flower Bloom",
temps: ["Perfect", "Warm", "Hot", "Scorching", "Heat Wave"],
winds: "any",
precipitations: "any",
description:
"An overabundance of plants and flowers have been spotted growing in and around the village! All residents and visitors may `/tableroll flowerbloom` to gather today!",
emote: "🌼",
},
{
name: "Jubilee",
temps: "any",
winds: "any",
precipitations: "any",
description:
"Fish are practically jumping out of the water! All residents and visitors may `/tableroll jubilee` to catch some fish!",
emote: "🐟",
},
{
name: "Meteor Shower",
temps: "any",
winds: "any",
precipitations: ["Sunny"],
description:
"Shooting starts have been spotted streaking through the sky! Quick, all residents and visitors make a wish and use `/tableroll meteorshower` for a chance to find a star fragment!",
emote: "☄️",
},
{
name: "Muggy",
temps: ["Perfect", "Warm", "Hot", "Scorching", "Heat Wave"],
winds: "any",
precipitations: ["Rain", "Fog", "Cloudy"],
description:
"Oof! Sure is humid today! Critters are out and about more than usual. All residents and visitors may use `/tableroll muggy` to catch some critters!",
emote: "🐛",
},
{
name: "Rock Slide",
temps: "any",
winds: "any",
precipitations: "any",
description:
"Oh no there's been a rock slide! Travelling to and from this village is impossible today. All residents and visitors may use `/tableroll rockslide` to help clear the road! You might just find something interesting while you work...",
emote: "⛏️",
},
];

View File

@ -0,0 +1,76 @@
import { Temperature } from "../../interfaces/weather/Temperature";
export const temperatures: Temperature[] = [
{
fahrenheit: 0,
celsius: -18,
name: "Frigid",
emote: "🥶",
},
{
fahrenheit: 8,
celsius: -14,
name: "Freezing",
emote: "🐧",
},
{
fahrenheit: 24,
celsius: -4,
name: "Cold",
emote: "☃️",
},
{
fahrenheit: 36,
celsius: 2,
name: "Chilly",
emote: "🧊",
},
{
fahrenheit: 44,
celsius: 6,
name: "Brisk",
emote: "🔷",
},
{
fahrenheit: 52,
celsius: 11,
name: "Cool",
emote: "🆒",
},
{
fahrenheit: 61,
celsius: 16,
name: "Mild",
emote: "😐",
},
{
fahrenheit: 72,
celsius: 22,
name: "Perfect",
emote: "👌",
},
{
fahrenheit: 24,
celsius: 28,
name: "Warm",
emote: "🌡️",
},
{
fahrenheit: 89,
celsius: 32,
name: "Hot",
emote: "🌶️",
},
{
fahrenheit: 97,
celsius: 36,
name: "Scorching",
emote: "🥵",
},
{
fahrenheit: 100,
celsius: 38,
name: "Heat Wave",
emote: "💯",
},
];

52
src/data/weather/winds.ts Normal file
View File

@ -0,0 +1,52 @@
import { Wind } from "../../interfaces/weather/Wind";
export const winds: Wind[] = [
{
name: "Calm",
lowSpeed: 0,
highSpeed: 1,
emote: "😌",
},
{
name: "Breeze",
lowSpeed: 2,
highSpeed: 12,
emote: "🎐",
},
{
name: "Moderate",
lowSpeed: 13,
highSpeed: 30,
emote: "🍃",
},
{
name: "Fresh",
lowSpeed: 31,
highSpeed: 40,
emote: "🌬️",
},
{
name: "Strong",
lowSpeed: 41,
highSpeed: 62,
emote: "💫",
},
{
name: "Gale",
lowSpeed: 63,
highSpeed: 87,
emote: "💨",
},
{
name: "Storm",
lowSpeed: 88,
highSpeed: 117,
emote: "🌀",
},
{
name: "Hurricane",
lowSpeed: 118,
highSpeed: 150,
emote: "🌪️",
},
];

View File

@ -0,0 +1,21 @@
import { Client } from "discord.js";
import { WeatherCache } from "../interfaces/WeatherCache";
import { onInteraction } from "./handlers/onInteraction";
import { onReady } from "./handlers/onReady";
/**
* Mounts listeners for the Discord gateway events.
*
* @param {Client} BOT The bot's discord instance.
* @param { WeatherCache } CACHE The cache of weather data.
*/
export const handleEvents = (BOT: Client, CACHE: WeatherCache) => {
BOT.on("ready", async () => await onReady(BOT, CACHE));
BOT.on(
"interactionCreate",
async (interaction) => await onInteraction(interaction, CACHE)
);
};

View File

@ -0,0 +1,31 @@
import { Interaction } from "discord.js";
import { CommandList } from "../../commands/_CommandList";
import { WeatherCache } from "../../interfaces/WeatherCache";
/**
* Handles the INTERACTION_CREATE event from Discord. Checks if the interaction is
* an application command, and if there is a matching command, and runs it.
*
* @param {Interaction} interaction The interaction payload from Discord.
* @param { WeatherCache } CACHE The cache of weather data.
*/
export const onInteraction = async (
interaction: Interaction,
CACHE: WeatherCache
) => {
if (!interaction.isCommand()) {
return;
}
const target = CommandList.find(
(command) => command.data.name === interaction.commandName
);
if (!target) {
await interaction.reply(`Command ${interaction.commandName} not found.`);
return;
}
await target.run(interaction, CACHE);
};

View File

@ -0,0 +1,40 @@
import { REST } from "@discordjs/rest";
import { Routes } from "discord-api-types/v9";
import { Client, WebhookClient } from "discord.js";
import { CommandList } from "../../commands/_CommandList";
import { WeatherCache } from "../../interfaces/WeatherCache";
import { scheduleForecasts } from "../../modules/scheduleForecasts";
import { logHandler } from "../../utils/logHandler";
/**
* Handler for the READY event from Discord. Logs that the bot is connected,
* then registers the guild slash commands.
*
* @param {Client} BOT The bot's Discord instance.
* @param {WeatherCache} CACHE The cache of weather data.
*/
export const onReady = async (BOT: Client, CACHE: WeatherCache) => {
const webhook = new WebhookClient({ url: process.env.DEBUG_HOOK as string });
await webhook.send("Ruu Bot is online!");
logHandler.log("info", "Connected to Discord!");
const rest = new REST({ version: "9" }).setToken(
process.env.DISCORD_TOKEN as string
);
const commandData = CommandList.map((command) => command.data.toJSON());
await rest.put(
Routes.applicationGuildCommands(
BOT.user?.id || "oopsie whoopsie",
process.env.HOME_GUILD_ID as string
),
{ body: commandData }
);
logHandler.log("info", "Registered commands!");
scheduleForecasts(CACHE);
};

37
src/index.ts Normal file
View File

@ -0,0 +1,37 @@
import { RewriteFrames } from "@sentry/integrations";
import * as Sentry from "@sentry/node";
import { Client } from "discord.js";
import { IntentOptions } from "./config/IntentOptions";
import { handleEvents } from "./events/handleEvents";
import { WeatherCache } from "./interfaces/WeatherCache";
import { getWeatherForecast } from "./modules/getWeatherForecast";
import { loadChannels } from "./modules/loadChannels";
import { errorHandler } from "./utils/errorHandler";
(async () => {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
integrations: [
new RewriteFrames({
root: global.__dirname,
}),
],
});
const BOT = new Client({ intents: IntentOptions });
const CACHE: WeatherCache = {
Rudania: getWeatherForecast("Rudania"),
Inariko: getWeatherForecast("Inariko"),
Vhintl: getWeatherForecast("Vhintl"),
channels: await loadChannels(BOT),
};
handleEvents(BOT, CACHE);
await BOT.login(process.env.DISCORD_TOKEN as string).catch(
async (err) => await errorHandler(err, "login")
);
})();

View File

@ -0,0 +1,14 @@
import { NewsChannel, TextChannel } from "discord.js";
import { WeatherForecast } from "./weather/WeatherForecast";
export interface WeatherCache {
Rudania: WeatherForecast | null;
Inariko: WeatherForecast | null;
Vhintl: WeatherForecast | null;
channels: {
Rudania: TextChannel | NewsChannel;
Inariko: TextChannel | NewsChannel;
Vhintl: TextChannel | NewsChannel;
};
}

View File

@ -0,0 +1,4 @@
export interface AttachmentData {
attachmentString: string;
filePath: string;
}

View File

@ -0,0 +1,14 @@
import {
SlashCommandBuilder,
SlashCommandSubcommandsOnlyBuilder,
} from "@discordjs/builders";
import { CommandInteraction } from "discord.js";
import { WeatherCache } from "../WeatherCache";
export interface Command {
data:
| Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">
| SlashCommandSubcommandsOnlyBuilder;
run: (interaction: CommandInteraction, CACHE: WeatherCache) => Promise<void>;
}

View File

@ -0,0 +1,10 @@
import { PrecipitationName } from "./names/PrecipitationName";
import { TemperatureName } from "./names/TemperatureName";
import { WindName } from "./names/WindName";
export interface Precipitation {
name: PrecipitationName;
temps: TemperatureName[] | "any";
winds: WindName[] | "any";
emote: string;
}

View File

@ -0,0 +1,13 @@
import { PrecipitationName } from "./names/PrecipitationName";
import { SpecialName } from "./names/SpecialName";
import { TemperatureName } from "./names/TemperatureName";
import { WindName } from "./names/WindName";
export interface Special {
name: SpecialName;
temps: TemperatureName[] | "any";
winds: WindName[] | "any";
precipitations: PrecipitationName[] | "any";
description: string;
emote: string;
}

View File

@ -0,0 +1,8 @@
import { TemperatureName } from "./names/TemperatureName";
export interface Temperature {
fahrenheit: number;
celsius: number;
name: TemperatureName;
emote: string;
}

View File

@ -0,0 +1,15 @@
import { RegionName } from "./names/RegionName";
import { Season } from "./names/Season";
import { Precipitation } from "./Precipitation";
import { Special } from "./Special";
import { Temperature } from "./Temperature";
import { Wind } from "./Wind";
export interface WeatherForecast {
region: RegionName;
season: Season;
temperature: Temperature;
wind: Wind;
precipitation: Precipitation | null;
special: Special | null;
}

View File

@ -0,0 +1,8 @@
import { WindName } from "./names/WindName";
export interface Wind {
lowSpeed: number;
highSpeed: number;
name: WindName;
emote: string;
}

View File

@ -0,0 +1,20 @@
export type PrecipitationName =
| "Blizzard"
| "Cinder Storm"
| "Cloudy"
| "Fog"
| "Hail"
| "Heat Lightning"
| "Heavy Rain"
| "Heavy Snow"
| "Light Rain"
| "Light Snow"
| "Partly Cloudy"
| "Rain"
| "Rainbow"
| "Sleet"
| "Snow"
| "Sun Shower"
| "Sunny"
| "Thundersnow"
| "Thunderstorm";

View File

@ -0,0 +1 @@
export type RegionName = "Rudania" | "Inariko" | "Vhintl";

View File

@ -0,0 +1 @@
export type Season = "Spring" | "Summer" | "Fall" | "Winter";

View File

@ -0,0 +1,11 @@
export type SpecialName =
| "Avalanche"
| "Blight Rain"
| "Drought"
| "Fairy Circle"
| "Flood"
| "Flower Bloom"
| "Jubilee"
| "Meteor Shower"
| "Muggy"
| "Rock Slide";

View File

@ -0,0 +1,13 @@
export type TemperatureName =
| "Frigid"
| "Freezing"
| "Cold"
| "Chilly"
| "Brisk"
| "Cool"
| "Mild"
| "Perfect"
| "Warm"
| "Hot"
| "Scorching"
| "Heat Wave";

View File

@ -0,0 +1,9 @@
export type WindName =
| "Calm"
| "Breeze"
| "Moderate"
| "Fresh"
| "Strong"
| "Gale"
| "Storm"
| "Hurricane";

View File

@ -0,0 +1,13 @@
import { PrecipitationName } from "../names/PrecipitationName";
import { Season } from "../names/Season";
import { SpecialName } from "../names/SpecialName";
import { TemperatureName } from "../names/TemperatureName";
import { WindName } from "../names/WindName";
export interface RegionRestriction {
season: Season;
temps: TemperatureName[];
wind: WindName[];
precipitation: PrecipitationName[];
special: SpecialName[];
}

View File

@ -0,0 +1,69 @@
import { MessageEmbed, MessagePayload } from "discord.js";
import { WeatherForecast } from "../interfaces/weather/WeatherForecast";
import { generateBanner } from "./images/generateBanner";
import { getSeasonIcon } from "./images/getSeasonIcon";
/**
* Parses a weather forecast into a Discord message embed.
*
* @param {WeatherForecast} forecast The forecast to parse.
* @returns {MessageEmbed} A Discord message embed.
*/
export const generateWeatherEmbed = async (
forecast: WeatherForecast | null
): Promise<MessagePayload["options"]> => {
if (!forecast) {
const embed = new MessageEmbed()
.setTitle("Error")
.setDescription("No forecast was generated.");
return { embeds: [embed] };
}
const weatherEmbed = new MessageEmbed();
weatherEmbed.setTitle(`${forecast.region}'s Daily Weather Forecast`);
let emoteString = forecast.temperature.emote + forecast.wind.emote;
weatherEmbed.addField(
"Temperature",
`${forecast.temperature.fahrenheit}°F / ${forecast.temperature.celsius}°C [${forecast.temperature.name}]`
);
weatherEmbed.addField(
"Wind",
`${forecast.wind.name} [${forecast.wind.lowSpeed} - ${forecast.wind.highSpeed}kph]`
);
if (forecast.precipitation) {
weatherEmbed.addField("Precipitation", `${forecast.precipitation.name}`);
emoteString += forecast.precipitation.emote;
}
if (forecast.special) {
weatherEmbed.addField("Description", `${forecast.special.description}`);
emoteString += forecast.special.emote;
}
weatherEmbed.setDescription(emoteString);
const seasonIcon = getSeasonIcon(forecast.season);
weatherEmbed.setThumbnail(seasonIcon.attachmentString);
const banner = await generateBanner(forecast);
weatherEmbed.setImage(banner.attachmentString);
switch (forecast.region) {
case "Rudania":
weatherEmbed.setColor("RED");
break;
case "Inariko":
weatherEmbed.setColor("BLUE");
break;
case "Vhintl":
weatherEmbed.setColor("GREEN");
break;
}
return {
embeds: [weatherEmbed],
files: [seasonIcon.filePath, banner.filePath],
};
};

View File

@ -0,0 +1,26 @@
/**
* Module to select a random value from an array.
*
* @param {any[]} array The array to select from.
* @param {"low" | "high"} weight Optional parameter to prefer lower or higher values.
* @returns {any} The selected value.
*/
export const getRandomValue = <T>(array: T[], weight?: "low" | "high"): T => {
let random = Math.floor(Math.random() * array.length);
switch (weight) {
case "low":
random = Math.min(
Math.floor(Math.random() * array.length),
Math.floor(Math.random() * array.length),
Math.floor(Math.random() * array.length)
);
break;
case "high":
random = Math.max(
Math.floor(Math.random() * array.length),
Math.floor(Math.random() * array.length),
Math.floor(Math.random() * array.length)
);
}
return array[random];
};

View File

@ -0,0 +1,53 @@
import { temperatures } from "../data/weather/temperatures";
import { winds } from "../data/weather/winds";
import { RegionName } from "../interfaces/weather/names/RegionName";
import { WeatherForecast } from "../interfaces/weather/WeatherForecast";
import { errorHandler } from "../utils/errorHandler";
import { getRandomValue } from "./getRandomValue";
import { getPrecipitation } from "./weather/getPrecipitation";
import { getRegionRestrictions } from "./weather/getRegionRestrictions";
import { getSeason } from "./weather/getSeason";
import { getSpecial } from "./weather/getSpecial";
/**
* Generates a weather forecast for the specified region, using the current
* date to determine the season.
*
* @param {RegionName} region The name of the region to forecast for.
* @returns {WeatherForecast | null} The weather forecast.
*/
export const getWeatherForecast = (
region: RegionName
): WeatherForecast | null => {
try {
const season = getSeason();
const allowedWeather = getRegionRestrictions(region, season);
if (!allowedWeather) {
return null;
}
const tempName = getRandomValue(allowedWeather.temps);
const temperature = temperatures.find((el) => el.name === tempName);
const windName = getRandomValue(allowedWeather.wind, "low");
const wind = winds.find((el) => el.name === windName);
if (!temperature || !wind) {
return null;
}
const precipitation = getPrecipitation(allowedWeather, tempName, windName);
const special = getSpecial(
allowedWeather,
tempName,
windName,
precipitation?.name
);
return { region, season, temperature, wind, precipitation, special };
} catch (err) {
errorHandler(err, "get weather forecast");
return null;
}
};

View File

@ -0,0 +1,37 @@
import { AttachmentData } from "../../interfaces/commands/AttachmentData";
import { WeatherForecast } from "../../interfaces/weather/WeatherForecast";
import { getBannerImage } from "./getBannerImage";
import { getOverlayImage } from "./getOverlayImage";
import { overlayImages } from "./overlayImages";
/**
* Generates the banner image. If an overlay is available, constructs a new banner image by overlaying the overlay on the banner.
* Otherwise, returns the banner itself.
*
* @param {WeatherForecast} forecast The weather forecast.
* @returns {AttachmentData} The banner image attachment data.
*/
export const generateBanner = async (
forecast: WeatherForecast
): Promise<AttachmentData> => {
const background = getBannerImage(forecast.region);
const overlayQuery =
forecast.special?.name === "Blight Rain"
? "Blight Rain"
: forecast.precipitation?.name;
if (!overlayQuery) {
return background;
}
const overlayPath = getOverlayImage(overlayQuery);
if (!overlayPath) {
return background;
}
const overlay = await overlayImages(background.filePath, overlayPath);
return overlay;
};

View File

@ -0,0 +1,19 @@
import { join } from "path";
import { AttachmentData } from "../../interfaces/commands/AttachmentData";
import { RegionName } from "../../interfaces/weather/names/RegionName";
/**
* Selects one of the random banner images from the banner folder.
*
* @param {RegionName} region The name of the region.
* @returns {AttachmentData} The banner image attachment data.
*/
export const getBannerImage = (region: RegionName): AttachmentData => {
const fileName = `${region}${Math.ceil(Math.random() * 3)}.png`;
const filePath = join(process.cwd(), "src", "assets", "banners", fileName);
return {
attachmentString: `attachment://${fileName}`,
filePath,
};
};

View File

@ -0,0 +1,68 @@
import { join } from "path";
import { PrecipitationName } from "../../interfaces/weather/names/PrecipitationName";
import { SpecialName } from "../../interfaces/weather/names/SpecialName";
/**
* Checks if the current weather conditions have an overlay.
*
* @param {SpecialName | PrecipitationName} name The name of the weather condition to look for.
* @returns {string | null} The file path to the overlay, or null if there is no overlay.
*/
export const getOverlayImage = (
name: SpecialName | PrecipitationName
): string | null => {
let fileName = null;
switch (name) {
case "Blight Rain":
fileName = "ROOTS-blightrain.png";
break;
case "Blizzard":
fileName = "ROOTS-blizzard.png";
break;
case "Cinder Storm":
fileName = "ROOTS-cinderstorm.png";
break;
case "Cloudy":
case "Partly Cloudy":
fileName = "ROOTS-cloudy.png";
break;
case "Fog":
fileName = "ROOTS-fog.png";
break;
case "Hail":
fileName = "ROOTS-hail.png";
break;
case "Heat Lightning":
fileName = "ROOTS-heatlightning.png";
break;
case "Rain":
case "Light Rain":
case "Heavy Rain":
fileName = "ROOTS-rain.png";
break;
case "Rainbow":
fileName = "ROOTS-rainbow.png";
break;
case "Sleet":
fileName = "ROOTS-sleet.png";
break;
case "Snow":
case "Light Snow":
case "Heavy Snow":
fileName = "ROOTS-snow.png";
break;
case "Thundersnow":
fileName = "ROOTS-thundersnow.png";
break;
case "Thunderstorm":
fileName = "ROOTS-thunderstorm.png";
break;
}
if (!fileName) {
return null;
}
const filePath = join(process.cwd(), "src", "assets", "overlays", fileName);
return filePath;
};

View File

@ -0,0 +1,19 @@
import { join } from "path";
import { AttachmentData } from "../../interfaces/commands/AttachmentData";
import { Season } from "../../interfaces/weather/names/Season";
/**
* Module to generate the season icon attachment.
*
* @param {Season} season The season.
* @returns {AttachmentData} The icon attachment data.
*/
export const getSeasonIcon = (season: Season): AttachmentData => {
const fileName = `${season.toLowerCase()}.png`;
const filePath = join(process.cwd(), "src", "assets", "seasons", fileName);
return {
attachmentString: `attachment://${fileName}`,
filePath,
};
};

View File

@ -0,0 +1,25 @@
import { join } from "path";
import sharp from "sharp";
import { AttachmentData } from "../../interfaces/commands/AttachmentData";
/**
* Module to combine an overlay and a banner.
*
* @param {string} banner The file path for the banner.
* @param {string} overlay The file path for the overlay.
* @returns {AttachmentData} The attachment data for the new banner.
*/
export const overlayImages = async (
banner: string,
overlay: string
): Promise<AttachmentData> => {
await sharp(banner)
.composite([{ input: overlay, gravity: "center" }])
.toFile(join(process.cwd(), "src", "assets", "overlay.png"));
return {
attachmentString: `attachment://overlay.png`,
filePath: join(process.cwd(), "src", "assets", "overlay.png"),
};
};

View File

@ -0,0 +1,42 @@
import { Client } from "discord.js";
import { WeatherCache } from "../interfaces/WeatherCache";
import { logHandler } from "../utils/logHandler";
/**
* Module to fetch the configured channels for sending weather forecasts.
*
* @param {Client} BOT The bot's Discord instance.
* @returns {WeatherCache["channels"]} The configured channels for sending weather forecasts.
*/
export const loadChannels = async (
BOT: Client
): Promise<WeatherCache["channels"]> => {
try {
const guildId = process.env.HOME_GUILD_ID as string;
const rudaniaId = process.env.RUDANIA_ID as string;
const inarikoId = process.env.INARIKO_ID as string;
const vhintlId = process.env.VHINTL_ID as string;
const guild = await BOT.guilds.fetch(guildId);
const Rudania = await guild.channels.fetch(rudaniaId);
const Inariko = await guild.channels.fetch(inarikoId);
const Vhintl = await guild.channels.fetch(vhintlId);
if (
!guild ||
!Rudania ||
!Inariko ||
!Vhintl ||
!("send" in Rudania && "send" in Inariko && "send" in Vhintl)
) {
logHandler.log("error", "Cannot locate the forecast channels!");
process.exit(1);
}
return { Rudania, Inariko, Vhintl };
} catch (err) {
logHandler.log("error", err);
process.exit(1);
}
};

View File

@ -0,0 +1,35 @@
import { scheduleJob } from "node-schedule";
import { WeatherCache } from "../interfaces/WeatherCache";
import { errorHandler } from "../utils/errorHandler";
import { logHandler } from "../utils/logHandler";
import { generateWeatherEmbed } from "./generateWeatherEmbed";
import { getWeatherForecast } from "./getWeatherForecast";
/**
* Schedules a CRON job for sending weather forecasts to the appropriate channels.
*
* @param {WeatherCache} CACHE The weather cache.
*/
export const scheduleForecasts = (CACHE: WeatherCache) => {
logHandler.log("info", "Scheduling weather forecasts...");
// Run daily at 5AM PST to get 8AM EST.
scheduleJob("0 0 5 * * *", async () => {
try {
CACHE.Rudania = getWeatherForecast("Rudania");
CACHE.Inariko = getWeatherForecast("Inariko");
CACHE.Vhintl = getWeatherForecast("Vhintl");
const rudania = await generateWeatherEmbed(CACHE.Rudania);
await CACHE.channels.Rudania.send(rudania);
const inariko = await generateWeatherEmbed(CACHE.Inariko);
await CACHE.channels.Inariko.send(inariko);
const vhintl = await generateWeatherEmbed(CACHE.Vhintl);
await CACHE.channels.Vhintl.send(vhintl);
} catch (err) {
const errorId = await errorHandler(err, "scheduled forecast");
await CACHE.channels.Rudania.send(errorId);
}
});
};

View File

@ -0,0 +1,34 @@
import { precipitations } from "../../data/weather/precipitations";
import { TemperatureName } from "../../interfaces/weather/names/TemperatureName";
import { WindName } from "../../interfaces/weather/names/WindName";
import { Precipitation } from "../../interfaces/weather/Precipitation";
import { RegionRestriction } from "../../interfaces/weather/regions/RegionRestriction";
import { getRandomValue } from "../getRandomValue";
/**
*
* @param {RegionRestriction} options The allowed weather for the region + season.
* @param {TemperatureName} temp The current temperature.
* @param {WindName} wind The current wind.
* @returns {Precipitation | null} The precipitation, or null if there are no valid forecasts.
*/
export const getPrecipitation = (
options: RegionRestriction,
temp: TemperatureName,
wind: WindName
): Precipitation | null => {
const restrictedPrecipitation = precipitations.filter((el) =>
options.precipitation.includes(el.name)
);
const validPrecipitations = restrictedPrecipitation.filter(
(el) =>
(el.temps.includes(temp) || el.temps === "any") &&
(el.winds.includes(wind) || el.winds === "any")
);
if (!validPrecipitations.length) {
return null;
}
return getRandomValue(validPrecipitations);
};

View File

@ -0,0 +1,42 @@
import { inarikoSeasons } from "../../data/weather/regions/inarikoSeasons";
import { rudaniaSeasons } from "../../data/weather/regions/rudaniaSeasons";
import { vhintlSeasons } from "../../data/weather/regions/vhintlSeasons";
import { RegionName } from "../../interfaces/weather/names/RegionName";
import { Season } from "../../interfaces/weather/names/Season";
import { RegionRestriction } from "../../interfaces/weather/regions/RegionRestriction";
/**
* Module to get the allowed weather for a region based on the season.
* Will throw an error if the data is not found.
*
* @param {RegionName} region The name of the region.
* @param {Season} season The season.
* @returns {RegionRestriction} The allowed weather for the region.
*/
export const getRegionRestrictions = (
region: RegionName,
season: Season
): RegionRestriction | null => {
let restrictions = null;
switch (region) {
case "Rudania":
restrictions = rudaniaSeasons;
break;
case "Inariko":
restrictions = inarikoSeasons;
break;
case "Vhintl":
restrictions = vhintlSeasons;
break;
default:
return null;
}
const seasonalRestriction = restrictions.find((el) => el.season === season);
if (!seasonalRestriction) {
return null;
}
return seasonalRestriction;
};

View File

@ -0,0 +1,27 @@
import { Season } from "../../interfaces/weather/names/Season";
/**
* Module to get the season based on today's date.
* Winter: Dec 21 - March 20.
* Spring: March 21 - June 20.
* Summer: June 21st - September 22.
* Fall: September 23 - December 21.
*
* @returns {Season} The season name.
*/
export const getSeason = (): Season => {
const date = new Date();
const month = date.getMonth();
const day = date.getDate();
if (month < 2 || (month === 2 && day < 21) || (month === 11 && day > 20)) {
return "Winter";
}
if (month < 5 || (month === 5 && day < 21)) {
return "Spring";
}
if (month < 8 || (month === 8 && day < 23)) {
return "Summer";
}
return "Fall";
};

View File

@ -0,0 +1,53 @@
import { specials } from "../../data/weather/specials";
import { PrecipitationName } from "../../interfaces/weather/names/PrecipitationName";
import { TemperatureName } from "../../interfaces/weather/names/TemperatureName";
import { WindName } from "../../interfaces/weather/names/WindName";
import { RegionRestriction } from "../../interfaces/weather/regions/RegionRestriction";
import { Special } from "../../interfaces/weather/Special";
import { getRandomValue } from "../getRandomValue";
/**
* Module to get a special weather event for a region. Checks if the temp, wind, and precipitation
* allow for a special event. If so, has a 30% chance to trigger one.
*
* @param {RegionRestriction} options The allowed weather for the region + season.
* @param {TemperatureName} temp The current temperature.
* @param {WindName} wind The current wind.
* @param {PrecipitationName | undefined} precipitation The current precipitation.
* @returns {Special | null} The special event, or null if there are no valid forecasts.
*/
export const getSpecial = (
options: RegionRestriction,
temp: TemperatureName,
wind: WindName,
precipitation: PrecipitationName | undefined
): Special | null => {
const restrictedSpecial = specials.filter((el) =>
options.special.includes(el.name)
);
const validSpecials = precipitation
? restrictedSpecial.filter(
(el) =>
(el.temps.includes(temp) || el.temps === "any") &&
(el.winds.includes(wind) || el.winds === "any") &&
(el.precipitations.includes(precipitation) ||
el.precipitations === "any")
)
: restrictedSpecial.filter(
(el) =>
(el.temps.includes(temp) || el.temps === "any") &&
(el.winds.includes(wind) || el.winds === "any")
);
if (!validSpecials.length) {
return null;
}
const shouldSpecialTrigger = Math.floor(Math.random() * 100) < 30;
if (!shouldSpecialTrigger) {
return null;
}
return getRandomValue(validSpecials);
};

49
src/utils/errorHandler.ts Normal file
View File

@ -0,0 +1,49 @@
import { captureException } from "@sentry/node";
import { MessageEmbed, WebhookClient } from "discord.js";
import { v4 } from "uuid";
import { logHandler } from "./logHandler";
/**
* Formats an error into an embed and sends it to the developer debug webhook.
*
* @param {Error} err The error.
* @param {string} context A description of where the error occurred.
* @returns {string} The UUID tied to the error.
*/
export const errorHandler = async (
err: unknown,
context: string
): Promise<string> => {
const error = err as Error;
logHandler.log("error", `There was an error in the ${context}:`);
logHandler.log(
"error",
JSON.stringify(
{ errorMessage: error.message, errorStack: error.stack },
null,
2
)
);
captureException(error);
const errorId = v4();
const errorEmbed = new MessageEmbed();
errorEmbed.setTitle(`RuuBot had a ${context} error!`);
errorEmbed.setColor("DARK_RED");
errorEmbed.setDescription(error.message.substring(0, 4000));
errorEmbed.addField(
"Stack Trace:",
`\`\`\`${error.stack?.slice(0, 1000)}\`\`\``
);
errorEmbed.addField("Error ID:", errorId);
errorEmbed.setTimestamp();
const webhook = new WebhookClient({ url: process.env.DEBUG_HOOK as string });
await webhook.send({ embeds: [errorEmbed] });
return `The ${context} logic had an error. Please contact the developer with this ID: \`${errorId}\``;
};

24
src/utils/logHandler.ts Normal file
View File

@ -0,0 +1,24 @@
import { createLogger, format, transports, config } from "winston";
const { combine, timestamp, colorize, printf } = format;
/**
* Standard log handler, using winston to wrap and format
* messages. Call with `logHandler.log(level, message)`.
*
* @param {string} level - The log level to use.
* @param {string} message - The message to log.
*/
export const logHandler = createLogger({
levels: config.npm.levels,
level: "silly",
transports: [new transports.Console()],
format: combine(
timestamp({
format: "YYYY-MM-DD HH:mm:ss",
}),
colorize(),
printf((info) => `${info.level}: ${[info.timestamp]}: ${info.message}`)
),
exitOnError: false,
});

154
tests/regions.spec.ts Normal file
View File

@ -0,0 +1,154 @@
import { assert } from "chai";
import { inarikoSeasons } from "../src/data/weather/regions/inarikoSeasons";
import { rudaniaSeasons } from "../src/data/weather/regions/rudaniaSeasons";
import { vhintlSeasons } from "../src/data/weather/regions/vhintlSeasons";
suite("Region Tests", () => {
suite("Inariko", () => {
test("Seasons should be unique", () => {
const seasons = new Set(inarikoSeasons.map((el) => el.season));
assert.equal(
seasons.size,
inarikoSeasons.length,
"seasons are not unique!"
);
});
for (const season of inarikoSeasons) {
test(`${season.season} should have unique temps`, () => {
const temps = new Set(season.temps);
assert.equal(
temps.size,
season.temps.length,
`${season.season} temps are not unique!`
);
});
test(`${season.season} should have unique winds`, () => {
const winds = new Set(season.wind);
assert.equal(
winds.size,
season.wind.length,
`${season.season} winds are not unique!`
);
});
test(`${season.season} should have unique precipitation`, () => {
const precipitation = new Set(season.precipitation);
assert.equal(
precipitation.size,
season.precipitation.length,
`${season.season} precipitation is not unique!`
);
});
test(`${season.season} should have unique specials`, () => {
const specials = new Set(season.special);
assert.equal(
specials.size,
season.special.length,
`${season.season} specials are not unique!`
);
});
}
});
suite("Rudania", () => {
test("Seasons should be unique", () => {
const seasons = new Set(rudaniaSeasons.map((el) => el.season));
assert.equal(
seasons.size,
rudaniaSeasons.length,
"seasons are not unique!"
);
});
for (const season of rudaniaSeasons) {
test(`${season.season} should have unique temps`, () => {
const temps = new Set(season.temps);
assert.equal(
temps.size,
season.temps.length,
`${season.season} temps are not unique!`
);
});
test(`${season.season} should have unique winds`, () => {
const winds = new Set(season.wind);
assert.equal(
winds.size,
season.wind.length,
`${season.season} winds are not unique!`
);
});
test(`${season.season} should have unique precipitation`, () => {
const precipitation = new Set(season.precipitation);
assert.equal(
precipitation.size,
season.precipitation.length,
`${season.season} precipitation is not unique!`
);
});
test(`${season.season} should have unique specials`, () => {
const specials = new Set(season.special);
assert.equal(
specials.size,
season.special.length,
`${season.season} specials are not unique!`
);
});
}
});
suite("Vhintl", () => {
test("Seasons should be unique", () => {
const seasons = new Set(vhintlSeasons.map((el) => el.season));
assert.equal(
seasons.size,
vhintlSeasons.length,
"seasons are not unique!"
);
});
for (const season of vhintlSeasons) {
test(`${season.season} should have unique temps`, () => {
const temps = new Set(season.temps);
assert.equal(
temps.size,
season.temps.length,
`${season.season} temps are not unique!`
);
});
test(`${season.season} should have unique winds`, () => {
const winds = new Set(season.wind);
assert.equal(
winds.size,
season.wind.length,
`${season.season} winds are not unique!`
);
});
test(`${season.season} should have unique precipitation`, () => {
const precipitation = new Set(season.precipitation);
assert.equal(
precipitation.size,
season.precipitation.length,
`${season.season} precipitation is not unique!`
);
});
test(`${season.season} should have unique specials`, () => {
const specials = new Set(season.special);
assert.equal(
specials.size,
season.special.length,
`${season.season} specials are not unique!`
);
});
}
});
});

108
tests/weather.spec.ts Normal file
View File

@ -0,0 +1,108 @@
import { assert } from "chai";
import { precipitations } from "../src/data/weather/precipitations";
import { specials } from "../src/data/weather/specials";
import { temperatures } from "../src/data/weather/temperatures";
import { winds } from "../src/data/weather/winds";
suite("Weather Tests", () => {
suite("Temperature", () => {
const temps = new Set(temperatures.map((el) => el.name));
test("Temperatures should be unique", () => {
assert.equal(
temps.size,
temperatures.length,
"temperatures are not unique!"
);
});
});
suite("Winds", () => {
const wind = new Set(winds.map((el) => el.name));
test("Winds should be unique", () => {
assert.equal(wind.size, winds.length, "winds are not unique!");
});
});
suite("Precipitations", () => {
const precip = new Set(precipitations.map((el) => el.name));
test("Precipitations should be unique", () => {
assert.equal(
precip.size,
precipitations.length,
"precipitations are not unique!"
);
});
for (const precipitation of precipitations) {
test(`${precipitation.name} should have unique temps`, () => {
if (precipitation.temps === "any") {
return;
}
const temps = new Set(precipitation.temps);
assert.equal(
temps.size,
precipitation.temps.length,
`${precipitation.name} temps are not unique!`
);
});
test(`${precipitation.name} should have unique winds`, () => {
if (precipitation.winds === "any") {
return;
}
const winds = new Set(precipitation.winds);
assert.equal(
winds.size,
precipitation.winds.length,
`${precipitation.name} winds are not unique!`
);
});
}
});
suite("Specials", () => {
const spec = new Set(specials.map((el) => el.name));
test("Specials should be unique", () => {
assert.equal(spec.size, specials.length, "specials are not unique!");
});
for (const special of specials) {
test(`${special.name} should have unique temps`, () => {
if (special.temps === "any") {
return;
}
const temps = new Set(special.temps);
assert.equal(
temps.size,
special.temps.length,
`${special.name} temps are not unique!`
);
});
test(`${special.name} should have unique winds`, () => {
if (special.winds === "any") {
return;
}
const winds = new Set(special.winds);
assert.equal(
winds.size,
special.winds.length,
`${special.name} winds are not unique!`
);
});
test(`${special.name} should have unique precipitation`, () => {
if (special.precipitations === "any") {
return;
}
const precip = new Set(special.precipitations);
assert.equal(
precip.size,
special.precipitations.length,
`${special.name} precipitation is not unique!`
);
});
}
});
});

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./prod",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"exclude": ["tests/**/*.spec.ts"]
}