Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/refresh-data.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Refresh data snapshots

# Re-fetches Pokémon base stats and the released-shiny list into data/ so the
# site does not depend on PoGoAPI being up at page load. Also reports curated
# CP values that have drifted from current base stats — check the run log.

on:
schedule:
- cron: '17 6 * * 1' # Mondays, 06:17 UTC
workflow_dispatch:

permissions:
contents: write

jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Fetch and trim snapshots
run: npm run refresh-data

- name: Run tests against the refreshed data
run: node --test

- name: Commit if anything changed
run: |
if [[ -z "$(git status --porcelain data/)" ]]; then
echo "Snapshots already current — nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add data/
git commit -m "Refresh Pokémon data snapshots"
git push
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Tests

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
# No dependencies to install — the suite runs on Node's built-in test runner.
- run: node --test
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.DS_Store
node_modules/

# Local editor/tooling config — contains machine-specific absolute paths
.claude/
144 changes: 137 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,140 @@
# **Project Title: Sundance deGen**
# Sundance deGen

## Project Goal:
To create a web application that simplifies the creation of event descriptions for Pokémon GO meetups hosted on Niantic's Campfire. The tool aims to be adaptable to various event types and bonuses while incorporating relevant meetup data streamlining the event description process.
A browser tool that writes Pokémon GO meetup descriptions for [Niantic's Campfire](https://niantic.helpshift.com/hc/en/19-campfire/), so organisers stop retyping the same post every month.

## Project Background:
The repetitive task of manually creating and updating event descriptions for Pokémon GO meetups, often involving significant copy-pasting month after month, motivated the development of Sundance deGen. This tool was conceived to minimize user input and simplify the generation of consistent event descriptions, freeing up time and reducing the potential for errors.
Paste a month's event schedule as a markdown table and get a finished, copy-ready description for every event — with the Pokémon artwork, 100% IV catch CP, and shiny availability filled in automatically. Or build one event at a time by hand.

## Adaptability:
deGen's adaptable design allows for expansion to support various communities by customizing the description output format. Future updates will also ensure compatibility with new event types and bonuses, maintaining its relevance for event organizers.
Vanilla HTML/CSS/JS. No build step, no dependencies, no framework — just static files.

---

## Two ways to use it

### Bulk Import (the fast path)

Paste a markdown table of events and hit **Generate All Descriptions**. Each row becomes a finished description.

The table needs three columns — event name, details, date — and a header row:

```markdown
| Event Name | Details | Date |
| --- | --- | --- |
| Ho-Oh Raid Hour | Boosted shiny raids | August 12 |
| Charmander Community Day | 3x Catch Stardust, +1 special trade and half trade cost | August 16 |
```

From each row the tool works out:

| Field | How |
| --- | --- |
| Event type | Matched against the known types in the event name |
| Pokémon | The part of the name that isn't the event type (hyphenated names like `Ho-Oh` and `Porygon-Z` are handled) |
| Date | Month + day; the year rolls forward if the month has already passed |
| Bonuses | Keyword-matched from the details column, capped at the event type's limit |
| Shiny | Looked up against the released-shiny list, falling back to whether "shiny" appears in the details |
| Catch CP | Curated table first, then computed from base stats |
| Featured attack | Pulled from "gets"/"learns" phrasing in the details |

Rows it can't read are reported inline as a **Skipped** notice and don't stop the rest of the batch.

### Manual builder

Click an event type, choose how many descriptions you need (up to 20), and fill in the fields. The Pokémon field autocompletes — arrow keys and <kbd>Enter</kbd> work, as does the mouse — and choosing a name fills in CP and shiny status.

Your pasted event list and location settings survive a refresh.

---

## Event types

| Event type | Time | Max bonuses | Extra fields |
| --- | --- | --- | --- |
| Spotlight Hour | 6-7PM | 2 | — |
| Raid Hour | 6-7PM | — | 100% CP, weather-boosted CP |
| Community Day | 2-5PM | 4 | Featured attack |
| Community Day Classic | 2-5PM | 4 | Featured attack |
| Raid Day | 2-5PM | 4 | 100% CP, weather-boosted CP, featured attack |
| Hatch Day | 2-5PM | 3 | — |
| Research Day | 2-5PM | 2 | — |
| Max Battles | 2-5PM | 5 | 100% CP |

Location defaults to **Sundance Park**; switch to **Custom** for anywhere else.

## Output

```
Ho-Oh Raid Hour
🎈 Join us at Sundance Park on August 12th for the Ho-Oh Raid Hour from 6-7PM 💃☀️🕺

💯 - 2207 / WB - 2759

If you're lucky, you might encounter a shiny one ✨
✅ "Check in" on Campfire when you arrive
```

Each result comes with the Pokémon's artwork and a **Copy** button.

---

## Running locally

The scripts are ES modules, so opening `index.html` off disk won't work — it needs to be served over HTTP:

```bash
python3 -m http.server 8080
```

Then open <http://localhost:8080>. Any static server works.

## Tests

Pure logic — table parsing, name extraction, bonus matching, date handling, description rendering — is separated from the DOM in `parse.js` and covered by Node's built-in test runner. No dependencies to install:

```bash
npm test
```

## Project layout

| File | What's in it |
| --- | --- |
| `index.html` | Markup and the full stylesheet (Observation Deck design tokens, dark-only) |
| `config.js` | Event definitions, bonus keyword patterns, and every fixed phrase in the output |
| `parse.js` | Pure logic — parsing, formatting, and the single description renderer |
| `data.js` | Everything that touches the network, plus the data-source status chip |
| `main.js` | DOM wiring only |
| `cpOverrides.js` | Manual CP pins for cases base stats can't express — normally empty |
| `data/` | Vendored API snapshots, refreshed weekly by CI |
| `scripts/refresh-data.js` | Fetches and trims those snapshots |
| `test/` | Test suite |

## Adapting it for another community

`config.js` is the only file you need. `constants` holds every fixed phrase in the generated text; `eventConfig` defines which event types exist, when they run, which bonuses each may carry, and which extra fields it collects. A type's `specialFields` drives both the form and the description — adding `"hundo"` makes the CP line appear, `"attack"` adds the featured-attack line — so no other file hardcodes wording or event names.

## Data sources

| Source | Used for |
| --- | --- |
| [PokéAPI](https://pokeapi.co) | Autocomplete name list; Pokédex number for artwork |
| [pokemon.com](https://www.pokemon.com) | Official artwork |
| [PoGoAPI](https://pogoapi.net) | Base stats for CP calculation; released-shiny list |

Catch CP is `(attack + 15) × √(defense + 15) × √(stamina + 15) × CPM² ÷ 10`, using CPM 0.5974 (level 20, standard raid catch) and 0.667934 (level 25, weather boosted).

PoGoAPI data is **vendored into `data/`** rather than fetched at page load, so an outage there can't quietly strip the CP and shiny lines out of your descriptions. A weekly GitHub Action re-fetches it; the live API is consulted only for Pokémon released since the last snapshot. The status chip under the title shows which source is answering.

**CP is derived, never stored.** There is no table of CP values to maintain, so none can go stale. Lookups resolve cheapest-first: `cpOverrides.js` → snapshot → live API.

Two details make deriving reliable:

- **The catchable form is chosen explicitly.** PoGoAPI doesn't list base forms first — Galarian Articuno precedes Normal, Crowned Sword Zacian precedes Hero — so `CATCHABLE_FORMS` in `parse.js` picks the form you actually catch rather than whichever came first.
- **Form-qualified names are indexed under their own key.** "Deoxys Attack" and "Giratina (Origin)" have no entry of their own in the API, which models them as name + form. Both spellings of a regional form work: `Exeggutor (Alolan)` and `Exeggutor Alola` resolve to the same stats.

`cpOverrides.js` is the escape hatch for a value a human has to judge. It is currently empty — every name the tool has ever needed derives correctly. Prefer fixing the derivation over adding an override, since an override is a value nothing will re-check. `npm run refresh-data` reports overrides that have become redundant or unresolvable.

## Known limitations

- Bonus detection is keyword-based, so unusual phrasing in the details column may be missed. Check the generated bonus list before posting.
- Bulk results can't be edited in place — to change a parsed value, rebuild that event in the manual builder.
- Changing the "how many descriptions" count rebuilds the form and clears anything already typed in.
125 changes: 125 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Event definitions and the wording of the generated description.
*
* This is the file to edit to adapt deGen to another community: `constants`
* controls every fixed phrase in the output, and `eventConfig` controls which
* event types exist, when they run, which bonuses they may carry, and which
* extra fields they collect. No other file hardcodes description wording.
*/

export const constants = {
checkInText: '✅ "Check in" on Campfire when you arrive',
eventEmojis: "💃☀️🕺",
shinyText: "If you're lucky, you might encounter a shiny one ✨\n",
bonusHeaderSingle: "————Event Bonus————",
bonusHeaderMultiple: "————Event Bonuses————",
defaultLocation: "Sundance Park"
};

/**
* specialFields drives both the form fields rendered and the lines that appear
* in the description — "hundo"/"whundo" produce the CP line, "attack" produces
* the featured-attack line. Nothing keys off the event *name*.
*/
export const eventConfig = {
"Spotlight Hour": {
time: "6-7PM",
bonuses: ["2x Catch Stardust", "2x Catch XP", "2x Catch Candy", "2x Transfer Candy", "2x Evolution XP"],
specialFields: [],
maxBonuses: 2
},
"Raid Hour": {
time: "6-7PM",
bonuses: [],
specialFields: ["hundo", "whundo"],
maxBonuses: 0
},
"Community Day": {
time: "2-5PM",
bonuses: ["1/2 Hatch Distance", "1/4 Hatch Distance", "2x Catch XP & Stardust", "2x Catch Candy & XL Chance", "3x Catch Stardust", "3 HR Lures / Incense", "+1 Special Trade & Half Trade Cost"],
specialFields: ["attack"],
maxBonuses: 4
},
"Community Day Classic": {
time: "2-5PM",
bonuses: ["1/4 Hatch Distance", "3 HR Lures / Incense", "5 Photobomb Encounters", "Extra Special Trade"],
specialFields: ["attack"],
maxBonuses: 4
},
"Raid Day": {
time: "2-5PM",
bonuses: ["Increased shiny chance", "5 free raid passes by spinning gyms, 6 total", "Remote raids increased to 20", "Extra Raid Bonus", "1.5x XP from Raids"],
specialFields: ["hundo", "whundo", "attack"],
maxBonuses: 4
},
"Hatch Day": {
time: "2-5PM",
bonuses: ["Increased shiny chance", "1/4 Hatch Distance", "1/2 Hatch Distance"],
specialFields: [],
maxBonuses: 3
},
"Research Day": {
time: "2-5PM",
bonuses: ["Increased shiny chance"],
specialFields: [],
maxBonuses: 2
},
"Max Battles": {
time: "2-5PM",
bonuses: ["1/4 Adventure Distance for MP", "MP Collection raised to 1600", "8x MP from Power Spots", "2x MP for Exploring", "+2 Special Trades"],
specialFields: ["hundo"],
maxBonuses: 5
}
};

/** Event-name fragments the bulk parser maps to a type. Longest match wins, so order matters. */
export const eventTypeAliases = [
["community day classic", "Community Day Classic"],
["community day", "Community Day"],
["hatch day", "Hatch Day"],
["raid day", "Raid Day"],
["raid hour", "Raid Hour"],
["spotlight hour", "Spotlight Hour"],
["research day", "Research Day"],
["max battle", "Max Battles"]
];

/**
* Bonus detection for the bulk importer. A pattern may offer several spellings;
* the first one the matched event type actually declares is the one used.
*/
export const bonusPatterns = [
{ re: /3[x×]\s*catch stardust|3[x×]\s*stardust/i, bonus: "3x Catch Stardust" },
{ re: /2[x×]\s*catch xp\s*[&]\s*stardust|2[x×]\s*xp\s*[&]\s*stardust/i, bonus: "2x Catch XP & Stardust" },
{ re: /2[x×]\s*catch xp|2[x×]\s*xp(?!\s*[&])/i, bonus: "2x Catch XP" },
{ re: /2[x×]\s*catch stardust|2[x×]\s*stardust/i, bonus: "2x Catch Stardust" },
{ re: /2[x×]\s*candy\s*[&]\s*xl|2[x×]\s*candy.*xl/i, bonus: "2x Catch Candy & XL Chance" },
{ re: /2[x×]\s*catch candy|2[x×]\s*candy(?!\s*[&xl])/i, bonus: "2x Catch Candy" },
{ re: /2[x×]\s*transfer candy/i, bonus: "2x Transfer Candy" },
{ re: /2[x×]\s*evolution xp|2[x×]\s*evolv/i, bonus: "2x Evolution XP" },
{ re: /[¼]|1\/4\s*hatch/i, bonus: "1/4 Hatch Distance" },
{ re: /[½]|1\/2\s*hatch/i, bonus: "1/2 Hatch Distance" },
{ re: /3\s*hr\s*lures?/i, bonus: "3 HR Lures / Incense" },
{ re: /boosted shiny|increased shiny/i, bonus: "Increased shiny chance" },
{ re: /5\s*photobomb/i, bonus: "5 Photobomb Encounters" },
// Community Day calls this "+1 Special Trade & Half Trade Cost"; CD Classic
// calls it "Extra Special Trade". Offer both spellings.
{ re: /extra special trade|special trade|half trade cost/i, bonus: ["Extra Special Trade", "+1 Special Trade & Half Trade Cost"] },
{ re: /free raid pass/i, bonus: "5 free raid passes by spinning gyms, 6 total" },
{ re: /remote raid/i, bonus: "Remote raids increased to 20" },
{ re: /1\.5[x×]\s*xp from raids/i, bonus: "1.5x XP from Raids" },
{ re: /extra raid bonus/i, bonus: "Extra Raid Bonus" },
{ re: /1\/4.*adventure|adventure.*1\/4/i, bonus: "1/4 Adventure Distance for MP" },
{ re: /mp.*1600|1600.*mp/i, bonus: "MP Collection raised to 1600" },
{ re: /8[x×].*mp|8[x×].*power spot/i, bonus: "8x MP from Power Spots" },
{ re: /2[x×].*mp.*explor/i, bonus: "2x MP for Exploring" }
];

/**
* Combat Power multipliers for the two catch levels shown in raid descriptions.
* L20 is a standard raid catch; L25 is the weather-boosted catch.
*/
export const CPM = {
level20: 0.5974,
level25: 0.667934
};
28 changes: 28 additions & 0 deletions cpOverrides.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Manually pinned catch CP.
*
* Catch CP is normally *derived* from current Pokémon GO base stats, so it can
* never go stale — see data.js. This map exists only for cases the stat data
* cannot express on its own, and it is checked before anything else.
*
* Add an entry when, and only when, the derived value is wrong for a reason a
* human has to judge. The known category is **which form you actually catch**:
* Zacian's base stats resolve to Crowned Sword, but raids give Hero of Many
* Battles. That particular case is now handled generically by CATCHABLE_FORMS
* in parse.js, which is the better fix whenever the rule generalises.
*
* Prefer fixing the derivation over adding an override. An override is a value
* nothing will ever re-check — exactly the staleness this file replaced.
*
* Format matches the derived shape:
*
* "Some Pokémon": { hundo: 1234, whundo: 1543 }, // why, and when checked
*
* hundo = level 20 catch (standard raid), 15/15/15
* whundo = level 25 catch (weather boosted), 15/15/15
*/
export const cpOverrides = {
// Empty by design. Every name the tool has ever needed currently resolves
// from base stats, including form-qualified ones like "Deoxys Attack" and
// "Giratina (Origin)".
};
Loading
Loading