diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..71d7bea --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,61 @@ +# Every push and pull request runs the whole suite in a real browser -- Chromium, +# WebKit (Safari's engine) and Firefox -- the same ./tests/run.sh as a local `npm test`, +# so a PR shows green or red. +# +# Also checked: mod.js parses, the committed userscript was rebuilt from it, and +# the author's two files are untouched (his changelog byte for byte, index.html +# his plus the one script tag) against his repository, fetched read-only. +name: tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 60 + # WebKit is the engine Safari is built on: it is what caught the skill rows + # wrapping under a classic scrollbar, which Chromium alone never showed. + strategy: + fail-fast: false + matrix: + browser: [chromium, webkit, firefox] + name: tests (${{ matrix.browser }}) + env: + BROWSER: ${{ matrix.browser }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + # The browsers are ~100-200 MB each and downloaded on every run. Keyed on the + # lockfile (which pins the Playwright version, and so the browser build); + # `install` below is a no-op for the download when this hits, but still + # adds the OS libraries, which are not in the cache. + - name: Cache the browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }} + + - name: Install + run: | + npm ci + npx playwright install --with-deps ${{ matrix.browser }} + + - name: mod.js parses, userscript current + run: npm run check + + - name: The author's files are his + run: ./tests/authorfiles.sh + + - name: Test suite + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6221889 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,991 @@ +# proto23 mod — working notes for Claude + +## What this is + +A mod for a single-file idle RPG. The game is `index.html`: 837 KB, ~14,800 +lines, **not minified**, every global declared with `var` at top level, no +strict mode. That is what makes the mod possible. + +`mod.js` is the entire mod, loaded by one appended script tag at the very +bottom of `index.html`: + +```html + + +``` + +Because it loads last and shares global scope, it can read and wrap anything +the game defines. That single tag is the **only** change to `index.html` — +keep it that way, so `git checkout index.html` is always a clean undo. + +`MOD-NOTES.md` is the full design record, section by section, with the +reasoning behind each decision and what was tested. Read the relevant section +before changing anything in that area. + +## How to change things + +Wrap, don't edit. The pattern used throughout: + +```js +var MOD_thing_original = thing; +thing = function () { + var r = MOD_thing_original.apply(this, arguments); + /* extra behaviour */ + return r; // always preserve the return value +}; +``` + +Preserving the return matters: `allbuff` does `let dm = skl.fgt.use()` and +adds the result to stats, so a wrapper that swallows a return silently +changes the game's math. + +## Save format constraints — read before adding content + +The game's save/load is positional in places, which means some things are +append-only. Getting this wrong corrupts existing saves. + +- **Milestone "granted" flags are stored by array index.** Milestones must be + appended, never inserted or reordered, and their levels must stay ascending. + `tests/audit.mjs` checks this. +- **Skill `exp` and `p` are stored positionally** over `for..in skl`. New + skills go at the **end** of `skl`, which happens naturally since `mod.js` + runs after the game script. +- **Actions are stored by id**, so those are safe to add anywhere. +- On load, milestones re-fire **before** `p` is restored. So a perk must never + do `sk.p += 0.1` — the restore would wipe it. Use a declarative flag on the + milestone (`xpBonus`, `sectionXp`) read at point of use instead. +- `you.res` and `you.mods` are saved. Any mutation of them needs delta + tracking in `global.flags` or it compounds on every load. See + `MOD_applyMoneyDrops` for the pattern. + +## The bottom bar + +Buttons added to `dom.sl` must match the game's own: plain `inline`, `padding:3px`, +no `display:inline-block`. An inline-block forces the line box to grow, which +made the fixed bar 32px instead of 24px and covered the bottom of the panel +above it. The bar is `position:fixed`, so any height it gains eats game screen. + +## DOM constraints in the skill panel + +The game's per-second updater reads fixed child indices **within** each skill +row: `children[0]`, `children[1]`, `children[2].children[0]`. So: + +- `dom.skcon.children[m]` must stay aligned with `you.skls[m]`. +- Never insert an element as a row's first child. Section headers are appended + **last** with `order:-1; flex:0 0 100%` — `.skwmmc` is `display:flex`, so + that puts them visually first without shifting indices. + +## Balance + +Enemy dials on `MOD_ENEMY`, live in the browser console: + +```js +kill: 8, die: 20, hit: 0.45, margin: 1.5, +hpSpread: 0.6, atkSpread: 0.3, bossKill: 2.0, bossDie: 0.7, armor: 1.0 +``` + +**The skill exp curve is the author's below level 10, and the mod's above.** +The base game's `expnext = 50 + (lvl+1)^log(9*lvl+1)` is super-exponential — +109→110 alone costs ~1.2e14 xp, so the cap ladder is unclimbable by ~10¹³. But +the author only designed skills up to 15 (his 22 perk ladders end between 1 and +15, median 10), and below that his curve is fine. So section 19 is in two parts: + +```js +MOD_XP = { vanillaTo: 10, total, ratio } // levels < 10: HIS formula, exactly + // from 10: his level-9 cost * ratio^n +``` + +`ratio` (~1.029) is **solved**, not chosen: it is whatever makes 0→110 cost the +same wall-clock time the old `50 * 1.106^lvl` curve took at its 2x multiplier. +The end is exactly as far away; the time is spread evenly instead of the old +curve's "cap 60 in under an hour, then months on the last twenty levels". + +- **Why 10 and not 15.** His level 15 alone costs 823k; 95 more levels even at + that flat price is 78M xp, 2.5x the whole budget. 10 is the highest start that + still leaves a rising ramp. `setXpCurve({vanillaTo})` accepts 0-12. +- **The curve is installed twice — at section 19 and at the very END of mod.js.** + It used to run once, and every skill created later kept the author's curve: + section 29's six masteries cost 7,474,050 xp at level 20 instead of 375, so + their ladders to 110 stopped climbing at ~20. Keep the final call last. +- **The defaults are the original's:** `MOD.skill_xp_mult` 1 (was 2) and + `MOD_MONEY.chance` 0 (was 0.15 — the author only turns the coin drop on through + the Coin Ring and Ring of Greed, and a 15% base made both rounding errors). + Both are settings boxes persisted in localStorage, so a value the player set + still wins. + +`tests/vanilla.mjs` holds all of this against the author's own page — it blocks +`mod.js` so the one script tag loads nothing — and asserts every level below 10 +costs exactly what HIS `expnext` returns, not a reimplementation of it. + +**Pacing presets (section 45)**: `setPacing('original' | 'fast')`. `fast` is the +pre-4.2 pacing exactly — `MOD_XP.fixedRatio` 1.106 with `vanillaTo` 0 makes every +level cost `50 * 1.106^lvl`, plus 2x exp and a 15% coin drop. The curve is not +persisted on its own, so the chosen preset is stored (`p23_mod_pacing`) and its +curve re-applied at load; the exp and coin boxes persist on their own keys. +Since v4.6 each preset also sets Fights (`original` / `scaled`), which persists on +`p23_mod_fights`. Anything else reads `custom`. `tests/pacing.mjs` checks each +preset is exact. + +The full ladder 10/15/20/30/40/50/60/75/90/110 is genuinely reachable, so +**model the player AT the cap** — that is what the tests do. + +### Fights: Original by default, Scaled on request (section 47, v4.6) + +At equal progression the original's opening route is nearly free: median 1.3 +swings to kill and ~20,000 to die, because its creatures are fixed and you +out-level them. The mod's enemy model (section 8) exists to remove that and +holds every fight near 8.8 / 22. + +**`MOD_FIGHTS.mode` chooses.** On `original`, the default, `MOD_scaleEnemy` +returns at once for any spawn whose `global.current_z` is one of the author's +areas, and `MOD_applyAreaLevels()` puts his level ranges back (section 8 keeps +both: `pop[i]._modLv0` his, `_modLvS` the x1.4). On `scaled` it is section 8 +everywhere, as before 4.6. + +- **"The author's areas" is `area[k]._modAuthor`**, set on every area that exists + when section 8 starts, before the mod adds any. Areas are not saved, so marking + the object is safe. The mod's own — Hollow, Spire, Vigil, the ten rank duels — + are always scaled: there is no original to match, and the rank ladder needs + `_modKillT`. +- **The value model reads `_modLvS` whatever the mode**, so an item's price does + not change when the setting does. +- **Raw Meat follows the mode**: his 6% on Original (`_modChance0`), 18% on + Scaled. The raise only ever made up for the scaled kill times, and + `MOD_MEAT.killSecOriginal` (5s) is the matching assumption. +- **Balance scripts measure section 8, so they call `setFights('scaled')` first** + — allareas, earlybal, combat, fightsmoke, fullbal, targets, baseline and + vanilla's gap table. A new script that measures the model must too, or on the + default it measures the author's creatures and reports nonsense. +- `tests/fights.mjs` holds Original against his page: all 70 spawns in his areas + identical stat for stat (random() seeded the same on both sides), and every + step of his route past the tutorial winnable at arrival. His own game loses + Tutorial fight 1 at skill 0, and so does Original — that is his design. + +An earlier comparison showed the original as a wall of unwinnable fights. That +was the old skill curve: it handed the original's player far lower skills for +the same xp. Compare at equal skill levels, which the matched curve now gives. + +### The enemy model, and why it looks the way it does + +`dmg_calc` is **subtractive in both directions**: an attack does +`attacker.str - defender.str` (plus weapon, plus 1, plus crits). The defender's +STR *is* their armour. Four consequences, all load-bearing: + +* **Never scale enemy `strm`.** Raising it raises offence and armour together, + and once armour passes the attacker's STR the damage does not get small, it + clamps to **zero**. `MOD_ENEMY.armor` exists so this stays visible; leave it + at 1. The same applies to `aglm`, which is the denominator of `hit_calc(1)`. +* **Never judge a fight by a stat ratio.** `enemyHP/playerSTR` and + `playerHP/enemySTR` both ignore the subtraction and read several times off. + That proxy is what hid a tutorial fight needing 63,000 swings and a whole + ladder of `kill 1 / die 1`. Measure through `dmg_calc` and `hit_calc`. +* **Nothing is replicated, everything is measured.** The base game's defence + term goes *negative* at high skill levels (`shdc` at 110 makes its last + multiplier −12.3, so your defence is added to the enemy's damage), and it is + ill-conditioned besides. Each spawn runs the real `dmg_calc` a few times in + both directions inside `MOD_probe` — which parks `giveSkExp`, the crit flag + and the DOM node it jiggles — and scales against what actually came out. +* **The dials are two-sided.** Flooring every multiplier at 1 so nothing is + weaker than vanilla leaves pockets the base game itself made unwinnable. + +Scaling is anchored to the player's power at spawn, not fitted to a level, +because within tier 0 alone the player goes from STR 1 to STR ~265. Variety +comes from the area's level band and each creature's own `stat_p`, and +`margin` guarantees `kill < die` however those compound. + +Damage is heavy-tailed at the top of the ladder: crit rate reaches 33% and a +crit is about 8x a normal swing, so crits carry roughly three quarters of all +damage. `MOD_meanDamage` therefore strata on the crit roll rather than taking a +plain sample mean — the variance of a plain mean is almost entirely "how many +crits landed", and enemy HP comes straight off that number. `fightsmoke` fights +nine times per area and reports a median for the same reason. + +**Section 46 corrects three lines of the author's combat and death code, and +the luck fix lives INSIDE `MOD_dmg_calc_original`.** That variable is rebound to a +version that applies his luck crit multiplier (capped at 50% from luck, +`MOD_LUCK_CAP`) and floors the Shield Mastery defence bracket at zero. Section 8's +probe and its incoming-damage wrapper both call through the variable, so enemies +are sized against the fixed player. **Anything that samples your damage must go +through `dmg_calc` or `MOD_dmg_calc_original`, never a reference captured before +section 46**, or it measures a player without luck. `MOD_critRate(att, stt)` +includes the luck bonus. The bonus rides on `you.mods.crflt` for one call and is +restored to its exact prior value — `crflt` is saved, and add-then-subtract +would leave float residue. `tests/basefixes.mjs` holds all three. + +**Section 51 is a fourth: every creature held ONE weapon.** `Creature` starts with +`eqp = [eqp.dummy, eqp.dummy]` and the author wrote 32 per-creature `aff`/`cls` +lines straight onto it, so all 48 carried the last writer's, and the player's bare +fists (the same object) leak `lvl/4` and `lvl/5` into every creature's damage. +Each creature now owns its slots, with his values read back from his source; the +fists stay the original object. `tests/creaturegear.mjs` holds it. A creature +built later by the mod gets the same treatment only if it exists when section 51 +runs. + +**Any script that samples `dmg_calc` must park `giveSkExp` first** (`quiet()` in +the test scripts, `MOD_probe` in the mod). `dmg_calc` grants skill exp, so +sampling it levels the player mid-measurement. + +**`tests/baselines/balance.json` is committed on purpose.** `tests/baseline.mjs` +records the curve, the dials, realm and tribulation costs, prices and every +area's per-spawn fight targets, and fails on any difference. When a change is +MEANT to move balance, run `UPDATE=1 PORT=8080 node tests/baseline.mjs` and +commit the file with the change — the diff is the review. + +Run `./tests/run.sh earlybal combat fightsmoke` after any balance change, +`allareas` (all 3,720 matchups: every area x every creature x every tier x +four skill builds, the fourth with every section folded) before calling one finished, and `capreach` after touching the curve or any xp rate. +What matters: + +- **`kill < die` at every matchup** — every balance script fails on this. +- **`whiff%` at 0** — anything above means damage is being eaten. +- `epow` on `MOD_TIERS` no longer feeds difficulty (the player anchor replaced + it) but `modCaps()` and the tests still read it. Tests select a tier by + **index** (`setTier(9)`), not by cap number. + +### Uncertainty: `tests/lib/stats.mjs` + +Every balance number here is a sample mean of something heavy-tailed, so a +point estimate cannot say whether a gap is real. `summarize()` gives a 95% CI, +`withinBand(samples, target, tolPct)` asks the question that usually matters — +is it *practically* on target — and `fightsmoke` and `targets.mjs` both use it. + +- **Student's t, not z.** `fightsmoke` fights nine times per area; z at n=9 is + ~15% too narrow. Between table rows `tCrit95` takes the lower-df (wider) + value, never the narrower. +- **Compare against the per-spawn target, never the dial.** `MOD_ENEMY.kill` is + the centre of a distribution: `killT = kill * band^hpSpread * creatureShape()`, + clamped. A basement rat is legitimately a five-swing kill. Use `_modKillT` / + `_modDieT`, which `MOD_scaleEnemy` leaves on the spawn. +- **Fold in the hit rate.** The targets count swings *including misses*, while + `abl.default.f` returns damage per landed hit and never rolls to-hit. Miss + this and every ratio comes back multiplied by `MOD_ENEMY.hit` — a die ratio of + exactly 0.45 is the tell. +- **Equivalence, not significance.** At n=45 the model tracks its targets to + ±0.1%, so "does the CI contain the target" fails on a 1% quantisation bias + that means nothing in play. `targets.mjs` uses a ±5% band. +- **An empty stratum is topped up, never dropped.** Every stratified estimator + (mod.js `MOD_meanDamage` and the five test copies) used to fall back to a plain + mean when n swings rolled no crit. That does not average the crits badly — it + DROPS them, and at cap 30 they are 30% of all damage. In the mod it sized ~5% + of spawns ~20% weak (Tallyman p5 kill/target 0.795 → 0.968 after); in + `allareas` it read a fight ~40% long 0.15% of the time, which across 2,790 + matchups intermittently failed the rank duels, whose margin was only 1.25 + (now `MOD_RANK_MARGIN` 1.4, since v4.3). + They now keep sampling (up to 8n) until both strata show. +- And the crit stratification the section above demands applies here too: a + plain sample mean put two of forty-five rank-duel spawns the wrong side of + `kill < die` on noise alone. + +### The body maximums: write the multiplier, not the total — FIXED + +`hpTrack` (sections 9/10) used to **assign** `you.hpmax` at the end of the +`allbuff` wrapper. The assignment did not survive: `stat_r` recomputes hpmax as +`(hp_r + hpa) * hpm * hpe`, and the tick, `fght`, `update_d` and `allbuff` +itself all call it constantly. Measured on a cap-60 character, one `stat_r()` +took hpmax from **1,492,947 to 72,377** — a 20x collapse — and sampling every +137ms across six seconds of real play found the low number in all 43 samples. +The HP bar agreed: `hp: 2,438/10,050`. + +Every balance script reads `you.hpmax` in the instant after `allbuff` while the +assignment still stands, and so does `MOD_scaleEnemy` when it sizes `_modDmg`. +So the enemy's damage was built for a character with ~18x the health the +character actually had, and all 2,790 `allareas` matchups were validating +against a number the player never held. + +It now writes `you.hpm`, which IS an input to `stat_r` and therefore survives. +Three things that took a correction each: + +- **`hpm` is saved** (`hpm:you.hpm` in the save object), so the mod's factor is + tracked in `global.flags.mod_hpm` and divided back out before re-applying — + the same delta pairing `MOD_applyMoneyDrops` uses, and for the same reason + `you.res` once compounded on every load. +- **The recovered base is floored at 1.** Anything that resets `hpm` without + clearing the flag — a test harness rebuilding the player, a new game keeping + flags — otherwise recovers `1/18.86` and max HP collapses. Flooring is an + invariant, not a guess: only milestones touch `hpm`, and they add. Without + it `allareas` reported the player at **12 max HP** and six rank duels flipped + to `kill > die`. +- **`Math.ceil`, not `Math.round`** — `stat_r` ceils, and the two have to agree + to the unit or this call and the next report max HP one apart. + +`stat_r()` is deliberately **not** called from inside the wrapper: the mod's +skill `use()` calls add straight into `you.str` and friends, and `stat_r` would +recompute those from `(r + a) * m * e` and wipe them. Setting `hpmax` by hand +alongside `hpm` keeps the current call right and lets the next `stat_r` reach +the same number on its own. `tests/targets.mjs` checks all of it, and fails +against the old behaviour. + +**The same bug was still in the realm code**, found while extending it (v4.0). +Section 28 raised max HP *and max energy* by assigning `you.hpmax`/`you.satmax`, +and `stat_r` recomputes **both**: + +```js +hpmax = ceil((hp_r + hpa ) * hpm * hpe ) +satmax = ceil((sat_r + sata) * satm * sate) +``` + +Measured on a realm-10 character: `hpmax` 332 after `allbuff` and **39** after +one `stat_r`; `satmax` 1700, then **200**. The ×8.5 the top realm advertised +lasted one call, so ten realms of body were decoration. + +**Nothing writes `hpm` or `satm` any more.** Two contributors wanted the same two +fields, and two delta-trackers on one field cannot coexist — each recovers its +base by dividing out *its own* factor and finds the other's sitting inside that +base. So contributors publish a factor to **`MOD_BODY`** and `MOD_applyBody()` +writes the product, once, with one delta flag per axis (`mod_hpm`, `mod_satm`). + +- To add a contributor: give it a key on `MOD_BODY.hp` / `MOD_BODY.sat`, set that + key **every call** for `you` — to 1 when it does not apply, or last call's + factor lingers — and never touch `hpm`, `satm`, `hpmax` or `satmax` yourself. +- `MOD_applyBody()` is **idempotent**, which is what lets more than one wrapper + call it. Sections 28 and 38 both do. `tests/cultivation.mjs` asserts it. +- `satm` is saved (`satm:you.satm`) exactly like `hpm`, so it needs the delta + pairing for the same reason `you.res` did. +- The recovered base is floored at 1 on both axes, for the reason above. + +Sections 6, 7 and 9 once each had an `allbuff` wrapper of their own (the section +9 one assigned `you.hpmax` directly), but section 10 re-anchors on +`MOD_allbuff_original`, so none of them ever ran after load. They were removed in +v4.3. `tests/polish.mjs` asserts the live chain: the game's own `allbuff` runs +once per call, and a mod skill's `use()` once. + +## How big numbers are written (section 40) + +The mod's numbers are enormous next to the original's, which READS like a balance +problem but is not one — section 8 scales enemies to the player, so a fight is +the same length at STR 50 or 5,000,000. A `Number format` settings row (and +`setNumberFormat()`) picks `game` / `short` / `sci` / `myriad`, default `short`. + +- **Up to 9,999 every mode prints exactly what the original prints** — commas + where he used `format3`, bare where he used `Math.round`. The threshold is his: + `printDamageNumber` does `if(ddmg>9999) formatw(ddmg);` and **discards the + result**, so his damage log never compacted anything. The wrapper hands it an + already-formatted string, which makes his `>9999` test false (NaN). +- `myriad` (万 10⁴, 亿 10⁸ …) is his notation: `m_update` draws the wallet with a + 10⁸ coin, `㊧`, over 10⁴ groups. +- `game` mode is the original byte for byte, bugs included — past 10²¹ his + `format3` inserts commas into exponential notation (`7,.7e,+47`). +- Every site is wrapped, not replaced: `formatw`, `update_db`, `update_m`, the + three bars, `dom.d6.update` (after section 34's gold held-rank paint, which it + leaves alone), `m_update`, `printDamageNumber`. Choose decimals AFTER rounding + (`MOD_sigFig`) or 99,996 prints as "100.0K". + +## The screen: the rank line, the skill panel, and What next (sections 42-43) + +- **The player panel is a fixed 310px box and it is full** (307px used). Never + give it a new line. The realm lives at the end of the rank line (`dom.d6`), + nowrap + ellipsis, as ONE persistent span re-appended after each + `dom.d6.update` rewrite (so its tooltip listeners survive). At a bottleneck the + span is the breakthrough button and calls `MOD_breakthrough` unchanged. +- **A tooltip whose text is a function needs `true` as addDesc's sixth + argument** — `addDesc(el, null, 2, title, fn, true)`. Without it the game + prints the function's source: the realm on the rank line showed a page of code + until v4.7. `tests/tooltips.mjs` renders all ~1,370 tooltips and fails on code + or `undefined`/`NaN`. +- **ASCII only on that line.** The game's font is MS Gothic; where it is missing, + a symbol like ▲ or ⚠ falls back to a font with a taller line box — measured, + ▲ grew the line 1px and ⚠ 6px, pushing the panel past 310px. `½` is safe. + `tests/polish.mjs` asserts the longest possible line keeps panel height. +- **The skill panel's per-second updater writes only what changed** (v4.6), + through `MOD_setIfChanged`, and skips hidden rows. An innerHTML write + re-parses and dirties layout even with identical text; the game's version + wrote three cells on every row every second (329 DOM changes a pass at 110 + rows, 3.8ms on WebKit; now 2 changes and 0.3ms). +- **The skill panel's per-second updater is the mod's now.** The game's captures + `sklsize` once and never updates it, so after the list first grows it rebuilt + every row every second forever (265 redraws in 5s at 53 rows), and it never + noticed a shrink. `MOD_skillUpdater` replaces it in the same `timers.sklupdate` + slot the game clears on close, keeps the row update line for line, and redraws + on any change of length. +- Rarity filter and sort go through section 13's hide branch and + `MOD_skillCmp`, like everything else in that panel. +- **`dom.ch_1` is not the first line of a screen.** `chs()` reassigns it on every + call, so it holds the LAST line drawn. To test which screen is up, read + `dom.ctr_2.children[0]`. +- **What next** (`MOD_whatNext`, wiki page `next`, console `modNext()`) calls the + same gate each system uses — `MOD_tierFlag`, `MOD_atBottleneck`, + `MOD_hollowCleared`, the crafting tiers' `gate()` — and never restates a + threshold. A new progression track should add a line there. + +## Content gating + +- The three added areas (Sunken Hollow / Ashen Spire / Long Vigil) are hidden + until `global.flags.trne4e1` — golem arena IV cleared, the base game's last + normal area — and then open in order on the same kill counts that drive their + caps. `tests/areagate.mjs` guards this. +- **The catacombs are reachable now** (section 30). All 26 rooms are the + author's; the mod adds only the Village Center entrance, which is where their + own exit already led. Gated on `mod_t_deep` **for a reason**: entering sets + `mod_t_cata`, the cap-40 rung, so an ungated door would hand a fresh character + cap 40 straight out of the tutorial, past the forest's 20 and the deep + forest's 30. Any new area that sets a tier flag needs the same care. +- **The Pill Tower** is `chss.mod_pltwr`, deliberately not `chss.pltwr1` — the + author wrote that choice on the Village Center and commented it out, and the + two must not collide if they ever finish theirs. +- `chs(txt, true, ...)` calls `clr_chs()` and wipes everything already drawn for + that location. Only the *first* line of an `sl()` may pass `true`; every later + line, including greyed-out hints, must pass `false`. + +## Cultivation + +Realms are **derived from `MOD_RANK_AT`** — the same thresholds title ranks use +— so realm N is the level that earns a rank N title. Never restate the levels; +retuning the ranks must move the realms with them. `tests/cultivation.mjs` +asserts the alignment. + +**Every breakthrough pill must have a source.** The ladder shipped once with +none of them obtainable — the items existed and nothing gave, dropped or sold +them, so realms were console-only. Realm 1 comes from the instructor at the Qi +unlock, 2-5 from `vendor.pha1` (the Herbalist), 6-10 from the dojo's +continuation rungs. `tests/cultivation.mjs` fails if any realm's pill is +orphaned. Realm 1 must not come from the marketplace: that is gated behind the +Paper Boy at 40% a visit, and realm 1 opens long before it. + +Realms live in `global.flags.mod_realm` and the bonus is applied **in +`allbuff`** — never written into `you.stra`/`strm`, or it would compound on +every load. Reaching a Qi Circulation level does not advance the realm; it opens +a bottleneck that costs a breakthrough pill and can fail. + +**The wall is sized to the climb (v4.6).** `MOD_realmClimbSec(n)` is the +Circulate Qi time from realm n−1's threshold to n's on the live curve. +`MOD_insightNeed(n)` is `wallShare` (5%) of that at the meditation rate — +never below the old 1, 3, 5 … floor or the wall below it. `MOD_secludeFull(n)` is +the seclusion that earns that insight on average (floor 900s), and +`MOD_devMinutes(n)` equals it (floor 720). Never restate these as numbers: they +move with the curve and the ladder. Meditation grants insight **only at a wall** +— on the way up the same Circulate Qi that climbed the level used to pay for +every wall long before it. `tests/road.mjs` prints the table. + +**The Qi Settling Pill costs the breakthrough pill of the realm at the wall** +(`MOD_settlePrice`, section 48, never under 5,200), through a getter on its +Herbalist listing plus a per-tick reprice of rows already stocked — vendor stock +keeps its own copy of the price from restock time. + +**Every price the ladder requires is held by `tests/econ.mjs`** to 10% of the +climb it gates, in hours of the best income open at that tier (fighting each +area through `dmg_calc`, gathering each node), on the Original preset. The Pill +Tower's realm 8-10 pills were cut to pass it when the coin drop went to 0. + +Elemental techniques are a **proc inside a wrapped `you.battle_ai`**, because +combat is automatic and there is no ability picker. They use `stt: 2` so they +route through the INT branch of `dmg_calc`. The wrapper must keep swallowing +errors and falling through to the ordinary attack — a bug in a proc must never +cost a swing. + +**A skill created after section 10/11 is invisible to their maps.** Register it: +`MOD_KEY_BY_ID[sk.id] = key` (cap system) and +`MOD_PARENT_OF[key] = MOD_PARENT_KEY[MOD_sectionOf(sk)]` (panel grouping). +Nothing errors if you forget — the skill just silently loses its cap and its +section. + +## The dojo + +`chss.t3.sl` is a long `else` chain with several one-time screens inside it — +the first skillbook choice, the accessory gift, the named-manual choice — plus +a **"Level Advancement"** ladder drawn by an anonymous handler nested inside it. +Read all of it before adding anything: a first attempt at extending the dojo +missed both Level Advancement and the second grade of skillbook that already +existed. + +Section 27 continues that ladder from 35 to 110 in fives, as its own lobby entry +gated on `dj1rw6` (the nested handler cannot be appended to). Spirit pills are +sized against `4*lvl^3 + lvl^2`, the character exp curve — a level at 110 costs +5.3M, so the base game's best pill is worth 0.3% of one. + +## Titles + +Rank (`rar`) runs **1-10 and is derived from the level that earns the title** +(`MOD_RANK_AT`), never hand-assigned — the base game gave rank 3 at skill level +8. Every skill grants five titles, at levels 25/50/75/90/110, folded into the +milestone already at that level (a second milestone at the same level is a +duplicate the save cannot tell apart by index). + +- A title's exp bonus writes to `skl..p`, which **is saved and is restored + after milestones fire**. Never set it from milestone code; it is reconciled on + the tick against `global.flags.mod_ttlxp`, like section 13 does for `you.res`. +- Colours come from `MOD_RANK_COLOUR` (ten ranks). The game's own rank 7 + tooltip branch **throws** — it sets `this.dl`, undefined in a type 5 call — + so `dscr` is wrapped to park the rank at 1 (a branch-less value) and paint + the label afterwards. Do not remove that wrapper. +- Only the **worn** title applies, until renown level N makes rank ≤ N passive. + Base-game `talent`s are a separate, already-permanent mechanism — don't + conflate them. + +### How a title finds its group + +`MOD_TITLE_SKILL` attributes a title to a skill in four passes, each derived +from something already stated — never a per-title table: + +1. `MOD_SKILL_TITLES` — the generated five per skill know their own skill. +2. `MOD_FLAGSHIP` — section 17 already pairs the skill with the title it + grants. Read the pairing; the milestone closure captures the row in a + variable, so pass 3's source scan cannot see a literal to match. +3. Scanning each milestone's `f` source for a `giveTitle(ttl.x)` / + `MOD_title('x')` literal. +4. **The author's numbered tiers**, keyed `` — `tghs1/2/3`, + `srd3/srd4`, `dth4`, `rtr1`, `axc3`. Most are granted by nothing at all, so + pass 3 cannot see them. **The trailing digit is load-bearing**: without it + the stem of `ttl.thr` ("Thrasher", for smashing the dojo's equipment) is + `thr`, which is `skl.thr`, Throwing. Only the numbered convention is safe to + read this way, and an ambiguous stem prefix is left alone. + +What no skill claims is bucketed by the same digit-stripped stem into +**ladders** — `kill1..5`, `geti1..4`, `ttsttl1..4`, `mod_realm1..10` and so on. +Membership is derived; `MOD_TITLE_FAMILY` holds only a *label per family*, with +the lowest-ranked member's name as the fallback. A family of one is not a +family — it falls back to a plain row. The picker and the wiki's title page +both read this, so they cannot drift apart. + +- The picker **skips** a title with no usable name; the wiki **keeps** it and + tags it "unfinished". The author left five. Section 41 finished the four + whose intent is in his code — `shpt2`/`shpt3` at the 5,000 / 10,000 + purchases his commented-out checks name, `mone2`/`mone3` at 10 / 100 gold + (his checks were placeholders at `GOLD`, his other ladders step 5-10x). The + new checks are pushed onto his own `global.shptchk` / `global.monchk`, so + they run at his call sites. **`ttl.ddcd` is left alone on purpose**: name + and desc are `"null"` and nothing says what it was for. It is NOT the + missing first rung of the stay-home ladder — that exists as `ttl.neet`. +- `tests/wiki.mjs` caps the tallest page at 6,000px, which is why the ladders + nest under one "Ladders of their own" group rather than sitting beside the + skills — eleven more top-level summaries cost ~530px on the page that is + already the tallest. + +## Perks and the changelog + +Every skill has perks at `10/25/50/60/75/90/110` (section 22). New ones are +**appended above the skill's current highest level** — never inserted — because +"granted" flags are stored by array index. `tests/perkcoverage.mjs` fails if any +skill tops out below the highest story cap. + +- A perk must never touch `skl..p`. Skill xp multipliers are restored from + the save *after* milestones fire, so the bonus is overwritten on the load that + first grants it and `g` is true forever after. Stats restore *before* + milestones, so those are safe. +- Absorption perks use `you.caff`, not `you.res` — section 13 already drives + `you.res` continuously for those skills and reconciles it through + `global.flags.mod_aff`. + +**`changelog/mod-changelog.html` gets an entry for EVERY change**, including +ones that are not gameplay — a README, a build script, repo housekeeping. Mark +those as such rather than leaving them out; the rule is literal on purpose, so +there is never a judgement call about what counts. Newest first. + +That file is the mod's own. **`changelog/changelog.html` is the AUTHOR'S and must +stay byte-identical to `upstream/main`** — the mod's entries used to be prepended +to it, and were moved out precisely so the single script tag in `index.html` is +the only change the mod makes to anything of his. Verify before committing: + +``` +git diff upstream/main -- changelog/changelog.html # must be empty +./tests/authorfiles.sh # both rules, and CI runs it +``` + +**Remotes:** `origin` is the fork (`Xeus24/23html.github.io`) and is where pushes +go; `upstream` is the author's game (`23html/23html.github.io`), which this +account cannot push to. They were renamed so the desktop app's cloud move had a +writable `origin` — compare the author's files against `upstream`, never +`origin`. + +In game the `changelog` button opens the mod's file; the version number opens +his. + +## The rank ladder + +`you.rank()` is the base game's **Power rank** — the number under the portrait, +lowest is strongest. It is **derived from live stats on every read**, not +stored, so there is no field to award. Rank 1 is reachable on stats alone, but +only with **every** skill at 110: measured, the whole of ranks 1-10 lives inside +the last fifteen skill levels (at 108 across the board you are still rank 3), +after a game in which the number is an unreadable ten-digit blob. + +Section 34 adds a second way to hold one. Ten challengers in the Hall of the +First Gate, off the Old Path trailhead. + +- **The rank is a flag, and `you.rank` is wrapped to floor at it.** Never write + it into a stat — `you.res` and `skl.p` are the cautionary tales. It floors, + never caps: hold rank 6 with rank-900 stats and it shows 6; pass it and it + shows the better number. `you = new You()` runs once at game init and `load()` + restores fields into that instance rather than replacing it, which is the only + reason the wrapper survives a load. `tests/ranks.mjs` asserts both directions. +- **Difficulty steps into fight length, and the margin is re-derived.** Each + duel is a `protected` one-creature `size 1` area, which is exactly what + `MOD_scaleEnemy` reads as an arena boss, so it is already anchored to the + player. `MOD_scaleRankDuel` then reads back the `_modKillT`/`_modDieT` it + stored, multiplies the length, and **re-asserts `kill < die` itself**. Do not + "just make it harder" by scaling both targets in opposite directions: margin + 1.5 over step^1.5 is already under 1.2 by a step of 1.16, so the guarantee is + gone by rung three. +- **Challenger `rnk` stays ≤ 14.** That is the monster danger grade, not the + ladder rank — the coin drop is `rand(lvl, lvl/4) ** (1 + rnk/5 << 0)`, so + rnk 15 pushes the exponent from 3 to 4 and multiplies an endgame drop by a + hundred. The base game's toughest creature sits at rnk 10, in the same bucket. +- Areas 981-990, creatures 990-999, the hall at `chss.mod_hall` 979. Adding a + rung means adding a row to `MOD_RANK_LADDER`; the hall, the wiki page and + `modRankLadder()` are all drawn from it. + +## Crafting + +The base game's crafting stops at two stars in practice. Measured across all 62 +recipes, by output rarity and by whether any `giveRcp` call can hand the recipe +over: 35/43 reachable at 1★, 7/17 at 2★, **0/1 at 3★** (the only one is +`rcp.trr`, Trinity), 1/1 at 4★ (the Clover Pin), nothing at 5★. **Nineteen +recipes have no `giveRcp` anywhere** — defined, priced, complete, unteachable. +Section 50 teaches the fifteen dishes by Cooking level (`MOD_DISHES`, simplest +first by the cooking exp each grants); `test`, Trinity, Star Shell and Grand +Gelatin stay unteachable, reported in the wiki: they are not dishes, and which +the author gated is his call. + +Section 35 adds four rungs, 2★ to 5★, each a full set (weapon, body armour, +shield, accessory, tonic) from twelve gathered materials along three lines, at +four nodes behind one door on the Village Center. Each rung consumes the rung +below it. + +- **Ids are blocked by namespace and `load()` depends on it.** A saved item id + resolves through `itemgroup[(id+1)/10000<<0]`, `itemgroup` being + `[item, wpn, eqp, sld, acc]`. An id in the wrong block restores as the wrong + object or not at all. Items 9200+, weapons 10101+, armour 20101+, shields + 30101+, accessories 40101+. +- **`rar` must not exceed 6.** `equip()` does + `w.wc = global.text.wecs[w.rar][0]` and `wecs` has seven entries, so a 7★ + anything throws the moment it is worn. +- **Armour `str` is nearly decorative.** `stat_r` adds only `eqp[0].str` (the + weapon) into `str_d`; `int`/`agl`/`spd` are summed over every slot. Armour + `str` shows in the DEF tooltips and nowhere in `dmg_calc`. +- **Re-index the value model after adding recipes.** Section 15 builds + `MOD_VAL.madeBy` in an IIFE at load. Without the re-index at the end of + section 35, new gear falls past rule 2 (price from inputs) to rule 3 (price + from stats) — a 5★ sword came out at 1,161 while each of the eight ingots it + eats came out at 13,000. Clear `MOD_VAL.cache` too. +- **Materials are anchored, not derived.** Rule 4 would price them + `stypeBase[5] * rarMult[5]` = 3,250 each, because `rarMult` is calibrated + against equipment rarity. `MOD_CRAFT.matValue` sets 6/18/54/162, sized so + gathering at the cap pays about six coin a second after the 25% sell rate + against roughly one a second from endgame combat. +- Nodes set **no tier flag** — they are gathering locations, not fight areas. + See the catacombs note above for why that matters. + +## Ids are the save's primary key + +**No two things in a namespace may share an id, and nothing enforced this until +`tests/ids.mjs`.** The game matches ids in loops with no `break`: + +```js +for (a in a6) for (b in skl) if (a6[a].id === skl[b].id) you.skls.push(skl[b]) +for (gg in chss) if (chss[gg].id === global.lst_loc) chss[gg].sl() +``` + +So a duplicate is not a clash, it is corruption that compounds. Section 29 gave +Fire Mastery `2010 + 0`; section 11 had already given the Companions parent +`2000 + 10`. Every save/load pushed both skills for both entries and the sheet +**doubled each cycle** — 1, 2, 4, 8, 16 — until a played-in character had a +hundred of each. `MOD_KEY_BY_ID` is id-keyed too, so the clash also silently +took the Companions parent's level cap. + +- **An `item` id is a CLASS as well as a namespace.** `dscr` type 1 reads the + category straight off the number, and there is no field for it — + `< 3000` Food (plus a "Tried:" footer), `3000-4999` Medicine/Tool, + `5000-8999` Material/Misc, `>= 9000` Book (plus a "Read:" footer). `stype` is + **not** it: the author's stype 4 spans Food, Medicine and Book. Everything the + mod added started at 9100+, so twenty-six items — ore, hides, essences, every + pill — were labelled "Book" with a "Read: Never" line under them. Pick from + `MOD_ITEM_IDS`; `tests/ids.mjs` checks the class against the mod's own tables. +- **Moving an item id costs the player what they were holding.** The inventory + saves `{id, am, data}` and restores by matching id, so an entry whose id no + longer exists is dropped without a word. Section 37 reads the blob *before* + the game's load runs, notes what the moved ids were carrying, and gives it + back afterwards. It never rewrites the save — a migration that fails is + better than a load that does. +- Ranges in use: parents `2000+section` (2001-2010), masteries 2011-2016, + Renown 2100, converged skills 2201-2210. Mod locations 973-990 (973 the Damp + cellar, 974 the Slip Archive, 979 the hall, 981-990 the rank duels and nodes). **Check `tests/ids.mjs` before picking.** +- Prefer an explicit table to arithmetic when two schemes could ever meet: + `MOD_ELEM_IDS` lists the six rather than computing them, and says why fire is + out of line. +- Changing an id loses that skill's saved level, so move the *newest* claimant. +- `MOD_dedupeSkills` (section 33) prunes `you.skls` on load. It repairs damage + already written into saves; it is not a licence to leave a duplicate id. +- **`global.titles` has the same shape of bug, from the other direction.** + `giveTitle` pushes each title onto *both* `global.titles` and + `global.titlese`, and `load()` rebuilds `global.titles` from the save by index + and then appends the whole of `titlese` on top — so everything earned that + session lands in the array twice (241 titles measured as 481 after one load, + 241 distinct). It does not compound, but `save()` writes the inflated array + back out, and both the game's title screen and the mod's picker iterate it. + `MOD_dedupeTitles` repairs it on load, by identity. **Do not "fix" it at the + source** — `titlese` is the author's mechanism. Anything the mod adds that + reads `global.titles` should still prefer counting from `ttl`, as + `MOD_titleCount` does. + +## Skill rarity and convergence (section 39) + +**Rarity is derived:** `MOD_skillRank(sk)` is `MOD_rankForLevel(sk.lvl)` — the +rank of the level the player has taken it to, against `MOD_RANK_AT`, painted +with `MOD_RANK_COLOUR`. Never hand-assign a rarity to a skill. + +The colour goes on the row's `children[0]` **style**, never into the name. The +game's per-second updater rewrites `children[0].innerHTML` every tick, which +wipes markup but leaves the element's own inline style alone. Names are a +primary key and must stay plain. + +**Folding HIDES a skill; it must never remove it from `you.skls`.** A Jade Slip +marks the skill in `global.flags.mod_folded` and section 13's `renderSkl` +wrapper hides the row through the same branch that hides collapsed groups and +maxed skills. Shortening `you.skls` instead breaks three things at once: + +- the game's panel only redraws when the list **grows** + (`let sklsize=you.skls.length; ... if(sklsize= 1` destroys +`amount * randf(rot[2],rot[3]) + 1`. So a perishable stock **converges** rather +than accumulating: + +``` +A = (gain_per_day * days_between_rot - 1) / lossFraction +``` + +The mod never touched the drop table. It made a rabbit take ten swings instead +of one (`MOD_ENEMY.kill` targets eight landed swings; a quest-stage rabbit went +from 93 HP to 3,574), and ten times less meat an hour against an unchanged rot +clock dropped the summer steady state to **4 against the 10 required**. Feasible +in three seasons and impossible in the fourth is a bug, not a difficulty choice. + +On Fights: Original (the default since v4.6) his fights are back, so is his 6%, +and `MOD_MEAT.killSecOriginal` (5s) replaces the 15s — see section 47. On Scaled, +section 36 raises the drop 6% → 18%, solved backwards from a steady state of 25 +in the worst season and the first hunting area, and applies it to **every** +creature carrying `rwmt1` found by scanning the drop tables rather than by +naming the rabbit and the wolf. `MOD_meatSteadyState()` is the shared formula; +`modMeat()` prints the table. **Any change to kill times has to re-check this** — +it is the one quest whose difficulty is set by the clock rather than by the +fight. + +## Choice lines have a fixed height + +`.chs` is `height:22px` with no overflow rule, so a choice that wraps does not +grow its row — it spills over the next one and off the bottom of the panel. +Roughly 55 characters fit. Keep an added line under 50 and put the sentence in +`addDesc(node, null, 2, title, text)`, which is what the game does. Use +`MOD_chsAboveBack()` so the line lands above the location's `"<= Return"`; all +85 of the game's back-choices start with `"<=`. + +## Selling and key items + +Section 15 added selling, which the base game does not have, so **the mod owns +the question of what must never be sold.** `MOD_KEY_ITEMS` (section 32) derives +it: the game is one inline ` +``` + +Delete that line and the game is stock again. `git checkout index.html` also +works, and is the only reason it is the sole change to that file. + +## Playing it on the author's site, with nothing to download + +Install [Tampermonkey](https://www.tampermonkey.net/) or +[Violentmonkey](https://violentmonkey.github.io/) in your browser — on Safari, +Tampermonkey or the free [Userscripts](https://github.com/quoid/userscripts) +extension — then open +[`userscript/proto23-mod.user.js`](https://raw.githubusercontent.com/Xeus24/23html.github.io/main/userscript/proto23-mod.user.js) +and accept the install. The next time you open +[23html.github.io](https://23html.github.io/), the whole mod runs on it, and your +save there is backed up before the mod first touches it (`modBackups()` in the +console lists the backups). The manager checks the same address for updates. + +Turning the userscript off gives you the author's game back, but a save the mod +has written to keeps the mod's extra fields. Download a backup from the saves +panel first if you want to go back and forth. + +**On Safari, download a backup now and then.** Safari can clear a site's stored +data when you have not visited it for a while (seven days of browsing, under its +tracking prevention), and the saves and the automatic backups both live there. +A downloaded backup file is the copy that survives it. The mod reminds you, at +most once a day, until you have downloaded one that week. + +## Running it + +Open `index.html` in a browser, or serve the folder: + +```bash +npx http-server -p 8080 -c-1 . +``` + +`-c-1` disables caching. Without it the browser holds `mod.js` for an hour and +edits appear not to take, which is confusing enough to be worth the flag. + +## Playing it from the fork's own site + +The repository root is the game with the mod's one script tag already in it, so +GitHub Pages can serve it as it stands: in the fork's **Settings > Pages**, set +the source to **Deploy from a branch**, branch `main`, folder `/ (root)`. It is +then at `https://xeus24.github.io/23html.github.io/`, with no userscript and no +download. `.nojekyll` at the root tells Pages to serve the files as they are. + +Saves live in the browser per site, so this address is a separate save from +[23html.github.io](https://23html.github.io/) and from a local copy: move a +character between them with the saves panel's export and import. + +## What it changes + +Fifty-one sections, roughly in the order they were built. The design record +for every one of them, including the reasoning and what was measured, is in +[MOD-NOTES.md](MOD-NOTES.md). Version 4.7. + +**Measured against the original** + +The mod is checked against the author's untouched game: a test loads it twice, +once with `mod.js` blocked, and runs the same measurements on both. + +- Skill exp costs **exactly what the original charges below level 10**. That is + the range he designed — all 22 of his perk ladders end between level 1 and 15. + From level 10 the cost rises about 3% a level, at a rate solved so level 110 + is reachable in the same time as before. His own curve cannot be kept past + that: 109→110 alone costs about 10¹⁴ xp. +- Skill exp multiplier 1 and enemy coin drops off, the original's defaults — + both adjustable in settings. +- **Fights in the author's areas are his**, creature for creature, so you + out-grow them the way you do in his game. A *Fights* setting switches to + *Scaled*, where every fight is sized to you. +- Every price the progression asks for is checked against what you can earn + at that point with no coin drop. + +**Progression** + +- Skill level caps tied to story progress, 10 through 110. +- Perks at 10/25/50/60/75/90/110 on every skill. +- The dojo's "Level Advancement" ladder carried from level 30 to 110, which is + what the instructor promises and the base game stops delivering. + +**Combat and places** + +- An enemy model for everything the mod adds, and for the whole game on the + *Scaled* setting. It is solved per spawn against your measured power — about + 8 swings to kill, 20 to die — rather than fitted to a level. See the note + below on why the obvious approach does not work here. +- Three areas past the base game's last — the Sunken Hollow, the Ashen Spire and + the Long Vigil — opening in order once the golem arena is cleared. An entrance + to the author's catacombs, the Pill Tower, and the Damp cellar, which he wrote + and never connected. +- **The rank ladder.** The Power rank under your portrait is computed from your + stats, and rank 1 was only reachable by taking every single skill to 110. The + Hall of the First Gate adds ten named challengers, fought in order; beating + one takes that rank, and the rank you hold floors the rank you are shown + without ever capping it. +- **Crafting to five stars.** The base game's crafting stops at two in practice. + Four new rungs add a full set each — weapon, armour, shield, accessory, tonic — + from twelve gathered materials at four resource nodes that open with the story. + +**Cultivation** + +- **Ten realms above Mortal**, Qi Refining through Tribulation Transcendence, + advanced by breaking through a bottleneck. Each is worth a compounding + multiplier — up to ×6 on every stat and ×8.5 on health and energy. +- **The road between them**, modelled on the genre's own glossary: a Spiritual + Root rolled once, nine layers to every realm, insight earned by meditating, + by fights you nearly lost and by going somewhere new, Closed Door Training to + consolidate and shelter an attempt, Qi Deviation for failing in the open, and + a Heavenly Tribulation at the top. Each wall is sized to the climb to it: + about 5% of the time it took. +- Six elemental mastery skills whose techniques fire during combat. +- Your realm sits on the rank line, and at a bottleneck it is the breakthrough + button. + +**Skills, titles and items** + +- Skills of the mod's own, five earned actions, and effects for the skills and + affinities the base game left inert. +- **Skill rarity** — ten grades, from the level you have taken a skill to — + shown in the list and tooltips, with a sort and a filter. +- **Folding.** A Jade Slip folds a skill into its section; fold a whole section + and it is one line. Folded skills keep every level, perk and buff. +- Title ranks 1–10, derived from the level that earns the title rather than + assigned by feel; five titles per skill; titles that do something, worn or + made passive by Renown; and four of the author's unfinished titles finished. +- Selling, with anything that is the only way to unlock something protected. +- Three bugs in the author's own code fixed: the Death skill made dying cost + *more* energy as it levelled, Shield Mastery could turn your defence into + extra damage taken, and luck never reached the crit roll. + +**Quality of life** + +- Three save slots, with a "start new save" button, and **automatic backups**: + before a new version of the mod loads, every slot is copied first, and again + once a day. The saves panel restores any of them, or downloads it as a file. +- A **Pacing** setting — *Original* (the default) or *Mod before 4.2*, the + faster pacing and scaled fights the mod had before it was matched to the + original, kept as a legacy option. +- **Settings travel with the save**: a new browser or device takes them from it, + and one with settings of its own keeps those. +- Settings boxes for game speed, skill exp, coin drops and **number format** — + short (4.56M), scientific, myriads (5.6亿) or as the original. Up to 9,999 + every format prints exactly what the original prints. +- An optional "unrestricted actions" toggle — several actions at once, started + anywhere. Off by default; both are deliberate limits. +- Collapsible skill sections, live effect descriptions, and a title picker + grouped by skill. +- **A wiki**, on the bottom bar and in settings. Fifteen pages covering every + skill, area, item, title, action and realm, generated from the live game data + each time it opens and reading your save — including a skill handbook and a + **What next** page that says what you can do right now. + +Every change is logged in `changelog/mod-changelog.html`, reachable in game from +the `changelog` button in the bottom bar. The version number next to it opens the +game's own changelog, which the mod does not touch. + +## The one thing to know before changing anything + +`dmg_calc` is **subtractive in both directions**: an attack does +`attacker.str − defender.str`. A defender's STR *is* their armour. So scaling an +enemy's STR raises its offence and its armour together, and once that armour +passes your STR your damage does not get small — it clamps to zero. + +An earlier version of the mod did exactly that, fitted with a grid search scored +on `enemyHP / playerSTR`, a ratio that ignores the subtraction entirely. +Measured through the game's real combat math, the tutorial needed 63,000 swings +and killed you in one, and every tier from the southern forest onward was +kill-in-1 / die-in-1. Judge a fight by driving `dmg_calc` and `hit_calc`, never +by a stat ratio. + +## Tests + +```bash +npm install && npx playwright install chromium # once +npm test # all 48 scripts, four at a time +./tests/run.sh audit combat # a subset +JOBS=1 npm test # one at a time, output live +BROWSER=webkit npm test # on Safari's engine (npx playwright install webkit) +``` + +They drive a real browser against a served copy of the game, so they test the +actual thing rather than a model of it. [tests/README.md](tests/README.md) +explains what each one covers. The broadest is `allareas.mjs`: every area, every +creature, both ends of every level band, ten story tiers, four skill builds — +3,720 matchups, all of which must be winnable. `vanilla.mjs` holds the mod to +the original game wherever it claims to match it, and `baseline.mjs` keeps the +numbers that define the balance in a committed file, so any change to them shows +up as a diff. `fights.mjs` holds the author's areas to his page spawn for spawn, +and `econ.mjs` holds every required price to what that stage pays. + +The same suite runs on GitHub for every push and pull request, on Chromium and on +WebKit (the engine Safari is built on), along with a check that the author's +files are untouched (`tests/authorfiles.sh`). + +**Several tests write to the save.** Export one first, or run them against a +copy of the folder. + +## Layout + +| | | +|---|---| +| `mod.js` | the entire mod, ~13,800 lines | +| `userscript/` | the mod as a userscript for the hosted game — generated, `npm run build:userscript` | +| `tools/` | the userscript build | +| `MOD-NOTES.md` | the design record, section by section, with the reasoning | +| `CLAUDE.md` | the constraints that are easy to violate by accident | +| `tests/` | 48 Playwright scripts, `lib/stats.mjs`, the balance baseline, `authorfiles.sh` | +| `.github/workflows/` | the suite on every push and pull request | +| `changelog/mod-changelog.html` | what changed, every time | +| `index.html` | the author's game, plus one script tag — the only edit to anything of his | + +The checkout's `origin` is this fork; the author's repository is `upstream`. + +## Credit + +The game is truezhangwei's. Everything in `mod.js`, `userscript/`, `tools/`, +`tests/`, `MOD-NOTES.md` and this file is a modification of it, and none of it +is endorsed by the author. The userscript runs on the author's site in your own +browser; it changes nothing on the site itself. diff --git a/changelog/mod-changelog.html b/changelog/mod-changelog.html new file mode 100644 index 0000000..2dfeed9 --- /dev/null +++ b/changelog/mod-changelog.html @@ -0,0 +1,862 @@ + + + +proto23 mod — changelog + + + +PROTO23 MOD — CHANGELOG
+Changes made by the local mod.js. None of this is the work of the +game's author — the game's own changelog is here.
+Remove the <script src="mod.js"> line from index.html and none of it applies.

+
+ +----MOD 4.7 (a pill for the wall):
+[2026/10/07]
+Stuck at a realm 6-10 wall without the pill? The instructor keeps one back. The dojo's pills for those realms are
+  paid at character levels (45, 60, 75, 90, 105), while the walls are Circulate Qi levels, so a wall could come
+  before the level that pays for it. "A pill for the wall" on the dojo screen gives that realm's pill once; the level
+  reward for the same realm then skips its pill
+
+ +----MOD 4.7 (creature gear):
+[2026/10/07]
+Creatures no longer share one weapon. In the game every creature held the same object, and the affinities the author
+  wrote for 16 of them were all written onto it, so each creature carried whichever was written last. Your bare fists
+  were that same object too, so your level raised physical and blunt creatures' damage. Each creature now has its own
+  gear with the values he wrote for it, and your fists are your own
+
+ +----MOD 4.7 (dishes, a screen fix):
+[2026/10/07]
+Fifteen dishes nothing could teach are now taught by Cooking. Bacon and Eggs, Morning Set, Lunch Set, Onion Rings,
+  Cooked Fish, Batter Fried Fish, Fried Nori, Steamed Bun, Hippo Cookie, Steak, Cinnamon Bun, Broth, Egg Soup, Creamy
+  Chowder and Mushroom Soup were in the game and unreachable. You work each out at a Cooking level from 6 to 44, simplest
+  first. Trinity, Star Shell and Grand Gelatin (his late accessories) are still unteachable
+The gate's two locked-door hints no longer wrap over the line below them; the full sentence is in the tooltip
+  Tooling, not gameplay: a check that draws every mod screen with a capped character and a very long name; the
+  step from the author's last area to the Hollow is now held winnable with margin

+
+ +----MOD 4.7 (background tabs):
+[2026/10/07]
+A game left in a background tab catches up. Browsers slow a hidden tab's timers to about once a minute, so the
+  game nearly stopped while you were away. When it next runs, the mod replays the missed time, up to an hour: the
+  clock, restocks and weather, and the seconds of the action you were doing. Fights are never replayed, so being
+  away cannot kill you. setCatchUp(seconds) changes the cap, 0 turns it off; it travels with your save
+
+ +----MOD 4.7 (wiki search):
+[2026/10/07]
+The wiki search covers every page. The page list shows how many entries match on each page, dims the ones with none,
+  and Enter jumps to the first page that has a match
+  Tooling, not gameplay: package-lock.json is now committed (it was ignored, so every GitHub run failed at install)
+
+ +----MOD 4.7 (pills and hosting):
+[2026/09/30]
+The Pill Tower stocks the top realm pills sooner. A day is 24 real minutes and the Tower restocks every three, so
+  realm 10's pill at 6% took about twenty hours on average. Realms 8, 9 and 10 now stock at 26%, 20% and 15%: about
+  4.6, 6 and 8 hours, rising with the realm
+  Tooling, not gameplay: instructions in the README for playing from the fork's own GitHub Pages site, and a
+  .nojekyll file so Pages serves the files as they are; a check that every Tower pill is in stock within 8 hours

+
+ +----MOD 4.7 (small):
+[2026/09/30]
+Skill rarity has a grade marker. A small number after a skill's name (2 to 10) gives its rarity rank, so it reads
+  without colour. Rank 1 shows nothing
+  Tooling, not gameplay: GitHub caches the downloaded test browsers between runs; a check for the marker
+
+ +----MOD 4.7 (housekeeping):
+[2026/09/29]
+Switching save slots cannot pull in another slot's settings. A browser now takes settings from a save once, on
+  its first load; a later load (a slot switch) leaves the browser's own settings alone
+  Tooling, not gameplay: a check that the docs' stated numbers (sections, scripts, version, suite time) match the
+  source; the tests now run on Firefox in GitHub too; the docs' script count is 42
+  (the suite takes about 75 seconds)

+
+ +----MOD 4.7:
+[2026/09/28]
+The Qi Settling Pill costs what failing cost. Qi Deviation now lasts as long as the seclusion you skipped —
+  up to 91 in-game days at the top — and a flat 5,200 made it ten minutes of late-game coin. It now costs the
+  breakthrough pill of the realm you failed at, never less than before
+The rank line shows the wall: at a bottleneck it reads, for example, "½ step to Nascent Soul >> 40/104" — the
+  insight you have against what it asks for
+Your settings travel with your save. A browser with no settings of its own takes pacing, fights, number format,
+  speed and the rest from the save, so moving to a new browser or device brings them. One that has its own keeps them
+After an update the log says so, once, with the changelog a click away
+On Safari, a reminder to download a backup, at most once a day and not after you have downloaded one. Safari can
+  clear a site's saved data after a week unvisited, backups included
+The userscript says so in the game when the author's game is a version the mod was not tested on
+Near-death insight, explained: the wiki says where fights you nearly lost actually happen. On Original fights his
+  areas are no threat once you reach them; the three past the golem arena and the rank duels bring you there within a
+  few fights. No wall needs it — each can be met by sitting alone
+Small visuals: the mod's number boxes lose the spinner arrows that sat on their digits in Safari, and the wiki uses a
+  proper monospace font where the game's is missing, so tables wrap the same everywhere. The old pacing preset is
+  labelled legacy: it is kept exactly as it was, and its prices come cheap
+  Tooling: every tooltip in the game (1,367 of them) is rendered and read in a new check, so a description
+  that shows code fails the suite; the spirit pills and the Root Cleansing Pill are priced against income too; and the
+  tests wait for the game to tick instead of a flat 4.5 seconds — the whole suite is about 75 seconds. 40 checks.

+
+----MOD 4.6:
+[2026/09/28]
+Fights as the author wrote them. A new Fights setting, and Original is the default: every area the author
+  made has his creatures exactly as he made them — his level ranges, his HP, his exp — so you out-grow
+  them the way you do in his game. The forest goes back to a swing or two a kill
+  The mod's own areas (the three past the golem arena, and the rank duels) are still sized to you, since
+  there is no original of them. Scaled is the old behaviour everywhere, and the Mod before 4.2 preset picks it
+  Raw Meat is back to the author's 6% on Original: the raise to 18% only made up for the mod's longer fights
+  Checked against the author's own page: all 70 spawns in his areas come out stat for stat the same, and
+  every step of his route past the tutorial is winnable at the point you first reach it.

+Breakthrough walls sized to the climb. Realms 3 to 10 take days to months of Circulate Qi on the current skill
+  curve, but the insight they asked for (5 to 19) and the fifteen minutes of seclusion were sized for when
+  they took minutes, and meditating on the way up paid for every wall long before you reached it
+  Now a wall asks for about 5% of the climb to it, and meditating only earns insight while you are at one.
+  Realm 3's wall is 60 insight, about 1.4 hours in seclusion; realm 10's is 1,580, about 37 hours
+  Full consolidation takes as long as seclusion takes to earn that insight, so sitting it out finishes both,
+  and failing in the open leaves you deviated for as long as the seclusion you skipped. The wiki's cultivation
+  page has the table. Realms 1 and 2 are as they were
+The Pill Tower's top pills cost what you can earn. Realm 8, 9 and 10 were 900K, 2.4M and 7M, priced when
+  enemies dropped coin. With the original's no coin drop, they are 540K, 560K and 6.5M
+  Measured: the best income at every stage, from fighting in each area and gathering at each node, against
+  every price the ladder requires. The rule is that none costs more than 10% of the climb it gates. The
+  Herbalist's pills and the Jade Slips already passed; all hundred slips come to about 4% of what the game
+  pays after the Slip Archive opens.

+Play it on the author's hosted game, with no local copy: userscript/proto23-mod.user.js, for Tampermonkey or
+  Violentmonkey. It runs the whole mod on https://23html.github.io/, and your save there is backed up before the
+  mod first touches it
+  Tooling, not gameplay: the tests run on GitHub for every push and pull request; a check that the author's
+  two files are still his (his changelog byte for byte, index.html his plus the one script tag); and the test
+  runner reports seconds per script. allareas, listed as the slow one, now takes about seven seconds, so it
+  was left as it is. New checks: fights.mjs and userscript.mjs; econ.mjs is now a real check rather than a
+  printout. 38 in all.

+Safari. The skill list no longer wraps each exp bar onto a second line (Safari's scrollbar left the
+  row a few pixels short, which also made the list scroll sideways); the mod's dropdowns in settings match
+  the panel instead of showing as grey system buttons; and clicking a section header no longer selects its text
+The realm tooltip on the rank line showed a page of program code instead of your realm, root, insight and
+  breakthrough odds. It shows those now
+The skill list does far less work while it is open: it only redraws what changed each second, rather than
+  every line — about ten times cheaper, in every browser
+  Tooling: every check can now run on WebKit, the engine Safari is built on, and GitHub runs them on
+  both. The Safari problems above were invisible in Chrome's test browser, which has no scrollbar width.
+  On Safari, download a backup now and then: it can clear a site's saved data after a week unvisited.

+
+----MOD 4.5:
+[2026/09/28]
+Three bugs in the author's own code, fixed.
+  Death made dying worse the more you levelled it. You kept 55% of your energy at level 0, none at
+  level 10, and went into NEGATIVE energy from level 11 — the opposite of what the skill says. It now
+  keeps 55% at level 0, exactly as before, rising to 78% at 10 and 96% at 110
+  Shield Mastery could turn your defence into extra damage. At high levels one bracket in the enemy's
+  damage went below zero and your defence was added to the hit instead of taken off it. That bracket
+  now stops at zero. Nothing changes where it was already fine
+  Luck never touched your crit chance. The author wrote a luck multiplier and then declared it where
+  the crit roll could not see it. It works now, up to a 50% crit chance from luck
+  Why the cap: the mod's perks grant far more luck than the author's game ever did, and through his
+  formula a capped-out character would crit on every swing. Enemies are sized with it included.

+Breakthrough history on the What next page: your last attempts, their odds, and how each ended
+Backups: once a day as well, in their own place so they cannot push out an update's backup; and any
+  backup can be downloaded as a file and loaded back, so a save survives clearing the browser
+Shop prices read: the mod's own stock (pills to seven million) follows your number format. The base
+  game's prices, all under 10,000, are untouched
+  Tooling: tests for the cultivation road (every rule, which until now had only been checked by hand),
+  for the three fixes, and a balance baseline — the numbers that define the balance are kept in
+  tests/baselines/balance.json, so any change to them shows as a diff. 36 checks, all passing.

+
+----MOD 4.4:
+[2026/09/27]
+Your saves are backed up before a new version of the mod loads. Every slot, copied before the game
+  has even read its save. Three are kept. The saves panel lists them, restores one, or backs up on demand
+  It is the very first thing the mod does when it loads, and depends on nothing after it: an
+  update that breaks is exactly when you want the backup, and a broken section stops everything
+  below it while the game goes on loading and autosaving. Restoring puts every slot back, and backs
+  up what you had first, so it can be undone. It restores your saves, not the mod's code.

+A Pacing setting: Original, the default since 4.2, or Mod before 4.2 — skills much faster early,
+  skill exp 2x and a 15% coin drop, exactly as the mod played before. Level 110 is the same distance
+  away in both, and fights are the same. Change one of those boxes yourself and it reads Custom
+  New checks: tests/backups.mjs and tests/pacing.mjs. The backup check caught a real bug before it
+  shipped: when there was no room, it cleared every older backup to make space and then lost them
+  anyway when the copy still did not fit. It now frees only the one it is replacing.

+
+----MOD 4.3:
+[2026/09/27]
+Documentation, not gameplay: the notes and READMEs brought up to date.
+  MOD-NOTES.md opens with an overview of the mod as it stands at 4.3, and gains the missing
+  chapters for 4.0 to 4.3. Older passages that later versions changed now carry a dated note
+  beside them rather than being rewritten, since the reasoning is the point of that file
+  Two corrections along the way: the notes gave the top realm as "Ascendant" (it is Tribulation
+  Transcendence), and the README listed seven realms, Mortal through Ascendant (there are ten above
+  Mortal). Both READMEs now describe 43 sections, 31 test scripts and 3,720 matchups.

+  Repository: the checkout's remotes were renamed — origin is the fork, upstream is the author's
+  game — so the author's-changelog check is now against upstream/main.

+modHelp() lists every console command — fifty-four, grouped by purpose. It listed six, from the first
+  version, and said skill exp resets on reload, which stopped being true when the settings box
+  began persisting it. A check now fails if a new command is left out of it
+  Housekeeping: the code comments in sections 38 and 39, and two lines of the 4.0 entry below,
+  quoted Wuxiaworld's glossary at length. They paraphrase and cite it now. CLAUDE.md brought up to
+  date as well: the remotes, five actions, the dead wrappers now removed, and current counts.

+
+[2026/09/26]
+Your realm is on the main screen, and it is the breakthrough button.
+  The rank line now reads "rank: 135T · Core Formation 5/9". At a bottleneck it says "½ step to
+  Nascent Soul >>" and clicking it breaks through, with the same checks, odds and tribulation as ever.
+  Its tooltip shows your root, your insight, whether you hold the pill, your odds, and whether
+  you are sheltered in seclusion
+  The player panel is a fixed-height box and already full, so this shares the rank line rather
+  than adding one. Plain text only: a ▲ and a ⚠ each made the panel taller, measured.

+A What next page in the wiki, right after Start here. It reads your save and says what is ready
+  now, what you are working on, and what opens the rest: story cap, cultivation, locked actions,
+  the dojo, the late areas, the rank ladder, crafting, convergence, and odd jobs
+Rarity reads as a word, not only a colour. The skill tooltip names it. The skill panel sorts by rarity
+  and can show only one rarity and above
+The Damp cellar is finally somewhere. It is the author's area, but it had no spawn weights (nothing could
+  appear), no way in, and an exit into two screens that were never written. It is Notice #1 on the
+  Message Board now, once the dojo has taught you to fight. The first clear pays
+Four unfinished titles finished: Second-Rate and First-Rate Shopper at 5,000 and 10,000 purchases (the
+  author's own numbers, commented out in his code), and Peasant and Merchant at 10 and 100 gold
+  The fifth, named "null", is left alone: nothing says what it was for.
+Fixed: the skill list rebuilt itself every second, forever, once it had grown with the panel open: every
+  row, every second, until you closed it. It redraws once per change now, and notices a skill leaving
+Rank duels have more headroom (1.25 to 1.4). The top challengers hit about 12% softer
+Every enemy has been checked against a fully merged character too, 3,720 fights in all, and all are winnable
+  Housekeeping: three unused copies of the stat-refresh code removed. Tutorial fight 1 was on the list
+  as a fight you could lose; that is only true of the original's numbers, and here it was already
+  winnable (8 swings to win, 23 to lose), so nothing changed. New check: tests/polish.mjs.

+
+----MOD 4.2:
+[2026/09/26]
+Measured against the original game, and brought back in line with it.
+  A new check (tests/vanilla.mjs) loads the game twice, once with this mod blocked, which is
+  the author's game exactly, and runs the same measurements on both.

+  Skills cost exactly what the original charges, for the first ten levels. The mod's old curve
+  was 12x faster than the author's at level 5 and 364x faster at level 10, which is exactly the
+  range he designed: all 22 of his perk ladders end between level 1 and 15. From level 10 it
+  rises about 3% a level, at a rate worked out so level 110 is exactly as far away as it was
+  Why not match him to 15, his last perk? His level 15 alone costs 823,099 xp, and ninety-five
+  more levels even at that flat price is two and a half times the whole budget.

+  Skill exp is 1x and enemy coin drops are off, the original's values. Both were raised by this
+  mod (2x, 15%). The coin drop is the author's own mechanism, switched on only by the Coin Ring
+  and the Ring of Greed; at 15% under them those two rings were worthless. Both settings
+  boxes still work, and anything you set there yourself still wins
+  Fixed: the six elemental masteries were stuck on the original's curve. The mod installed its
+  curve before they existed, so level 20 cost 7,474,050 xp instead of 375 and their perks to
+  110 could not be reached
+  Fixed: about one spawn in twenty came out much weaker than intended. Enemy strength is set by
+  sampling 32 of your swings, and when none of them crit the crits were not averaged badly, they
+  were left out entirely, and they are 30% of your damage. Those spawns were ~20% short. Now
+  98% of spawns land within 4% of their intended fight length
+  The same bug in the balance tests is also why they sometimes failed on a rank duel and
+  passed on a re-run: 0.15% of the time they read a fight 40% longer than it was.

+  Not matched, on purpose: fight length. At equal progression the original's early fights take
+  one or two swings and almost never hurt you, because you out-level its fixed creatures. This
+  mod holds every fight near 9 swings and 22 to die. The test prints that gap
+
+Big numbers are readable: Short (4.56M), Scientific (1.23e7) or Myriads (5.6亿).
+  A Number format row in settings. Stats, health, energy, exp, damage, Power rank and money all use it
+  Up to 9,999 every format prints exactly what the original prints. That threshold is the author's.
+  His damage log checks for numbers over 9,999 and calls his own compactor, then throws the result
+  away, so damage was never compacted. It is now
+  Myriads are his notation too: his wallet already counts in 10^8 coins (㋧) over groups of
+  10^4, which is 亿 and 万. "As the original" leaves every number exactly as he draws it.

+
+----MOD 4.1:
+[2026/09/26]
+Skill rarity, and convergence — fold a whole section into one skill.
+  Every skill has a rarity now, ten of them from Novice to Transcendent, and the
+  skill list paints each row in its colour
+  It is the rank of the level you have taken the skill to — the same thresholds and
+  the same ten colours titles use. Derived, not assigned: a hand-picked rarity per skill
+  would be a hundred numbers to keep in step with a ladder that moves, and would say
+  nothing about your character.

+  Jade Slips (玉简) fold a skill into its section. Fold every skill in a section and it
+  is one line on your sheet instead of up to twenty-five — Warfare, Constitution,
+  Pathfinding, Livelihood, Workmanship, Fortitude, Attunement, Provision, Maintenance,
+  Fellowship
+  The Slip Archive is on the dojo lobby, beside Level Advancement. It sells the slips;
+  the price rises with every one you have spent, so converging all hundred is a project
+  Folding costs you nothing but the slip. A folded skill keeps its level, keeps earning
+  exp, keeps every perk and keeps applying every buff it ever applied. It is hidden, not
+  removed, which is also why it can be unpicked at any time
+  Hiding rather than removing is the whole design. Shortening the skill list breaks three
+  things at once: the game's panel only redraws when the list GROWS, so removed rows would
+  stay on screen forever; the game pushes a skill back onto the list on its next level-up,
+  banner and all, so a fold would undo itself; and the list is saved by id. The mod's
+  buffs were always applied from the skills themselves, never from the list — so "all
+  of the buffs" is literally true rather than re-implemented. Measured: every stat after
+  folding all hundred is exactly its unfolded value times the convergence bonus.

+  A converged section is worth x1.03 on every stat, compounding — x1.34 with all ten.
+  A partly folded one pays a share in proportion, so it rewards you as you go
+  A Skill handbook in the wiki, beside the skills page: the rarity ladder, which skills
+  fold into which, where you stand, and what the slips cost
+  New: modConverge(), modRarity(), modFold(section), modUnfold(section).
+  tests/convergence.mjs, 43 checks. tests/perkcoverage.mjs no longer grades the ten
+  converged skills — they are never trained, so a perk ladder on them could never be
+  climbed, the same reason Renown was already exempt.

+
+----MOD 4.0:
+[2026/09/25]
+The cultivator's road — the realm ladder finally has a road between its rungs.
+  Sourced, not invented. Wuxiaworld's "General Glossary of Terms in Wuxia, Xianxia &
+  Xuanhuan Novels" is the genre's own reference work and it names every piece below.
+  Its entry on bottlenecks was the design brief: breaking one can take new insight, a
+  medicinal pill, or harsher training.
+  The mod shipped with one of those three. All three are here now.

+  Spiritual Root (灵根) — rolled once, when the dojo finishes teaching you to
+  fight. Five grades by purity, commonest worst. Never a dead end: the worst costs 15%
+  cultivation speed, not access, and a Root Cleansing Pill moves you up a grade
+  Nine layers to every realm (层) — derived from where Qi Circulation sits between
+  this realm's threshold and the next, so breaking through lands you at the first layer
+  of the new one on its own. You are now "Core Formation, 5th layer (middle stage)", or
+  at the Peak of it, or a half step to Nascent Soul — which is the genre's own name
+  for the state this mod has been calling a bottleneck
+  Insight (参悟) — the half of the price that cannot be bought, from the three
+  sources the glossary names and no fourth of our own: meditating, a kill made under
+  20% health, and the first time you set foot in an area. Spent whether the attempt
+  works or not, because what you spend is understanding
+  Closed Door Training (闭关) — a fifth action, and the only one that costs you
+  something: it holds the action timer, and it refuses to start unless you are actually
+  at a wall. Fifteen minutes in it is worth +30% on the attempt
+  And it shelters you. The glossary gives seclusion two purposes, breaking a
+  bottleneck and not being disturbed at the crucial moment and suffering a backlash
+  — so a breakthrough attempted while you are still sitting in it cannot
+  deviate, only fail. Sit and be safe, or push it in the open and risk it.

+  Qi Deviation (走火入魔) — failing in the open now leaves your cultivation base
+  unstable for half an in-game day: stats at x0.55, body at x0.70, no second attempt
+  until it passes. Clears on its own or at once with a Qi Settling Pill. Never kills
+  Heavenly Tribulation (天劫) — realm 10 was called Tribulation Transcendence and
+  there was no tribulation. From realm 8 up, a successful breakthrough is answered by
+  lightning: 3, 5 then 7 bolts costing 55/70/85% of your max HP. A gate on walking in
+  healthy, anchored to the player like every other number here. Fall and the realm
+  does not open
+  Impurities (杂质) — something black and foul-smelling leaves you on the way up
+  New: modRoad() and modRoots() in the console; two pills at the Herbalist; the wiki's
+  Cultivation page carries all of it, generated from the live tables as ever.

+
+Fixed: a realm's body multiplier did nothing at all.
+  Found while building the above. Section 28 raised max HP and max energy by ASSIGNING
+  you.hpmax and you.satmax — the exact mistake v3.9 fixed for hpTrack, still sitting
+  in the realm code. stat_r recomputes BOTH from their inputs, so both were undone on
+  the next call. Measured on a realm-10 character: hpmax 332 after allbuff and 39 after
+  one stat_r; satmax 1700, then 200. The x8.5 the top realm advertised lasted one call.

+  Ten realms of health and energy you were never given, you now have. Both go
+  through hpm and satm, which are inputs to stat_r and therefore survive
+  Two contributors wanted the same two fields, and two delta-trackers on one field
+  cannot work — each recovers its base by dividing out its own factor and finds the
+  other's sitting in it. So nothing writes hpm/satm any more: contributors publish a
+  factor to MOD_BODY and one idempotent applier writes the product, one delta flag per
+  axis. Verified: survives stat_r, zero drift over 200 calls, Mortal untouched.

+
+----MOD 3.9:
+[2026/09/23]
+Fixed: you never actually had the max HP the game was giving you.
+  The mod raises max HP with your offence (hpTrack). It did that by ASSIGNING you.hpmax —
+  and stat_r recomputes hpmax from (hp_r + hpa) * hpm * hpe on the very next call, which
+  the tick, combat, and allbuff itself all make constantly. Measured on a cap-60
+  character: one stat_r() took max HP from 1,492,947 to 72,377. Sampled every 137ms
+  across six seconds of play, the low number appeared in all 43 samples.

+  Enemy damage was sized against the number you never held. MOD_scaleEnemy reads
+  you.hpmax in the instant after allbuff, while the assignment still stands, so every
+  fight was built for a character with ~18x the health you actually had. Harmless early,
+  worst at the cap
+  It writes the multiplier now, not the total — hpm is an input to stat_r, so it
+  survives. Max HP holds through 200 consecutive stat_r calls
+  hpm is SAVED, so the mod's factor is tracked in global.flags and divided back out
+  rather than multiplied in again — the same pairing the coin-drop rate uses, and the
+  same trap you.res once fell into. Verified across six save/load cycles and 20,000
+  allbuff calls: zero drift.

+  Your fights are now as deadly as they were always meant to be, which is to say
+  roughly eighteen times less so than they have been
+Two corrections found by the tests, not by reading:
+  the recovered base needed flooring at 1 — anything resetting hpm without clearing the
+  flag recovered 1/18.86 instead, and allareas immediately reported the player at 12 max
+  HP with six rank duels flipped to kill > die; and Math.ceil rather than Math.round, to
+  agree with stat_r to the unit
+Also: tests/run.sh now serves with -c-1. Without it http-server sends max-age=3600 and
+  a browser can be handed a mod.js from before the edit being tested — which showed up
+  once during this work as an allareas failure that would not reproduce standalone.
+  A flaky safety net is worse than a failing one.
+Tests. tests/targets.mjs gains 5 checks — max HP survives one stat_r and two hundred,
+  the factor lives in global.flags rather than being written as a total, hpm never drops
+  below the game's own value whatever resets it, and 5,000 allbuff calls do not compound
+  it. Verified they fail against the old behaviour
+  All 2,790 allareas matchups still pass — they were measuring the right number all
+  along. It was play that had the wrong one.

+
+----MOD 3.8:
+[2026/09/23]
+Tooling, not gameplay: the balance numbers now come with error bars.
+  Every balance figure in the suite is a sample mean of something heavy-tailed — crit rate
+  reaches 33% and a crit is ~8x a normal swing. Until now the scripts reported point
+  estimates: fightsmoke medians nine duels, allareas prints one number per matchup, and
+  nothing said how much of a gap between measurement and target was real.

+  tests/lib/stats.mjs — the first shared code in tests/. Confidence intervals on
+  Student's t (z at n=9 is 15% too narrow, and fightsmoke fights nine times), plus a
+  practical-equivalence check that fails two ways — real drift, or an interval too wide
+  to conclude anything — and says which
+  fightsmoke now prints a CI beside the median. Immediately useful: The Long Vigil's
+  median of 2 swings carries an interval of [0.46, 4.87], ±83%. That number was being
+  read as a measurement and it is noise. Flagged in place rather than failed
+New: tests/targets.mjs. Every other balance script checks a RELATION — kill < die,
+  whiff 0. None checked whether the model hits its own dials, which a single matchup
+  cannot tell you. 45 spawns an area, compared against the target THAT SPAWN was solved
+  for, with an interval. Catches the model drifting without breaking the ordering
+  Verified it fails: a 15% drift injected into _modDmg is caught at 0.869 of target.
+  Result: the model is on its dials — every area tracks its per-spawn kill and die
+  targets to within 1%, and kill < die holds on 45/45 spawns in all four areas sampled
+Four corrections along the way, all to the new test, none to the mod:
+  comparing against the flat dial instead of the per-spawn target (a basement rat is
+  legitimately a five-swing kill); omitting the hit rate, which pinned every die ratio at
+  exactly 0.45 — MOD_ENEMY.hit, the tell; a plain sample mean instead of the crit
+  stratification CLAUDE.md already demands; and testing significance rather than
+  equivalence, which at ±0.1% precision fails on a 1% quantisation bias that means
+  nothing in play
+Not gameplay. No change to mod.js, index.html, or any save.
+
+----MOD 3.7:
+[2026/09/16]
+Fixed: every item the mod added was labelled "Book".
+  An item's id is two schemes at once. One decides which list it belongs to; the OTHER
+  decides its class, read straight off the number with no field for it — under 3000 Food,
+  3000-4999 Medicine/Tool, 5000-8999 Material/Misc, 9000+ Book. stype is not a proxy:
+  the author's stype 4 spans Food, Medicine and Book.

+  Everything the mod added sat at 9100+ to stay clear of the author's maxima, so
+  26 of its 32 items read as Books, each with a "Read: Never" line under it — ore,
+  hides, essences, tonics, and all fourteen pills. Only the six skillbook manuals,
+  which really are books, were right
+  Tonics are 4201-4204, breakthrough pills 4210-4219, spirit pills 4220-4223, crafting
+  materials 5200-5211. The manuals stay where they were
+  MOD_ITEM_IDS now holds the ranges with the class table written beside them, so the
+  next addition picks from it instead of from "whatever is free above his highest"
+Your items survive the move. The inventory is saved BY id and restored by matching it,
+  so an entry whose id no longer exists is dropped without a word — and a Tribulation
+  Pill is seven million coin. The save is read BEFORE the game loads it, the moved ids
+  are noted, and those stacks are given back afterwards under their new ids
+  The save itself is never rewritten. Anything unexpected in it and the migration does
+  nothing at all — a migration that fails is better than a load that fails.

+Tests. tests/ids.mjs gains 7 checks: the class of every added item, taken from the mod's
+  own tables rather than from the ids; that nothing which is not a book gets a "Read:"
+  footer; and a round trip through a save deliberately rewritten back to 3.6's ids, which
+  must come back with the same amounts and must not double-grant on the next save.
+  Verified they fail before the fix
+  modItems() prints every added item with the class its id gives it
+
+----MOD 3.6:
+[2026/09/16]
+Fixed: two skills were both called "Foraging".
+  The base game has skl.hvt, "Foraging". MOD 1.x added skl.frg and called it the same
+  thing — two identical rows in the skill panel, and TEN identically named titles,
+  because a title's name is derived from its skill's. Nothing errored.

+  The mod's is Wildcraft now. Its key and id are unchanged, so nobody loses a level —
+  names are not saved, ids are
+  Swept the rest: all 140 things the mod names are unique, including across the whole
+  inventory bag (item / wpn / eqp / sld / acc share one list)
+  The author's own repeats are reported and left alone: "Chashu Ramen" twice, "Bandage"
+  on both an item and an armour, two "Blue Slime", two "Nameless", nine "Training Grounds".

+Fixed: the hunter's quest had become impossible in summer.
+  Yamato wants ten Raw Meat HELD AT ONCE, and raw meat rots on a daily timer that runs
+  twice as fast in summer. A perishable stock converges instead of accumulating, so there
+  is a ceiling and the quest needs that ceiling to be above ten.

+  The mod never touched the drop table — it changed how long a rabbit takes to kill.
+  At the point the quest is offered a Wild Rabbit now has 3,574 HP against the base
+  game's 93, so one that died in a single hit takes ten. Ten times less meat an hour
+  against an unchanged rot clock
+  Measured at the old 6%: steady state 15.6 in a normal season, 51 in winter, and
+  4.3 in summer — against the ten required
+  Raw meat now drops at 18%, solved backwards from the requirement rather than picked:
+  a steady state of 25 in the worst season and the first hunting area. Applied to every
+  creature carrying it, found by scanning the drop tables rather than by naming the
+  rabbit and the wolf. Worst case is now 26 against 10
+  Not an income: a kill in the first hunting area yields 0.28 coin of meat.
+  modMeat() prints the table per area and season
+Tests. tests/names.mjs — 12 checks. Verified both halves fail without their fix: the
+  name half reports the skill clash and all five title collisions, and the meat half
+  reports a summer steady state of 4 against 10 at the old drop rate
+
+----MOD 3.5:
+[2026/09/15]
+Crafting goes to five stars.
+  It stopped at two. Measured across all 62 recipes, by what they produce AND by whether
+  any giveRcp call can actually teach them: 35/43 reachable at 1★, 7/17 at 2★, 0 of 1 at
+  3★, 1 of 1 at 4★, and nothing whatsoever at 5★. The only 3-star recipe in the game is
+  Trinity, and nothing can teach it; the one 4-star result is a Clover Pin you get for
+  holding seven clovers.

+  Four new rungs, 2★ to 5★, each a full set — weapon, body armour, shield,
+  accessory and tonic. Twenty craftable things
+  Twelve materials along three lines (ore, weave, essence), one set per rung
+  Four resource nodes — the Stone Hollow, the Iron Seam, Ashfall Quarry and the
+  Deep Vein — behind one door on the Village Center, opening one at a time with the
+  story. They set no tier flag: a gathering spot that hands out a level cap on arrival
+  is the catacombs bug over again
+  A Gather action that turns up at the first node you walk into. It trains Mining,
+  Geology and Foraging — three skills the base game defines and then barely uses —
+  and the first thing out of a seam teaches that rung's five blueprints
+  Each rung eats the rung below it, so it is a ladder and not four shopping lists.
+  The 4★ blade lands beside the game's own best (Fate Cutters, str 108) and the 5★ one
+  goes past it, since that tier has nothing else in it
+Found: 19 of the 62 base-game recipes cannot be learned at all. They are defined,
+  priced and complete, and no giveRcp anywhere hands them over — trr, lnch1/2/3, orgs,
+  ffsh1/2, fnori, cbun1, sshl, hpck, steak, cnmnb, brth, eggsp, crmchd, msoop, jln4.
+  Listed in the wiki rather than wired up; which were gated on purpose is not our call
+Economy. The first pass came out backwards — a 5★ sword priced at 1,161 while each of
+  the eight ingots it eats priced at 13,000, so selling the ore paid ninety times better
+  than using it. The value model is re-indexed after the new recipes exist, and the
+  materials are anchored at 6/18/54/162 instead of being priced on an equipment scale.
+  Gathering now pays about 6 coin a second sold, against roughly 1 from endgame combat
+Wiki. A new Crafting page, after Items: what can be made at each rarity, which
+  base-game recipes are unreachable, every rung with its costs, and the gather rates
+Tests. tests/crafting.mjs — 27 checks, including the two traps this had to avoid:
+  every id must sit in the block load() restores its namespace from, and no rarity may
+  pass 6 or equip() runs off the end of global.text.wecs the moment you wear it
+
+----MOD 3.4:
+[2026/09/15]
+The title picker groups the titles it used to leave in a heap.
+  595 titles, 512 grouped by skill — and 83 in a flat list at the bottom, which is the
+  wall of rows the grouping existed to remove, just smaller. Reading them, almost none
+  were really one-offs.

+  +19 folded into the skill they belong to. The flagship titles (Executioner,
+  Deathless, Sage...) are paired with their skill in the mod's own table, and the
+  author's numbered tiers are keyed <skill><n> — tghs1/2/3 under Toughness, srd3/srd4
+  under Swordsmanship, dth4 under Death, rtr1 under Retreating
+  Most of those numbered tiers are granted by NOTHING — giveTitle never mentions them
+  anywhere in the game. Unfinished content, like the Damp cellar. They group correctly
+  whether or not they are ever earned.

+  +40 are ladders of their own and now collapse like a skill does: the ten cultivation
+  realms, five for creatures killed, four for items gathered, four for titles collected,
+  four for punch power, three each for time indoors / shields / jobs, and more
+  Each ladder says what it is on the row — "Nameless (4)" could be punch power or
+  titles collected, so the label sits beside it rather than only in the tooltip
+  595 titles held now renders as 126 rows, and the flat tail is down from 83 to 23 —
+  Nobody, Initiate, Thrasher, Wolf Slayer, Safehouse and friends, which really are
+  one-off story titles with nothing to group under
+  Membership is derived from the key, not typed out: only a LABEL per ladder is written
+  down, eleven of them, and a ladder with no label falls back to its lowest title's name.

+The wiki's title page uses the same grouping, so the two cannot drift apart. It had the
+  same problem solved worse — everything skill-less under "From the story", split into
+  rank buckets. The ladders nest under "Ladders of their own"
+Found: five titles the author never finished. ttl.ddcd has the literal string "null"
+  for its name AND its description; shpt2, shpt3 and mone3 are blank. Nothing grants any
+  of them. The picker skips them rather than drawing a blank row; the wiki lists them and
+  tags them "unfinished", the same call the Damp cellar got
+Tests. tests/titlepicker.mjs gains 8 checks — the ladders group, every one has a
+  readable label, no label names a ladder that does not exist, and "Thrasher" is NOT
+  filed under Throwing on a bare stem match (which is what the trailing-digit rule is
+  there to prevent). Its group counts are derived now instead of hardcoded
+
+----MOD 3.3:
+[2026/09/15]
+Fixed: every title was listed twice after loading a save.
+  Found by auditing, not reported. giveTitle pushes each earned title onto TWO arrays —
+  global.titles and global.titlese — and load() rebuilds global.titles from the save
+  BY INDEX and then appends the whole of titlese on top. So everything earned that
+  session ends up in the array twice. Measured on a character holding 241 titles: 241
+  before a load, 481 after, 241 distinct.

+  It does not compound — the next load finds titlese empty — but one load is enough,
+  and save() writes the inflated array straight back out
+  Both title screens are affected: the game's own, and the mod's grouped picker. Both
+  iterate global.titles, so each title got two rows
+  A base-game bug, but the mod is what makes it loud: vanilla hands out around a hundred
+  titles and section 24 grants five per skill, so the doubling is hundreds of rows.

+  Existing saves are repaired on load, by identity, the same way the skill sheet is —
+  global.titles holds references to the objects in ttl, so the same object twice is
+  always wrong whatever put it there. Not fixed at the source: titlese is the author's
+  mechanism and the mod has no business changing what it means
+Hardened: the rank-duel scaler now refuses the player and creature.default explicitly,
+  matching MOD_scaleEnemy, instead of relying on their not carrying a spawn target
+Tests. tests/ids.mjs gains 8 checks for the title list: distinct before a load, distinct
+  after one, distinct after two, no holes, the worn title still present, and an
+  already-doubled list repaired. Verified they fail without the fix (85 entries for 43
+  distinct) and pass with it
+Audit, no change needed. All 75 locations draw without throwing, no choice row wraps its
+  22px line, all 12 wiki pages build, no id clashes in any namespace, the wiki has no
+  horizontal overflow at 375/768/1200px, no interval leaks over 15s at 20x speed, and
+  acts/you.skls/furn/qsts all stay distinct across repeated save/load cycles
+
+----MOD 3.2:
+[2026/09/11]
+Rank 1 is reachable. It just never felt like it.
+  The number under your portrait is the base game's Power rank, and it is DERIVED from
+  your stats every time it is drawn — there is no stored field. Measured on a character
+  built to each rung of the ladder: rank 21,617,349,975 at tier 0, 65,544 at tier 5, 258 at
+  tier 7, 20 at tier 8, and 1 at the very top. Ranks 1–10 all live inside the last fifteen
+  skill levels — at 108 across the board you are still rank 3 — after a whole game in
+  which the number is an unreadable ten-digit blob.

+So there is a second way to hold a rank: take it off whoever has it.
+  The Hall of the First Gate, off the Old Path trailhead past the Western Woods gate.
+  Ten named challengers, rank 10 down to rank 1. Beating one takes that rank
+  Strictly in order. Rank N opens only when rank N+1 has been taken, and each rung also
+  wants a character level (60, 65, 70 ... 100, 110), so the ten fights span the endgame
+  instead of being one evening
+  The rank you hold floors the rank you are shown, and never caps it — hold rank 6 with
+  rank-900 stats and it shows 6; pass it on your own and it shows the better number. A held
+  rank is painted gold
+  Implemented by wrapping you.rank and keeping the earned rank in global.flags, like
+  mod_realm. Nothing is written into a stat, so there is no delta to track and nothing
+  compounds on load — the trap you.res and skl.p both fell into.

+  Losing costs nothing but the walk back. A rank you have not taken cannot be taken off
+  you, and a rank you have taken is never lost
+The duels get longer, not deadlier. Each is a protected one-creature area, so the enemy
+  scaling already treats it as an arena boss and anchors it to your power as you walk in.
+  Each rung then steps up ~7% into FIGHT LENGTH — the rank 1 duel runs 1.77x the rank 10
+  one — and the kill<die guarantee is re-derived from the targets afterwards rather than
+  hoped for
+  Scaling both targets in opposite directions does not survive ten rungs: margin 1.5 over
+  step^1.5 is already under 1.2 by a step of 1.16.

+Wiki. A new "The rank ladder" page, after Progression: the formula, what a maxed
+  character actually measures at, and the ten rungs with their gates and payouts
+Found, not fixed: the player's real max HP is not the one the balance model uses.
+  Section 9's hpTrack assigns you.hpmax at the end of the allbuff wrapper, but allbuff
+  opens with stat_r(), which recomputes hpmax from the base — so the raised value only
+  survives until the next stat_r, and the tick calls that constantly. Sampled 43 times over
+  six seconds of real play on a cap-60 character, you.hpmax was 10,050 every time; the
+  hpTrack value of 184,754 never appeared. Every balance script reads it in the one instant
+  it is standing, so enemy damage is sized for ~18x the health you really have. Harmless
+  early, worst in the endgame. Written up in MOD-NOTES and CLAUDE.md; the fix is its own
+  job, because it moves a number all 2,790 matchups are measured against.

+Tests. tests/ranks.mjs — 31 checks. The wrapper must floor and never cap; the held rank
+  must survive a save/load; the hall must offer exactly one rung at a time and every line
+  must fit a 22px row; and all 100 matchups (10 rungs x 10 story tiers) must keep kill<die
+  with zero whiffs, measured through the game's own dmg_calc. allareas is now 2,790
+  matchups across 31 areas
+
+----MOD 3.1:
+[2026/09/09]
+Fixed: a hundred Fire Masteries and a hundred Companions Disciplines.
+  Two skills shared id 2010 — the Companions parent (2000 + section 10,
+  section 11) and Fire Mastery (2010 + 0, section 29). Ids are the save's key for a
+  skill and the loader matches them in a loop with no break, so one shared id pushed
+  BOTH skills onto the sheet for BOTH saved entries. The next save wrote all of them.
+  It doubled every save/load: 1, 2, 4, 8, 16, 32... A week of play is a hundred of each.
+  Only Fire Mastery moved (2010 → 2016), so water through dark keep the ids they were
+  saved under and nobody loses those levels
+  The Companions parent gets id 2010 back, and with it the level cap that the clash had
+  been handing to Fire Mastery instead
+Existing saves are repaired. The duplicates are IN the save, so fixing the id only stopped
+  it getting worse. The skill list is now de-duplicated on load and the panel redrawn —
+  a 138-row sheet comes back as 12, with every level intact
+Also fixed: The Sunken Hollow and the Pill Tower both had location id 976 (the mod's own,
+  from MOD 2.9). global.lst_loc is matched the same way, so both would have drawn.
+  The tower is 980 now
+Tests. tests/ids.mjs — 32 checks. Every namespace (skl, ttl, act, abl, item, wpn, eqp,
+  sld, acc, chss, area, creature, quest, rcp) must have distinct ids; five save/load cycles
+  must not grow the sheet; and a deliberately damaged sheet must come back clean.
+  Nothing checked this before, which is why it shipped.
+
+You can get into the Marketplace again.
+  The base game's chain: finish at the dojo, then a Paper Boy turns up at the village
+  center at 40% a visit with a "Pamphlet", then you read it for three hours. Exactly one
+  item in the game sets the flag, and the Paper Boy is retired the moment the Pamphlet is
+  GIVEN rather than read. MOD 1.x added selling and never marked it a key item — so you
+  could sell it at the food stand, before ever reading it, and lose the marketplace, its
+  three shops, the guard-duty quest and the realm 2-5 breakthrough pills permanently.

+  Key items can no longer be sold. Derived, not listed: the game's own source is scanned
+  for flags it ever sets back to false, and an item that sets a flag nothing clears is
+  the only copy of something. Six items protected. A Smoke Bomb sets a flag the game does
+  clear, so it is state rather than a key, and stays sellable
+  The Paper Boy comes back if you no longer have a Pamphlet — which is what gets a save
+  that is already stuck out of the hole, without a console. A fresh character still meets
+  him exactly once, through the author's own code
+  And the village center says WHY the door is not there instead of silently omitting the
+  line: "finish at the dojo first", "read your Pamphlet", or "you do not know the way",
+  with the full explanation in the tooltip. The Herbalist inside gets the same treatment
+  Added lines sit above the location's "<= Return" and are kept under 50 characters:
+  .chs is height:22px with no overflow rule, so a choice that wraps spills into the next row.

+Tests. tests/marketplace.mjs — 30 checks, including that every added line fits on one row.
+
+----MOD 3.0:
+[2026/09/09]
+A wiki. A wiki button on the bottom bar and a "Game wiki" row in settings, both
+  opening a reference in a tab of its own. Eleven pages: Start here, Progression, Skills,
+  Cultivation, Elemental mastery, Titles, Areas & enemies, Items, Actions, The dojo, Mechanics
+  It is GENERATED from the live game data every time it is opened, not written out.
+  A hand-written wiki for 100 skills, 595 titles, 371 items and 19 areas would be wrong
+  within a week, and a reference nobody can trust is worse than none. Reading skl, item,
+  area, ttl, act and the mod's own tables means it cannot disagree with the game —
+  including changes made from the console mid-session.

+  It reads the save too, so it doubles as a progress sheet: your level in every skill,
+  which perks you have taken, which titles you hold, your realm and what the next one costs
+  Everything you have not reached is still listed — it is a wiki, not a fog-of-war map
+  Groups collapse. With everything listed, three pages ran past 40,000 pixels open;
+  collapsed, the tallest is 5,800 and Skills opens as ten lines you can take in at once
+  Search opens the groups that still have matches, since a match hidden inside a
+  collapsed group reads as no match at all
+  No fetches and no dependencies — it works from file:// as readily as from a server
+Two things found by generating it rather than writing it:
+  Spawn chances were not what pop[i].c says. z_bake normalises those weights into
+  area.popc, the [lo,hi] bands mon_gen actually rolls against, and the weights need not
+  sum to 1. The Southern forest's .35/.45/.25 are really 33/43/24%. The wiki reads the bands
+  Nothing can ever spawn in the Damp cellar (base game). Its pop entries carry no
+  weight at all, so z_bake produces NaN bands and no comparison in the spawn loop can be
+  true. Nothing in the game travels there either. Left as it is and reported in the wiki
+  rather than quietly fixed — it is the author's unfinished content, like chss.pltwr1 was
+Not listed as places: "Somewhere" (where current_z parks whenever you are not fighting)
+and "Test" (reached only from chss.tst, which has id -1 and is in no sector).

+Tests. tests/wiki.mjs — 57 checks. Coverage is counted from the game rather than
+  listed, so adding a skill without adding it to the wiki fails the suite automatically
+
+----MOD 2.9:
+[2026/09/08]
+The Catacombs are reachable. 26 finished locations — The Mourning Hall, Eleven Wisemen,
+  Thawing Candles, Tunnel of the Dead — written by the game's author and linked to by nothing
+  An entrance on the Village Center, which is where their own exit already led
+  Not a word of it is the mod's; the entrance is the whole change
+  Opens once you have been to the deep forest, so the level cap ladder keeps its order:
+  forest 20 → deep forest 30 → catacombs 40 → golem arena 50
+  This also makes the cap-40 rung live for the first time — nothing could reach it before
+The Pill Tower. The author wrote this choice on the Village Center and commented it out;
+  the location behind it was never built. Now it is
+  An alchemists' tower that sells the pill grades a village herbalist cannot source,
+  realms 6–10 and the Sublime and Transcendent Spirit Pills
+  A spirit vein you can sit in once a day for Qi Circulation exp, scaling with your realm
+  They do not let mortals past the door — you need a realm to get in
+
+ +----MOD 2.8:
+[2026/09/08]
+FIXED: the breakthrough pills had no source. All ten existed as items and nothing gave,
+  dropped or sold them, so the entire realm ladder was unreachable in normal play
+Three sources now, matching how far along each realm is:
+  Realm 1 — the instructor hands it over when the dojo finishes teaching you
+  Realms 2–5 — the Herbalist, who already sells spirit pills (900 to 38,000 copper)
+  Realms 6–10 — the dojo's Level Advancement rungs at 45, 60, 75, 90 and 105
+  The first one is deliberately NOT the Herbalist: the marketplace is gated behind the
+  Paper Boy, a 40% chance per visit, and realm 1 opens long before that
+The Herbalist also stocks the Superior and Refined Spirit Pills now
+
+ +----MOD 2.7:
+[2026/09/08]
+Circulate Qi is unlocked by the dojo now, not by a skill level
+  Clear the Easiest, Easy and Normal dummies and it is yours — it used to hang off
+  Temperance 5, a skill you train by throwing your possessions away, which is an obscure
+  gate for the thing the entire cultivation system is levelled through
+  Qi Circulation appears on the skill sheet at the same moment
+  Temperance keeps a perk at level 5 (INT +1, Energy Effectiveness +2%) so existing saves
+  do not have their milestone flags shift under them
+FIXED: the bottom bar was 8px taller than it should be, covering part of the panel above it
+  The mod's three buttons were display:inline-block where the game's own are plain inline;
+  only an inline-block forces the line box to grow. Bar is back to its original 24px
+
+ +----MOD 2.6:
+[2026/09/08]
+Cultivation realms. The game had Qi Circulation, spirit pills "made from condensed Ki",
+  meridians in its skill text — and no realm to advance through. Now there are seven:
+  Ten realms above Mortal, on exactly the levels that earn each title rank — so realm 3
+  is the Qi Circulation level that earns a rank 3 title, and its own title comes out rank 3
+  Qi Refining · Foundation Establishment · Core Formation · Nascent Soul · Spirit Severing ·
+  Soul Transformation · Void Refinement · Body Integration · Great Ascension · Tribulation Transcendence
+  Reaching the Qi Circulation level does NOT advance you — it puts you at a bottleneck
+  A breakthrough costs a pill, and can fail; training past the requirement improves the odds
+  Each realm multiplies every stat and widens what the body holds, up to x6 at the top
+  Ten breakthrough pills, one per realm, and a title for each realm reached
+Elemental mastery. The six Absorption skills were purely defensive — trained by being hit,
+  and no way to use an element at all. Six Mastery skills to match them:
+  Fire, Water, Air, Earth, Light and Dark, each with a technique that fires in combat
+  "Lantern Splitting Palm", "Still Water Draw", "Hollow Gale", "Settling Weight",
+  "Noon Without Shade", "Lamp Going Out"
+  They scale with INT and their element rather than with your weapon, and are capped at
+  45% of strikes so a weapon still matters
+  Nothing fires below Qi Refining — your channels are not open yet
+modRealm() and modMastery() print the ladders
+
+ +----MOD 2.5:
+[2026/09/08]
+The mod's changelog moved out of the game's own file and into this one
+  It had been prepended to changelog/changelog.html above the author's entries, which meant
+  the mod was editing a file that was not its own. That file is now untouched — the only
+  change the mod makes to anything of the author's is the single script tag in index.html
+The bottom-bar "changelog" button opens this file; the version number opens the game's, as it always did
+  (the version number's link was absolute, "/changelog/changelog.html", so it 404'd from a
+  file:// URL or any subdirectory — it is resolved relative to the page now)
+
+ +----MOD 2.4:
+[2026/09/08]
+The dojo's Level Advancement ladder now runs to character level 110
+  It stopped at 30, though the instructor promises a reward "after every 5 levels you reach"
+  Sixteen more rungs, 35 to 110 in fives, sequential like the original six
+Four new spirit pill grades, sized against what a level actually costs
+  Superior 100,000 · Refined 600,000 · Sublime 3,500,000 · Transcendent 20,000,000 EXP
+  (a level at 110 costs 5,336,100 — the game's best pill was worth 0.3% of one)
+A third grade of skillbook, +30% weapon mastery exp, offered as a CHOICE at the 50/75/100 rungs
+  Sword Saint, Nightblade, Headsman, Dragoon, Earthbreaker and Iron Body Manuals
+  the same shape as the choice the instructor gives you the first time you clear the dummies
+modDojo() prints the ladder and what is claimed
+Repository: a README added, describing the mod, how to disable it, and how to run the tests
+  (not a game change — logged here because every change gets an entry)
+
+ +----MOD 2.3:
+[2026/09/08]
+Title ranks now run 1–10 and are derived from the level that earns them, not assigned by feel
+  rank 1 at lv 1, rank 3 at 18, rank 5 at 45, rank 7 at 70, rank 9 at 90, rank 10 at 110
+  (the base game gave out eight rank-3 titles at skill level 8–15 — Runner at 10, Rookie at 15)
+Every skill now grants five titles, at levels 25 / 50 / 75 / 90 / 110 — ranks 3 / 5 / 7 / 9 / 10
+  465 new titles; the pool goes from 120 to 585, and all ten ranks are populated
+Titles do something now: each raises its own skill's exp rate, 5% at rank 3 up to 30% at rank 10
+  Only the title you are WEARING applies — which finally gives the selection screen a purpose
+  At renown level N, every title of rank N or lower applies permanently whether worn or not,
+  so renown 10 makes the whole collection passive
+Renown thresholds rescaled to the larger pool: level 10 now wants 550 titles rather than 100
+modTitles() prints held/total by rank and which ranks have gone passive; modTitles(n) lists one rank
+The title screen groups by skill — one row showing the best you hold, with a caret to open the rest
+  Base-game titles group with the generated ones, so Rookie and Fighter sit under Fighting too
+  Story titles have no skill and stay as plain rows below the groups
+Titles are coloured by rank, in the picker and in the tooltip, and the tooltip states the rank
+  grey 1, cyan 2, lime 3, yellow 4, orange 5, purple 6, pink 7, red 8, gold 9, white 10
+  FIXED: the game's own rank 7 tooltip threw an error (it set `this.dl`, which is undefined
+  in that branch). It had never run because the base game had no title above rank 5 —
+  there are now 93 at rank 7. Ranks 1, 8, 9 and 10 had no colour at all
+
+ +----MOD 2.2:
+[2026/09/08]
+Perks now run the whole ladder — every skill has milestones at 10/25/50/60/75/90/110
+  (447 added; 81 of 94 skills previously gave nothing at all between level 51 and 110)
+Perk rewards match the skill's type: resistances give resistance, absorptions give elemental defence,
+  combat gives STR and crit damage, mental gives INT and EXP, gathering gives energy
+Critical damage from combat perks kept deliberately small — it is shared by 13 skills and compounds,
+  and at full value a crit reached 23x a normal swing, which made combat a coin flip on crits
+The four added actions are earned now, not handed over on load
+  Toughness 4 → Endurance Drill, Harvesting 4 → Forage,
+  Temperance 5 → Circulate Qi, Literacy 8 → Practice Calligraphy
+New settings checkbox "Unrestricted actions": run several actions at once, and start them anywhere
+  (off by default — both are limits the base game means to impose)
+Three save slots, with a "saves" panel and a "new save" button in the bottom bar
+  Switching saves the slot you are leaving first; "delete the save" now deletes only the slot you are in
+Changelog reachable from a "changelog" button in the bottom bar, not just the version number
+
+ +----MOD 2.1:
+[2026/09/08]
+Enemy scaling rewritten — it had been scaling the wrong fields
+  An enemy's STR is subtracted from your damage, so scaling it armoured them instead of
+  lengthening the fight. The tutorial needed ~63,000 swings and killed you in one;
+  everything from the southern forest onward was kill-in-1 / die-in-1
+Difficulty is now solved per spawn against your actual power: ~8 swings to kill, ~20 to die
+Enemies in an area's higher level band are genuinely harder, and each creature keeps its own character
+Arena golems are proper boss fights rather than the softest thing in the game
+
+ +----MOD 2.0:
+[2026/09/07]
+The ~100 "discovered by playing" skills consolidated to 10, with the same total effect
+Skill EXP curve replaced so level 110 is reachable in about six months at 1x speed
+  (the base game's curve made level 109→110 alone cost 1.16e14 xp)
+The three added areas now stay hidden until golem arena IV is cleared, then open in order
+Saves from before this update still load; skills that were merged away simply do not return
+
+ + diff --git a/index.html b/index.html index 0756d2b..297747b 100644 --- a/index.html +++ b/index.html @@ -14764,5 +14764,8 @@ } + + + diff --git a/mod.js b/mod.js new file mode 100644 index 0000000..3e8b3af --- /dev/null +++ b/mod.js @@ -0,0 +1,14038 @@ +/* =========================================================================== + Proto23 — local mod + --------------------------------------------------------------------------- + Loaded as a plain inside content closing the block early, so that is the + one thing scrubbed. */ + safe: function (h) { + return String(h === undefined || h === null ? '' : h).replace(/<\/script/gi, '<\\/script'); + } +}; + +/* Descriptions carry real HTML (coloured spans, the game's separator div), so + they are passed through rather than escaped — but the separator becomes a + rule, since a wiki row is not a tooltip. */ +function MOD_wikiDesc(d) { + var s; + try { s = (typeof d === 'function') ? d() : d; } catch (e) { s = ''; } + if (!s) return ''; + s = String(s).split('
 
').join('
'); + return MOD_WIKI.safe(s); +} + +function MOD_wikiNum(n) { + if (!isFinite(n)) return '—'; + return Math.round(n).toLocaleString(); +} + +/* An effect line at a level the player has not reached yet. The effect + functions read the skill they are handed, so the level is borrowed and put + back; anything that throws simply gets no projection. */ +function MOD_wikiEffectAt(key, sk, lvl) { + var eff = MOD_EFFECTS[key]; + if (!eff) return ''; + var was = sk.lvl, out = ''; + try { sk.lvl = lvl; out = eff(sk) || ''; } + catch (e) { out = ''; } + finally { sk.lvl = was; } + return MOD_WIKI.safe(out); +} + +/* --- page builders -------------------------------------------------------- + Each returns the inner HTML of one page. `wk-e` marks a searchable entry and + `wk-g` a group that hides itself when all its entries are filtered out. + ------------------------------------------------------------------------- */ + +var MOD_WIKI_PAGES = []; + +function MOD_wikiPage(id, label, build) { + MOD_WIKI_PAGES.push({ id: id, label: label, build: build }); +} + +/* A collapsible group. + + Not decoration: with every skill, item and title listed, three of the eleven + pages ran past 40,000 pixels — a hundred screens of scrolling, which is the + same as not having the content at all. Collapsed, each of those pages opens + as an index you can read in one screen and expand where you want. + +
rather than a click handler, so it still works with JavaScript off, + the browser's own find can be made to reach inside it, and the summary is + keyboard-reachable for free. `level` is the heading level for the summary, + since a group is sometimes a section of a page and sometimes a subsection. */ +function MOD_wikiGroup(heading, count, inner, level) { + var hn = 'h' + (level || 2); + return '
<' + hn + '>' + heading + + (count !== null && count !== undefined + ? ' (' + count + ')' : '') + + '
' + inner + '
'; +} + +/* --- 1. orientation ------------------------------------------------------- */ +MOD_wikiPage('start', 'Start here', function () { + var cap = MOD_levelCap(); + var realm = MOD_realm(); + var h = '

proto23

' + + '

An idle RPG by truezhangwei, plus a mod. You train skills by ' + + 'doing things, the things you can do open up as the story does, and none of ' + + 'it is in a hurry.

' + + + '

Where you are right now

' + + '' + + '' + + '' + + '' + + '' + + '
Character level' + MOD_wikiNum(you.lvl) + '
Skill level cap' + cap + ' — ' + + MOD_WIKI.safe(MOD_CAP.name) + '
Cultivation realm' + MOD_WIKI.safe(realm.name) + + ' (' + realm.n + ' of ' + (MOD_REALMS.length - 1) + ')
Titles held' + MOD_titleCount() + ' — renown ' + + (skl.rnwn ? skl.rnwn.lvl : 0) + '
Coin' + MOD_wikiNum(you.wealth || 0) + '
' + + + '

The four things worth knowing early

' + + '
' + + '
Your skill levels are capped by the story, not by grinding.
' + + '
The cap starts at 10 and ends at 110, and it moves when you reach a new ' + + 'place — not when you earn enough experience. If a skill has stopped ' + + 'levelling, you are not doing it wrong; you need to go somewhere. See ' + + 'Progression.
' + + + '
Armour is subtraction, so levels matter more than they look.
' + + '
A hit does the attacker\'s STR minus the defender\'s STR. That means a ' + + 'small gap in strength is a large gap in damage, and a big enough one takes ' + + 'your damage to nothing at all rather than merely reducing it. See ' + + 'Mechanics.
' + + + '
Most skills have a parent that pulls them along.
' + + '
Every section has a parent skill that earns a share of everything its ' + + 'children do, and any child lagging behind the parent trains faster — up to ' + + 'twice as fast at ' + MOD_PARENT.maxGap + ' levels behind. A neglected skill is ' + + 'cheap to bring up later, which is the only reason having this many is ' + + 'workable.
' + + + '
Cultivation is a second ladder, and it is not automatic.
' + + '
Circulating qi levels a skill; the skill does not raise your realm. ' + + 'Reaching the level opens a bottleneck, and breaking through it costs a pill ' + + 'and can fail. See Cultivation.
' + + '
' + + + '

What is the mod and what is the game

' + + '

The game is index.html and it is entirely truezhangwei\'s ' + + 'work. Everything in mod.js is a modification of it and is not ' + + 'endorsed by the author. Pages here mark added content with ' + + 'mod where the distinction matters. The full ' + + 'list of changes is in the changelog, on the bottom bar.

'; + return h; +}); + +/* --- 2. progression ------------------------------------------------------- */ +MOD_wikiPage('progress', 'Progression', function () { + var cap = MOD_levelCap(); + var h = '

Progression

' + + '

Skill levels are capped by how far the story has gone. Ten ' + + 'rungs, 10 through 110. The cap applies to every skill at once; Renown is ' + + 'the single exception, since it is counted rather than trained.

' + + '' + + ''; + + MOD_TIERS.forEach(function (t) { + var got = null; + try { got = MOD_tierFlag(t); } catch (e) {} + var here = (t.cap === cap); + var need = t.needText ? t.needText + : (t.flags.length ? t.flags.join(' or ') : 'the start of the game'); + h += '' + + '' + + '' + + '' + + ''; + }); + h += '
CapStageWhat opens it
' + t.cap + '' + MOD_WIKI.safe(t.name) + '' + MOD_WIKI.safe(need) + '' + (here ? 'you are here' + : got ? 'reached' : '') + '
' + + + '

The experience curve

' + + '

The game\'s own skill curve is super-exponential — level 109 to 110 ' + + 'alone costs ' + MOD_wikiNum(MOD_vanillaExpnext(109)) + ' experience, so the top of the ladder ' + + 'is unreachable by a factor of about ten trillion. But the author only ever ' + + 'designed skills up to level 15, and below that his curve is fine.

' + + (MOD_XP.vanillaTo > 0 + ? '

So the mod keeps it. Below level ' + MOD_XP.vanillaTo + ' a skill costs ' + + 'exactly what the original charges; from there it rises by ×' + + MOD_XP.ratio.toFixed(4) + ' a level, a ratio solved so the climb to 110 takes ' + + 'the same time it always did.' + : '

You have chosen the Mod before 4.2 pacing: a plain geometric curve, ' + + '×' + MOD_XP.ratio.toFixed(3) + ' a level from level 0, much faster than the ' + + 'original early on. The Original pacing in settings puts back the ' + + 'author\'s own cost below level 10.') + + ' Character level uses the game\'s own curve, untouched.

' + + '' + + ''; + [1, 5, 9, 10, 15, 25, 50, 75, 90, 109].forEach(function (l) { + h += ''; + }); + h += '
LevelExperience for the nextOriginal game
' + l + + '' + MOD_wikiNum(MOD_expnextFor(l)) + '' + + MOD_wikiNum(MOD_vanillaExpnext(l)) + '
'; + return h; +}); + +/* --- 3. skills ------------------------------------------------------------ */ +MOD_wikiPage('skills', 'Skills', function () { + var cap = MOD_levelCap(); + var bySection = {}; + var keys = []; + for (var k in skl) { + var s = skl[k]; + if (!s || typeof s !== 'object' || !s.name) continue; + keys.push(k); + } + keys.forEach(function (k) { + var s = skl[k]; + var sec = MOD_sectionOf(s); + (bySection[sec] = bySection[sec] || []).push(k); + }); + + var h = '

Skills

' + + '

' + keys.length + ' skills in ' + + Object.keys(MOD_SECTIONS).length + ' sections. Every one has perks at ' + + MOD_LADDER.join(', ') + ', and every one that has milestones grants titles at ' + + MOD_TITLE_RUNGS.join(', ') + '. Levels and ticks below are yours.

'; + + Object.keys(MOD_SECTIONS).forEach(function (sec) { + var list = bySection[sec]; + if (!list || !list.length) return; + var parentKey = MOD_PARENT_KEY[sec]; + var g = ''; + if (parentKey && skl[parentKey]) { + g += '

Parent skill: ' + + MOD_WIKI.safe(skl[parentKey].bname || skl[parentKey].name) + + ' — earns ' + Math.round(MOD_PARENT.feed * 100) + + '% of what its children earn, and speeds up any child that lags behind it.

'; + } + list.sort(function (a, b) { return skl[a].name < skl[b].name ? -1 : 1; }); + list.forEach(function (k) { + var s = skl[k]; + var lvl = Number(s.lvl) || 0; + g += '
' + + '

' + MOD_WIKI.safe(s.bname || s.name) + + (s.bname ? ' (' + MOD_WIKI.safe(s.name) + ')' : '') + + 'lv ' + lvl + + (lvl >= cap ? ' — at the cap' : '') + '

' + + '
' + MOD_wikiDesc(s.desc) + '
'; + + var at110 = MOD_wikiEffectAt(k, s, 110); + if (at110) g += '
At level 110: ' + at110 + '
'; + + if (s.mlstn && s.mlstn.length) { + g += ''; + s.mlstn.forEach(function (m) { + g += '' + + '' + + '' + + ''; + }); + g += '
' + m.lv + '' + MOD_WIKI.safe(m.p || '—') + '' + (m.g === true ? '✓' : '') + '
'; + } + g += '
'; + }); + h += MOD_wikiGroup(MOD_WIKI.safe(MOD_SECTIONS[sec]), list.length, g, 2); + }); + return h; +}); + +/* --- 4. cultivation ------------------------------------------------------- */ +MOD_wikiPage('cultivation', 'Cultivation', function () { + var now = MOD_realm(); + var elig = 0; + try { elig = MOD_realmEligible().n; } catch (e) {} + var qic = skl.qic ? skl.qic.lvl : 0; + + var h = '

Cultivation

' + + '

Ten realms above Mortal. Circulating qi levels the Qi ' + + 'Circulation skill; the skill does not advance the realm. Reaching the level ' + + 'opens a bottleneck, and breaking through it costs a pill and can fail — the ' + + 'pill is spent either way.

' + + '

Where you are

' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Realm' + MOD_WIKI.safe(MOD_realmTitle()) + '
Spiritual Root' + MOD_WIKI.safe(MOD_root().name) + '
Qi Circulationlevel ' + qic + '
Insight' + MOD_insight() + '
Bottleneck open' + + (elig > now.n ? 'yes — ' + MOD_WIKI.safe(MOD_REALMS[now.n + 1].name) + + ' is within reach' : 'no') + '
Qi Deviation' + (MOD_deviated() + ? MOD_deviationLeft() + ' in-game minutes left' : 'no') + '
' + + '

Realm thresholds are the same levels that earn a title rank, so realm N ' + + 'is the level at which a rank N title arrives. Every realm multiplies all ' + + 'stats and health, and the multipliers do not stack with each other — the ' + + 'realm you are in is the one that applies.

' + + '' + + ''; + + MOD_REALMS.forEach(function (r) { + var pill = r.n >= 1 ? item['mod_bp' + r.n] : null; + h += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + h += '
RealmNameQi Circ.StatsHealthPill
' + r.n + '' + MOD_WIKI.safe(r.name) + '
' + + MOD_WIKI.safe(r.desc) + '
' + r.qic + '×' + r.mult + '×' + r.hp + '' + (pill ? MOD_WIKI.safe(pill.name) : '—') + + '
' + + + '

Where the pills come from

' + + '

Every breakthrough pill has a source in normal play. None of them are ' + + 'craftable, and none are cheap — a bottleneck you can buy in bulk is not a ' + + 'bottleneck.

' + + '' + + '' + + '' + + '' + + '
RealmsSource
1The dojo instructor, when qi ' + + 'circulation is first taught
2–5The Herbalist in the village ' + + '(vendor.pha1), restocked
6–10The dojo\'s Level Advancement ' + + 'ladder, and the Pill Tower
' + + '

Realm 1 is deliberately not sold at the marketplace: ' + + 'the marketplace is behind a 40%-a-visit chance encounter, and realm 1 opens ' + + 'long before that is reliable.

' + + + '

Breaking through

' + + '

A bottleneck wants three things, which is what the genre says it wants: ' + + 'a pill, insight, and enough consolidation to make the ' + + 'attempt worth making. The pill and the insight are both spent whether the ' + + 'attempt works or not.

' + + '

The odds improve the further past the threshold you are, plus your root, ' + + 'plus time sat in Closed Door Training, and cap short of certain.

' + + '' + + ''; + [0, 2, 4, 6, 8].forEach(function (o) { + var base = Math.min(0.55 + o * 0.05 + MOD_root().odds, 0.95); + h += ''; + }); + h += '
Levels past the thresholdChanceAfter full seclusion
+' + o + '' + + Math.round(base * 100) + '%' + + Math.round(Math.min(base + MOD_CULT.secludeOdds, 0.95) * 100) + '%
' + + + '

Insight

' + + '

Insight is the half of the price that cannot be bought. It comes from ' + + 'three places, and they are the three the genre names: meditating, fights you ' + + 'nearly lost, and going somewhere you have not been.

' + + '' + + '' + + '' + + '' + + '' + + '
SourceRate
Circulate Qi, at a wall' + + (MOD_CULT.meditateChance * 100).toFixed(1) + '% a second
Closed Door Training' + + (MOD_CULT.secludeChance * 100).toFixed(1) + '% a second
A kill made under ' + + Math.round(MOD_CULT.brinkFrac * 100) + '% health — ' + + (MOD_FIGHTS.mode === 'original' + ? 'the author\'s areas rarely come close once you reach them; the three past ' + + 'the golem arena and the rank duels do, within a few fights, as does an area ' + + 'you walk into early' + : 'every fight costs a share of your health, so a few in a row without resting') + + '' + + Math.round(MOD_CULT.brinkChance * 100) + '% a kill
Entering an area for the first timealways
' + + '

The insight a realm asks for is sized to the climb to it: about ' + + Math.round(MOD_CULT.wallShare * 100) + '% of the Circulate Qi time the climb took, ' + + 'spent at the wall. Seclusion earns it three times as fast, and full ' + + 'consolidation takes as long as that on average.

' + + '' + + '' + + ''; + var wkHours = function (sec) { + return sec >= 7200 ? (sec / 3600).toFixed(sec >= 36000 ? 0 : 1) + ' h' : Math.round(sec / 60) + ' min'; + }; + MOD_REALMS.forEach(function (r) { + if (!r.n) return; + h += '' + + '' + + '' + + '' + + '' + + ''; + }); + h += '
RealmClimb (Circulate Qi, 1x)Insight to enterFull seclusionQi Deviation (in-game)
' + MOD_WIKI.safe(r.name) + '' + wkHours(MOD_realmClimbSec(r.n)) + '' + MOD_insightNeed(r.n) + '' + wkHours(MOD_secludeFull(r.n)) + '' + (MOD_devMinutes(r.n) / 1440).toFixed(1) + ' days
' + + + '

The nine layers

' + + '

Each realm is nine layers deep, derived from where Qi Circulation sits ' + + 'between this realm\'s threshold and the next one\'s — so breaking through ' + + 'puts you at the first layer of the new realm on its own. Layers are worth ' + + '×' + MOD_CULT.layerMult + ' stats and ×' + MOD_CULT.layerBody + + ' body each above the first, and the top realm has no rung above it to ' + + 'measure against, so it stays at its first layer.

' + + '' + + ''; + for (var li = 1; li <= MOD_CULT.layers; li++) { + h += ''; + } + h += '
LayerCalled
' + + li + '' + MOD_LAYER_ORD[li] + ' layer, ' + + MOD_WIKI.safe(MOD_layerStage(li)) + '
' + + '

At the threshold of the next realm and before breaking ' + + 'through, you are a half step to it — the genre\'s own name for the ' + + 'state this mod calls a bottleneck.

' + + + '

Closed Door Training

' + + '

Seclusion does two things. It consolidates — a full sitting ' + + '(the table above) is worth +' + Math.round(MOD_CULT.secludeOdds * 100) + + '% on the attempt — and it shelters: a breakthrough attempted while ' + + 'you are still sitting in it cannot end in Qi Deviation, only in a lost ' + + 'pill. It will not start unless you are actually at a wall.

' + + + '

Qi Deviation

' + + '

Failing a breakthrough out in the open leaves your cultivation base ' + + 'unstable for as long as the seclusion you skipped (the table above, never ' + + 'under ' + MOD_CULT.devMinutes + ' in-game minutes): stats at ×' + + MOD_CULT.devStat + ', body at ×' + MOD_CULT.devBody + ', and no second ' + + 'attempt until it passes. It clears on its own, or at once with a ' + + 'Qi Settling Pill. It never kills — the death system stays out of it.

' + + + '

Heavenly Tribulation

' + + '

From realm ' + MOD_CULT.tribFrom + ' up, a successful breakthrough is ' + + 'answered. The cost is a fraction of your maximum health, so it is a gate on ' + + 'walking in healthy rather than a damage race; the realm bonus you are ' + + 'standing on is what pays it. Fall to it and the realm does not open.

' + + '' + + ''; + MOD_REALMS.forEach(function (r) { + if (!r.n || r.n < MOD_CULT.tribFrom) return; + h += '' + + '' + + ''; + }); + h += '
RealmBoltsOf your max HP
' + MOD_WIKI.safe(r.name) + '' + MOD_tribulationBolts(r.n) + '' + Math.round(MOD_tribulationCost(r.n) * 100) + '%
' + + + '

Spiritual Root

' + + '

Rolled once, when the dojo finishes teaching you to fight. It is never a ' + + 'dead end: the worst grade costs cultivation speed, not access, and a ' + + 'Root Cleansing Pill moves you up a grade.

' + + '' + + ''; + var rootTotal = 0; + MOD_ROOTS.forEach(function (r) { rootTotal += r.weight; }); + MOD_ROOTS.forEach(function (r) { + h += '' + + '' + + '' + + '' + + '' + + ''; + }); + h += '
GradeRootChanceCultivationBreakthrough
' + r.n + '' + MOD_WIKI.safe(r.name) + '
' + + MOD_WIKI.safe(r.desc) + '
' + Math.round(r.weight / rootTotal * 100) + '%×' + r.xp + '' + (r.odds >= 0 ? '+' : '') + + Math.round(r.odds * 100) + '%
'; + return h; +}); + +/* --- 5. elemental mastery ------------------------------------------------- */ +MOD_wikiPage('mastery', 'Elemental mastery', function () { + var h = '

Elemental mastery mod

' + + '

Six masteries, each with a technique that fires on its own ' + + 'during a fight. Combat is automatic and there is no ability picker, so a ' + + 'technique is a chance on each of your swings to become something else — one ' + + 'that scales with INT and the element rather than with your weapon.

' + + '

Nothing fires while you are still Mortal: the channels have to be open ' + + 'first. Chance is ' + (MOD_TECH.perLevel * 100).toFixed(1) + '% per mastery ' + + 'level plus ' + (MOD_TECH.perRealm * 100).toFixed(1) + '% per realm, capped at ' + + Math.round(MOD_TECH.cap * 100) + '%; a technique lands for ×' + + MOD_TECH.power + ' of a normal strike.

' + + '' + + ''; + + var realm = 0; + try { realm = MOD_realm().n; } catch (e) {} + MOD_MASTERY.forEach(function (m) { + var lvl = Number(m.skill.lvl) || 0; + var ch = realm < 1 ? 0 + : Math.min(lvl * MOD_TECH.perLevel + realm * MOD_TECH.perRealm, MOD_TECH.cap); + h += '' + + '' + + '' + + ''; + }); + h += '
MasteryTechniqueLevelChance now
' + + MOD_WIKI.safe(m.skill.name) + '' + MOD_WIKI.safe(m.name) + '' + lvl + '' + (realm < 1 ? 'Mortal' + : (ch * 100).toFixed(1) + '%') + '
'; + return h; +}); + +/* --- 6. titles ------------------------------------------------------------ */ +MOD_wikiPage('titles', 'Titles', function () { + var perm = MOD_permanentRank(); + var held = MOD_titleCount(); + var total = 0; for (var kk in ttl) if (ttl[kk] && ttl[kk].name) total++; + + var h = '

Titles

' + + '

A title\'s rank runs 1 to 10 and is derived from the level ' + + 'that earns it, so a rank is a statement about where in the game you are. ' + + 'Only the title you are wearing applies — until renown makes the lower ranks ' + + 'permanent.

' + + '

Where you are

' + + '' + + '' + + '' + + '
Titles held' + held + ' of ' + total + '
Renown level' + (skl.rnwn ? skl.rnwn.lvl : 0) + + ' of ' + MOD_RENOWN_MAX + '
Ranks that apply without wearing' + + (perm >= 1 ? '1–' + perm : 'none yet') + '
' + + + '

Ranks

' + + ''; + MOD_RANK_AT.forEach(function (lv, i) { + var st = MOD_rankStyle(i + 1); + h += '' + + ''; + }); + h += '
RankEarned from levelColour
' + (i + 1) + '' + lv + '' + '●●●' + '
' + + + '

Renown

' + + '

Renown is not trained. It counts the titles you hold, and each level ' + + 'multiplies every stat by ' + (1 + MOD_RENOWN_RATE) + ' — compounding, so ' + + 'level ' + MOD_RENOWN_MAX + ' is ×' + + (Math.round(MOD_renownMult(MOD_RENOWN_MAX) * 1000) / 1000) + '. At renown N, ' + + 'every title of rank N or below applies permanently.

' + + '' + + ''; + MOD_RENOWN_STEPS.forEach(function (n, i) { + h += '' + + ''; + }); + h += '
RenownTitles neededAll statsPassive ranks
' + (i + 1) + + '' + n + '×' + + (Math.round(MOD_renownMult(i + 1) * 1000) / 1000) + '1–' + (i + 1) + '
' + + + '

Every title

' + + '

Grouped by the skill that grants it. What is left is either ' + + 'a ladder of its own — creatures killed, items gathered, the ' + + 'cultivation realms — or a one-off from the story.

'; + + var groups = {}, fams = {}, loose = []; + for (var k in ttl) { + var t = ttl[k]; + /* Unlike the picker, the wiki KEEPS the author's broken entries and says + so — same call as area.clg, which is listed and tagged "nothing spawns" + rather than quietly dropped. A reader looking for why a title never + appears is better served by the row than by its absence. */ + if (!t || t.name === undefined || t.name === null) continue; + var sk = MOD_TITLE_SKILL[k]; + if (sk && skl[sk]) { (groups[sk] = groups[sk] || []).push(k); continue; } + var stem = MOD_titleStem(k); + if (stem) { (fams[stem] = fams[stem] || []).push(k); continue; } + loose.push(k); + } + /* Same rule as the picker: a family of one is not a family. */ + Object.keys(fams).forEach(function (stem) { + if (fams[stem].length < 2) { loose.push(fams[stem][0]); delete fams[stem]; } + }); + var order = Object.keys(groups).sort(function (a, b) { + return skl[a].name < skl[b].name ? -1 : 1; + }); + + var row = function (k) { + var t = ttl[k]; + var st = MOD_rankStyle(t.rar); + var gen = null; + for (var i = 0; i < MOD_SKILL_TITLES.length; i++) { + if (MOD_SKILL_TITLES[i].key === k) { gen = MOD_SKILL_TITLES[i]; break; } + } + /* The author left a handful unfinished: ttl.ddcd carries the literal string + "null" for both name and description, and shpt2/shpt3/mone3 are blank. + None is granted by anything, so none can be earned. */ + var broken = !t.name || t.name === 'null'; + return '
' + + '' + + MOD_WIKI.safe(t.name || '(unnamed)') + '' + + (broken ? 'unfinished' : '') + + 'rank ' + (t.rar || 1) + '' + + (gen ? 'lv ' + gen.rung + ' · +' + + Math.round(gen.xp * 100) + '% ' + MOD_WIKI.safe(skl[gen.skill].name) + + ' exp' : '') + + (t.have === true ? 'held' : '') + + '
' + MOD_wikiDesc(t.desc) + '
'; + }; + + var byRank = function (a, b) { return (ttl[a].rar || 1) - (ttl[b].rar || 1); }; + order.forEach(function (sk) { + groups[sk].sort(byRank); + h += MOD_wikiGroup(MOD_WIKI.safe(skl[sk].bname || skl[sk].name), + groups[sk].length, groups[sk].map(row).join(''), 3); + }); + /* The ladders that belong to no skill, by the same derivation the picker + uses — so the two cannot drift apart. Sorted by the label, since that is + what the reader scans. */ + var famList = Object.keys(fams) + .map(function (stem) { + fams[stem].sort(byRank); + return { stem: stem, + label: MOD_titleFamilyLabel(stem, fams[stem].map(function (k) { return ttl[k]; })) }; + }) + .sort(function (a, b) { return a.label < b.label ? -1 : a.label > b.label ? 1 : 0; }); + if (famList.length) { + /* Nested one level down rather than sitting beside the skills. Eleven more + top-level summaries cost ~530px on a page that is already the tallest in + the wiki, and these are a different KIND of thing from a skill anyway. */ + var famInner = '', famTotal = 0; + famList.forEach(function (f) { + famTotal += fams[f.stem].length; + famInner += MOD_wikiGroup(MOD_WIKI.safe(f.label), fams[f.stem].length, + fams[f.stem].map(row).join(''), 4); + }); + h += MOD_wikiGroup('Ladders of their own', famTotal, famInner, 3); + } + if (loose.length) { + loose.sort(byRank); + /* The story titles are the one group with no skill to divide them, and + there are enough of them to need dividing. Rank rather than the alphabet: + it is the axis they are already sorted on, and the one a reader cares + about — a rank is a statement about where in the game a title comes from. */ + var inner; + if (loose.length <= MOD_WIKI_ALPHA_AT) { + inner = loose.map(row).join(''); + } else { + var byRankNo = {}; + loose.forEach(function (k) { + var r = Math.min(Math.max(Number(ttl[k].rar) || 1, 1), 10); + (byRankNo[r] = byRankNo[r] || []).push(k); + }); + inner = ''; + Object.keys(byRankNo).sort(function (a, b) { return a - b; }).forEach(function (r) { + var st = MOD_rankStyle(r); + inner += MOD_wikiGroup('Rank ' + r + '', byRankNo[r].length, + byRankNo[r].map(row).join(''), 4); + }); + } + h += MOD_wikiGroup('From the story', loose.length, inner, 3); + } + return h; +}); + +/* --- 7. areas ------------------------------------------------------------- */ +MOD_wikiPage('areas', 'Areas & enemies', function () { + var h = '

Areas & enemies

' + + '

Every area, what lives in it and at what levels, and what it ' + + 'drops. Enemy strength is not read off these levels — see the note below the ' + + 'table.

'; + + /* Two of the game's 21 areas are not places and listing them misleads: + nwh "Somewhere" — where global.current_z parks whenever you are NOT in + a fight, populated by creature.default ("Nothing"). + tst "Test" — reached only from chss.tst, which has id -1 and is in no + sector, so nothing in the game can travel there. + The test for "is this a place" is whether you can get to it, and neither + can be. Everything else is listed whether or not you have been. */ + var MOD_WIKI_NOT_PLACES = { nwh: 1, tst: 1 }; + + var keys = []; + for (var k in area) { + if (area[k] && area[k].pop && !MOD_WIKI_NOT_PLACES[k]) keys.push(k); + } + /* Ordered by the middle of the level band, which is the closest thing the + game has to a difficulty order. */ + keys.sort(function (a, b) { + var mid = function (z) { + if (!z.pop.length) return 0; + var s = 0; z.pop.forEach(function (e) { s += (e.lvlmin + e.lvlmax) / 2; }); + return s / z.pop.length; + }; + return mid(area[a]) - mid(area[b]); + }); + + keys.forEach(function (k) { + var z = area[k]; + /* An area whose baked bands are not finite can never spawn anything — see + the note rendered under its table. Derived rather than listed by name, so + it stays true if the game changes. */ + var broken = !z.popc || z.popc.length !== z.pop.length || + z.popc.some(function (band) { return !band || !isFinite(band[1] - band[0]); }); + h += '

' + MOD_WIKI.safe(z.name) + + ' ' + k + '' + + (broken ? 'nothing spawns' : '') + + '' + (z.size === -1 ? 'endless' : z.size + ' to clear') + + '

'; + if (z.pop.length) { + /* The spawn share is NOT pop[i].c. z_bake normalises those weights into + area.popc — a list of [lo,hi] bands that mon_gen rolls a uniform + random against — and the weights do not have to sum to 1, so they are + off wherever they don't. The Southern forest's .35/.45/.25 are really + 33/43/24%. Read the bands the game actually rolls against. */ + var bands = z.popc || []; + h += ''; + z.pop.forEach(function (e, i) { + var c = e.crt || {}; + var band = bands[i]; + var share = band ? band[1] - band[0] : NaN; + h += '' + + '' + + ''; + }); + h += '
' + MOD_WIKI.safe(c.name || '?') + 'lv ' + e.lvlmin + + (e.lvlmax !== e.lvlmin ? '–' + e.lvlmax : '') + '' + (isFinite(share) + ? Math.round(share * 100) + '% of spawns' + : '—') + '
'; + if (broken) { + h += '
Nothing spawns here. The area\'s entries carry ' + + 'no spawn weight, so the table the game rolls against comes out as ' + + 'NaN and no creature can ever match it. A bug in the base game, left ' + + 'as it is — nothing in the game travels to this area either.
'; + } + } + if (z.drop && z.drop.length) { + var d = z.drop.map(function (x) { + return MOD_WIKI.safe((x.item && x.item.name) || '?') + + ' ' + ((x.c || 0) * 100).toFixed(2) + '%'; + }).join(', '); + h += '
Drops: ' + d + '
'; + } + h += '
'; + }); + + var fightsOrig = MOD_FIGHTS.mode === 'original'; + h += '

How hard an enemy is

' + + '

It depends on the Fights setting, which is ' + + (fightsOrig ? 'Original' : 'Scaled') + ' now. On Original (the default) ' + + 'every area the author wrote is exactly as he wrote it: his creatures, his ' + + 'level ranges, and you out-grow them as you do in his game. On Scaled every ' + + 'area is sized to you. The three areas past the golem arena and the rank ' + + 'duels are sized to you either way.

' + + '

Sizing to you works like this. Enemies are not fitted to a level. Each spawn is solved against your ' + + 'actual power at the moment it appears, aiming at roughly ' + MOD_ENEMY.kill + + ' swings to kill it and ' + MOD_ENEMY.die + ' before it kills you, with a ' + + 'guaranteed margin between the two so a fight is never a coin flip. Variety ' + + 'comes from the area\'s level band and each creature\'s own stat weighting, ' + + 'not from difficulty scaling.

' + + '

The reason for that: within the first stage alone you go from 1 STR to ' + + 'around 265, so anything anchored to a level is either trivial at the end of ' + + 'a stage or impossible at the start of one.

'; + return h; +}); + +/* --- 8. items ------------------------------------------------------------- + The one place where "list everything" needs help: 321 of the game's 371 + plain items are the same inventory type, so grouping by type alone leaves a + single group of 321. Anything past MOD_WIKI_ALPHA_AT gets an alphabetical + sub-split as well — the oldest index pattern there is, and derived rather + than invented, which a hand-made taxonomy would not be. + ------------------------------------------------------------------------- */ + +var MOD_WIKI_ALPHA_AT = 60; + +/* The game's own inventory tabs (isort): ALL / WPN / EQP / USE / OTHER. Using + its categories rather than new ones means a group here is a tab there. */ +var MOD_WIKI_STYPE = { 2: 'Weapons', 3: 'Equipment', 4: 'Usable', 5: 'Other' }; + +function MOD_wikiItemRow(it) { + var val = it.v !== undefined ? it.v : (it.val !== undefined ? it.val : null); + return '
' + + '

' + MOD_WIKI.safe(it.name) + + (it.rar > 1 ? 'rarity ' + it.rar + '' : '') + + (val !== null ? '' + MOD_wikiNum(val) + ' coin' : '') + + (it.amount > 0 ? '×' + it.amount + '' : '') + + '

' + MOD_wikiDesc(it.desc) + '
'; +} + +/* The letter a name files under. Several items are quoted — the master's + manuals are literally named "Sword Saint Manual" — and filing those under a + quotation mark is no use to anyone looking for S. */ +function MOD_wikiLetter(name) { + var m = String(name || '').replace(/^[^0-9A-Za-z]+/, ''); + return (m.charAt(0) || '?').toUpperCase(); +} + +function MOD_wikiByLetter(a, b) { + var x = MOD_wikiLetter(a.name) + String(a.name || '').toLowerCase(); + var y = MOD_wikiLetter(b.name) + String(b.name || '').toLowerCase(); + return x < y ? -1 : (x > y ? 1 : 0); +} + +/* Flat below the threshold, alphabetically sub-grouped above it. Ranges are cut + from the entries actually present, so they stay even as content is added. */ +function MOD_wikiAlpha(entries, render, level) { + if (entries.length <= MOD_WIKI_ALPHA_AT) return entries.map(render).join(''); + var per = Math.ceil(entries.length / Math.ceil(entries.length / 40)); + var out = ''; + for (var i = 0; i < entries.length; i += per) { + var chunk = entries.slice(i, i + per); + var first = MOD_wikiLetter(chunk[0].name); + var last = MOD_wikiLetter(chunk[chunk.length - 1].name); + out += MOD_wikiGroup(first === last ? first : first + '–' + last, + chunk.length, chunk.map(render).join(''), level || 4); + } + return out; +} + +MOD_wikiPage('items', 'Items', function () { + var h = '

Items

' + + '

Everything the game defines, whether or not you have seen ' + + 'it. Grouped the way your inventory groups it. Values are the base sell ' + + 'price where the game gives one.

'; + + /* item[] first, split by the inventory tab it appears under. */ + var buckets = {}; + for (var k in item) { + var it = item[k]; + if (!it || !it.name || it.name === 'dummy') continue; + var lab = MOD_WIKI_STYPE[it.stype] || 'Uncategorised'; + (buckets[lab] = buckets[lab] || []).push(it); + } + ['Usable', 'Other', 'Weapons', 'Equipment', 'Uncategorised'].forEach(function (lab) { + var list = buckets[lab]; + if (!list || !list.length) return; + list.sort(MOD_wikiByLetter); + h += MOD_wikiGroup(lab, list.length, + MOD_wikiAlpha(list, MOD_wikiItemRow, 4), 2); + }); + + /* and the equipment objects, which live in their own globals */ + [['Weapons', wpn], ['Armour', eqp], ['Shields', sld], ['Accessories', acc]] + .forEach(function (g) { + var list = []; + for (var kk in g[1]) { + if (g[1][kk] && g[1][kk].name && g[1][kk].name !== 'dummy') list.push(g[1][kk]); + } + if (!list.length) return; + list.sort(MOD_wikiByLetter); + h += MOD_wikiGroup(g[0], list.length, + MOD_wikiAlpha(list, MOD_wikiItemRow, 4), 2); + }); + return h; +}); + +/* --- 9. actions ----------------------------------------------------------- */ +MOD_wikiPage('actions', 'Actions', function () { + var unlock = {}; + MOD_ACTION_UNLOCKS.forEach(function (u) { + if (u.act) unlock[u.act.id] = (skl[u.skill] ? skl[u.skill].name : u.skill) + + ' level ' + u.lv; + }); + if (typeof act.mod_qi !== 'undefined') { + unlock[act.mod_qi.id] = 'clearing the dojo\'s Easiest, Easy and Normal fights'; + } + + var h = '

Actions

' + + '

What you can be doing. One at a time, and only where the ' + + 'action makes sense — unless "Unrestricted actions" is ticked in settings, ' + + 'which lets several run at once anywhere. Both limits are deliberate; the ' + + 'switch is there because they are not to everyone\'s taste.

'; + + var keys = []; + for (var k in act) if (act[k] && act[k].name && act[k].name !== 'dummy') keys.push(k); + keys.sort(function (a, b) { return act[a].name < act[b].name ? -1 : 1; }); + + keys.forEach(function (k) { + var a = act[k]; + var how = unlock[a.id]; + h += '
' + + '

' + MOD_WIKI.safe(a.name) + + (a.have === true ? 'unlocked' + : 'locked') + + (how ? 'from ' + MOD_WIKI.safe(how) + '' : '') + + '

' + MOD_wikiDesc(a.desc) + '
'; + }); + return h; +}); + +/* --- 10. the dojo --------------------------------------------------------- */ +MOD_wikiPage('dojo', 'The dojo', function () { + var h = '

The dojo

' + + '

The instructor promises a reward every five levels. The base ' + + 'game stops delivering at 30; the mod carries the same ladder to 110, on the ' + + 'same terms.

' + + '

Only the lowest rung you have not claimed is offered, which is how the ' + + 'first six behave. Rewards are sized against the character experience curve ' + + '(4×level3 + level2) — a level at 110 costs 5.3 ' + + 'million, which is why the base game\'s best pill is worth 0.3% of one there.

' + + '' + + ''; + + MOD_DOJO_RUNGS.forEach(function (r) { + var pill = item[r.pill]; + var extra = []; + if (r.realmPill) extra.push('breakthrough pill for realm ' + r.realmPill); + if (r.manual) extra.push('a choice of master\'s manual'); + h += '' + + '' + + '' + + '' + + '' + + ''; + }); + h += '
LevelRewardCoinAlso
' + r.lv + '' + (pill ? MOD_WIKI.safe(pill.name) + ' ×' + r.n + : '—') + '' + MOD_wikiNum(r.coin) + '' + (extra.join(', ') || '—') + '' + (global.flags[r.flag] ? 'claimed' : '') + + '
' + + + '

Spirit pills

' + + '

Character experience in a bottle. The four the mod adds continue the ' + + 'game\'s own series up to the new curve.

' + + '' + + ''; + MOD_PILLS.forEach(function (p) { + h += ''; + }); + h += '
PillExperience
' + MOD_WIKI.safe(p[2]) + '' + + p[3].toLocaleString() + '
' + + + '

Master\'s manuals

' + + '

A third grade of skillbook, offered at levels 50, 75 and 100 as a choice ' + + 'the same way the first one was. Each is worth +' + + Math.round(MOD_MANUAL_RATE * 100) + '% experience in its skill, permanently.

' + + '' + + ''; + MOD_MANUALS.forEach(function (m) { + h += ''; + }); + h += '
ManualSkill
' + MOD_WIKI.safe(m[2]) + '' + + MOD_WIKI.safe(skl[m[0]] ? (skl[m[0]].bname || skl[m[0]].name) : m[0]) + + '
'; + return h; +}); + +/* --- 11. mechanics -------------------------------------------------------- */ +MOD_wikiPage('mechanics', 'Mechanics', function () { + return '

Mechanics

' + + + '

Damage is subtractive, in both directions

' + + '

A hit does the attacker\'s strength minus the defender\'s strength, plus ' + + 'the weapon, plus one, before criticals. A defender\'s STR is their ' + + 'armour — there is no separate armour stat doing that job.

' + + '

Two consequences worth carrying around:

' + + '
    ' + + '
  • Damage does not taper, it stops. Once a defender\'s STR passes ' + + 'yours, your hits do not get small; they land for nothing. Progress against ' + + 'something too strong is not slow, it is zero.
  • ' + + '
  • A stat ratio tells you nothing. Comparing your HP to an enemy\'s ' + + 'strength ignores the subtraction entirely and reads several times off in ' + + 'either direction.
  • ' + + '
' + + '

This is also the reason enemies are never scaled by raising ' + + 'their strength: it would raise their offence and their armour together, and ' + + 'the second effect arrives first.

' + + + '

Criticals carry the late game

' + + '

Critical rate reaches about a third at the top of the ladder and a ' + + 'critical is roughly eight times a normal swing, so around three quarters of ' + + 'all damage done at that point is critical damage. Anything that raises ' + + 'critical rate or critical damage is worth more than it looks; Sharp Eye and ' + + 'War are the two that do.

' + + + '

Hit chance

' + + '

Landing a hit is a contest of agility. It is a ratio rather than a ' + + 'subtraction, so agility behaves more gently than strength does — falling ' + + 'behind on it costs you accuracy, not everything.

' + + + '

Skill sections and parents

' + + '

Each of the ' + Object.keys(MOD_SECTIONS).length + ' sections has a parent ' + + 'skill. A parent earns ' + Math.round(MOD_PARENT.feed * 100) + '% of everything ' + + 'its children earn and grants no stats of its own. In return, a child below ' + + 'the parent\'s level trains ' + Math.round(MOD_PARENT.perLevel * 100) + + '% faster per level of lag, up to ' + MOD_PARENT.maxGap + ' levels behind. ' + + 'Bringing one section up makes every skill in it cheaper to catch up.

' + + + '

Saving

' + + '

Three slots, switched from the bottom bar. The game builds its world once ' + + 'at startup and cannot unload a save, so switching slots or starting a new ' + + 'game reloads the page. Exporting a save before running anything unusual is ' + + 'always the safe move.

'; +}); + +/* --- the document --------------------------------------------------------- + One string, no fetches, no dependencies: this has to work from file:// as + readily as from a server. + ------------------------------------------------------------------------- */ + +function MOD_wikiCss() { + return [ + ':root{color-scheme:dark}', + '*{box-sizing:border-box}', + /* Menlo and Consolas before the generic: with MS Gothic missing (any Mac) + "monospace" is Courier, which Safari sets wider than Chrome does, and + table cells wrapped on one and not the other. */ + 'body{margin:0;background:#060a1c;color:#dfe6f5;font:14px/1.6 "MS Gothic",' + + '"MS Mincho",Menlo,Consolas,"DejaVu Sans Mono",monospace;display:flex;min-height:100vh}', + 'a{color:#7cb0ff}', + 'nav{width:210px;flex:0 0 210px;background:#081040;border-right:1px solid #46a;' + + 'position:sticky;top:0;height:100vh;overflow:auto;padding:14px 0}', + 'nav h2{font-size:13px;color:#9fb4d8;margin:0 0 10px 14px;letter-spacing:1px}', + 'nav a{display:block;padding:6px 14px;text-decoration:none;color:#cfe0ff;' + + 'border-left:3px solid transparent}', + 'nav a:hover{background:#154080}', + 'nav a.on{background:#123070;border-left-color:#7cb0ff;color:#fff}', + 'nav .srch{margin:0 10px 12px;width:calc(100% - 20px);background:#050a20;' + + 'color:#dfe6f5;border:1px solid #46a;padding:5px;font:inherit}', + 'nav .xall{display:block;margin:12px 10px 0;color:#8296b8;font-size:12px;' + + 'cursor:pointer;-webkit-user-select:none;user-select:none}', + 'nav .xall input{vertical-align:-1px;margin-right:4px}', + 'details.wk-g{border:1px solid #17224a;background:#0a1130;margin:8px 0}', + 'details.wk-g>summary{cursor:pointer;padding:7px 12px;list-style:none;' + + 'background:#0d1740}', + 'details.wk-g>summary:hover{background:#154080}', + 'details.wk-g>summary::-webkit-details-marker{display:none}', + // a caret drawn rather than borrowed, so it is the same in every browser + 'details.wk-g>summary::before{content:"\\25B8";color:#7cb0ff;' + + 'display:inline-block;width:1em;transition:transform .12s}', + 'details.wk-g[open]>summary::before{transform:rotate(90deg)}', + 'details.wk-g>summary h2,details.wk-g>summary h3{display:inline;margin:0;' + + 'font-size:15px;color:#fff}', + '.wk-gi{padding:2px 12px 10px}', + 'details.wk-g details.wk-g>summary h4{display:inline;margin:0;font-size:13px;' + + 'color:#cfe0ff;font-weight:normal}', + 'details.wk-g details.wk-g>summary{padding:5px 10px;background:#0b1435}', + 'main{flex:1;min-width:0;padding:22px 30px 80px;max-width:1000px}', + 'h1{font-size:26px;margin:0 0 6px;color:#fff;border-bottom:1px solid #46a;' + + 'padding-bottom:8px}', + 'h2{font-size:19px;color:#bcd4ff;margin:30px 0 8px}', + 'h3{font-size:15px;color:#fff;margin:0 0 4px}', + 'p.lede{color:#a8bcdc;margin:0 0 18px}', + '.dim{color:#8296b8;font-weight:normal}', + '.flav{font-size:12px;font-style:italic}', + '.note{background:#0b1638;border-left:3px solid #46a;padding:8px 12px;' + + 'color:#a8bcdc;margin:14px 0}', + 'code,pre{background:#0b1638;border:1px solid #24356e;padding:1px 5px;' + + 'color:#9fd0ff}', + 'pre{padding:10px;overflow-x:auto}', + 'dl dt{color:#fff;margin-top:14px}', + 'dl dd{margin:2px 0 0 0;color:#a8bcdc}', + 'ul{color:#a8bcdc}', + 'table.wk-tbl{border-collapse:collapse;width:100%;margin:10px 0;' + + 'display:block;overflow-x:auto}', + 'table.wk-tbl th{text-align:left;color:#9fb4d8;font-weight:normal;' + + 'border-bottom:1px solid #46a;padding:5px 9px;white-space:nowrap}', + 'table.wk-tbl td{border-bottom:1px solid #17224a;padding:5px 9px;' + + 'vertical-align:top}', + 'table.wk-tbl tr.done td{color:#cfe0ff}', + 'table.wk-tbl tr.here{background:#123070}', + '.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}', + '.wk-e{border:1px solid #17224a;background:#081040;padding:9px 12px;' + + 'margin:7px 0;border-radius:2px}', + 'tr.wk-e{border:0;background:none}', + '.wk-e.done{border-color:#2a4a8c}', + '.wk-e h3{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}', + '.lv{margin-left:auto;font-size:12px;color:#8296b8;font-weight:normal}', + '.lv.capped{color:#ffd24a}', + '.rank{font-size:12px;color:#8296b8;font-weight:normal}', + '.desc{color:#a8bcdc;font-size:13px}', + '.proj{color:#ffd24a;font-size:12px;margin-top:3px}', + '.tag{font-size:11px;border:1px solid #46a;color:#9fb4d8;padding:0 5px;' + + 'border-radius:2px;font-weight:normal}', + '.tag.mod{border-color:#7a4ea8;color:#c07bff}', + '.tag.done{border-color:#2e7d4f;color:#6fdc9a}', + '.tag.here{border-color:#ffd24a;color:#ffd24a}', + '.tag.warn{border-color:#a8562e;color:#ff9a5c}', + 'table.wk-perks{border-collapse:collapse;margin:6px 0 0;width:100%}', + 'table.wk-perks td{padding:2px 8px 2px 0;font-size:12px;color:#8296b8;' + + 'border-bottom:1px solid #101a3c}', + 'table.wk-perks tr.done td{color:#cfe0ff}', + 'table.wk-perks td.tick{color:#6fdc9a;width:1em;text-align:right}', + // fixed columns so the level bands line up between one area and the next + 'table.wk-pop{table-layout:fixed}', + 'table.wk-pop td:first-child{width:60%}', + 'table.wk-pop td:nth-child(2){width:20%}', + '.wk-title .tname{font-weight:bold}', + '.wk-now{background:#0b1638;border:1px solid #24356e;padding:10px 14px;' + + 'margin:0 0 18px;display:inline-block;min-width:320px}', + '.wk-now h3{color:#9fb4d8;font-weight:normal;font-size:13px;' + + 'letter-spacing:1px;margin-bottom:6px}', + '.wk-now td{padding:2px 16px 2px 0}', + '.wk-now td:last-child{color:#fff;text-align:right}', + '.pg{display:none}.pg.on{display:block}', + 'footer{color:#5a6c8c;font-size:12px;border-top:1px solid #17224a;' + + 'margin-top:40px;padding-top:12px}', + '@media(max-width:760px){body{display:block}nav{width:auto;height:auto;' + + 'position:static;flex:none}main{padding:16px}}' + ].join('\n'); +} + +/* Filtering, page switching and the hash. Small enough to inline, and inlining + it is the point — the document has to stand on its own. */ +function MOD_wikiJs() { + return [ + 'var pages=[].slice.call(document.querySelectorAll(".pg"));', + 'var links=[].slice.call(document.querySelectorAll("nav a[data-p]"));', + 'function show(id){', + ' var found=false;', + ' pages.forEach(function(p){var on=p.id==="pg-"+id;if(on)found=true;', + ' p.className="pg"+(on?" on":"")});', + ' if(!found&&pages.length){pages[0].className="pg on";id=pages[0].id.slice(3)}', + ' links.forEach(function(a){a.className=a.dataset.p===id?"on":""});', + ' window.scrollTo(0,0);return id;}', + 'links.forEach(function(a){a.addEventListener("click",function(e){', + ' e.preventDefault();location.hash=a.dataset.p;show(a.dataset.p)})});', + 'window.addEventListener("hashchange",function(){show(location.hash.slice(1))});', + 'show(location.hash.slice(1)||"start");', + // Search filters entries, hides groups left with none, and OPENS the groups + // that still have some — a match hidden inside a collapsed group reads as + // no match at all, which is worse than not having search. + 'var box=document.getElementById("srch");', + 'var xall=document.getElementById("xall");', + 'var groups=[].slice.call(document.querySelectorAll("details.wk-g"));', + 'function filter(){', + ' var q=box.value.trim().toLowerCase();', + ' pages.forEach(function(p){', + ' [].slice.call(p.querySelectorAll(".wk-e")).forEach(function(e){', + ' e.style.display=(!q||e.textContent.toLowerCase().indexOf(q)>=0)?"":"none"})});', + // Every page, not just the one on screen: the nav says how many entries + // match in each, dims the ones with none, and Enter jumps to the first + // page that has any. A prose page with no entries counts as one if its text + // contains the query. + ' links.forEach(function(a){', + ' var pg=document.getElementById("pg-"+a.dataset.p),n=0;', + ' if(a.dataset.l===undefined)a.dataset.l=a.textContent;', + ' if(q&&pg){var es=[].slice.call(pg.querySelectorAll(".wk-e"));', + ' if(es.length)es.forEach(function(e){if(e.textContent.toLowerCase().indexOf(q)>=0)n++});', + ' else if(pg.textContent.toLowerCase().indexOf(q)>=0)n=1}', + ' a.dataset.n=n;a.style.opacity=(q&&!n)?".4":"";', + ' a.textContent=a.dataset.l+(q&&n?" ("+n+")":"")});', + ' groups.forEach(function(g){', + ' var any=[].slice.call(g.querySelectorAll(".wk-e")).some(function(e){', + ' return e.style.display!=="none"});', + ' g.style.display=any?"":"none";', + ' g.open=xall.checked||(!!q&&any)}); }', + 'box.addEventListener("input",filter);', + 'box.addEventListener("keydown",function(e){', + ' if(e.key==="Escape"){box.value="";filter()}', + ' if(e.key==="Enter"&&box.value.trim()){', + ' var cur=links.filter(function(a){return a.className==="on"})[0];', + ' if(cur&&+cur.dataset.n)return;', + ' var hit=links.filter(function(a){return +a.dataset.n>0})[0];', + ' if(hit){location.hash=hit.dataset.p;show(hit.dataset.p)}}});', + // Everything is collapsed by default because three of these pages run past + // 40,000px open. This is the way back to one long document, which is what + // the browser's own find needs. + 'xall.addEventListener("change",filter);' + ].join('\n'); +} + +function MOD_wikiBuild() { + var nav = '', body = ''; + MOD_WIKI_PAGES.forEach(function (pg) { + nav += '' + + MOD_WIKI.safe(pg.label) + ''; + var inner; + /* One page throwing must not take the wiki down with it — the page says so + and the rest still renders. */ + try { inner = pg.build(); } + catch (e) { + inner = '

' + MOD_WIKI.safe(pg.label) + '

This page ' + + 'could not be built: ' + MOD_WIKI.safe(e.message) + '

'; + console.warn('[mod] wiki page "' + pg.id + '" failed: ' + e.message); + } + body += '
' + inner + '
'; + }); + + var stamp = new Date().toLocaleString(); + return '' + + '' + + '' + MOD_WIKI.safe(MOD_WIKI.title) + '' + + '' + + '' + + '
' + body + + '
Generated from the running game at ' + MOD_WIKI.safe(stamp) + + ' — mod ' + MOD_WIKI.safe(MOD.version) + '. Everything here is read from the ' + + 'live data, including your own save, so it cannot fall out of step with the ' + + 'game. The game is truezhangwei\'s; the mod is not endorsed by the author.' + + '
' + + '' +if [ -z "$removed" ] && [ "$added" == "$expected" ]; then + echo "ok index.html is the author's plus the one script tag" +else + echo "FAIL index.html changes more than the script tag against $REF:" + printf '%s\n' "$diff_out" | head -40 + fail=1 +fi +exit $fail diff --git a/tests/backups.mjs b/tests/backups.mjs new file mode 100644 index 0000000..96c1a60 --- /dev/null +++ b/tests/backups.mjs @@ -0,0 +1,134 @@ +import { launch, settle as settlePage } from './lib/browser.mjs'; +import fs from 'node:fs'; + +// Save backups (sections 0 and 44). +// +// The first time a new version of the mod loads it copies every save before +// the game has read one. That copy has to be made FIRST in mod.js: an update +// that breaks is exactly when it is wanted, and a section that throws stops +// every line after it while the game still loads and autosaves. +// +// Clears and rewrites localStorage and reloads the page repeatedly -- run it in +// the fresh profile the runner gives it, not against a browser you play in. +// +// PORT=8080 node tests/backups.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +p.on('dialog', d => d.accept()); +const settle = () => settlePage(p); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); await settle(); + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +console.log('--- it runs first'); +const src = fs.readFileSync(new URL('../mod.js', import.meta.url), 'utf8'); +const at = src.indexOf('A BACKUP OF YOUR SAVE BEFORE AN UPDATE LOADS'); +check(at > 0 && at < src.indexOf('1. SPEED CONTROL'), + 'the snapshot comes before section 1, so nothing that throws can stop it'); + +console.log('\n--- nothing to back up, nothing backed up'); +const first = await p.evaluate(() => ({ n: MOD_backupList().length, ver: localStorage.getItem(MOD.backup_ver), v: MOD.version })); +check(first.n === 0, `a first load with no save makes no backup (${first.n})`); +check(first.ver === first.v, 'and records the version it has seen'); + +console.log('\n--- a new version backs up the save it is about to load'); +const blob = await p.evaluate(() => { you.name = 'Alpha'; save(); localStorage.setItem(MOD.backup_ver, '4.2'); + return localStorage.getItem('v0.3'); }); +await p.reload({ waitUntil: 'load' }); await settle(); +const after = await p.evaluate(() => { const l = MOD_backupList().filter(b => b.n <= MOD.backup_count); + const daily = MOD_backupRead(MOD.backup_daily); + return { n: l.length, reason: l[0] && l[0].rec.reason, live: l[0] && l[0].rec.live, num: l[0] && l[0].n, + ver: localStorage.getItem(MOD.backup_ver), daily: daily && daily.reason, dailyLive: daily && daily.live }; }); +check(after.n === 1, `one version backup made (${after.n})`); +check(after.live === blob, 'holding exactly the save the game was about to read'); +check(/from v4\.2/.test(after.reason || ''), `and saying why (${after.reason})`); +check(after.daily === 'daily' && after.dailyLive === blob, 'and the first daily backup, in its own place'); +await p.reload({ waitUntil: 'load' }); await settle(); +const again = await p.evaluate(() => ({ n: MOD_backupList().filter(b => b.n <= MOD.backup_count).length, + dailyT: MOD_backupRead(MOD.backup_daily).t })); +check(again.n === 1, `the same version does not back up again (${again.n})`); + +console.log('\n--- the daily backup'); +await p.evaluate(() => { const r = MOD_backupRead(MOD.backup_daily); r.t = Date.now() - 2 * 86400000; + localStorage.setItem(MOD.backup_key + MOD.backup_daily, JSON.stringify(r)); }); +await p.reload({ waitUntil: 'load' }); await settle(); +const d1 = await p.evaluate(() => MOD_backupRead(MOD.backup_daily).t); +await p.reload({ waitUntil: 'load' }); await settle(); +const d2 = await p.evaluate(() => MOD_backupRead(MOD.backup_daily).t); +check(Date.now() - d1 < 60000, 'a day-old daily backup is replaced on the next load'); +check(d2 === d1, 'and not again the same day'); + +console.log('\n--- restoring puts the character back, and can be undone'); +await p.evaluate(() => { you.name = 'Beta'; save(); }); +await p.evaluate(n => { setTimeout(() => MOD_restoreBackup(n), 0); }, after.num); +await p.waitForTimeout(800); await p.waitForLoadState('load'); await settle(); +const restored = await p.evaluate(() => { + const undo = MOD_backupList().find(b => /before restoring/.test(b.rec.reason)); + return { name: you.name, undo: !!undo, undoWho: undo ? MOD_backupWho(undo.rec) : '' }; +}); +check(restored.name === 'Alpha', `the backup's character is back (${restored.name})`); +check(restored.undo && /Beta/.test(restored.undoWho), `and the one it replaced was backed up first (${restored.undoWho})`); + +console.log('\n--- three kept, oldest replaced'); +const rot = await p.evaluate(() => { + const dailyBefore = MOD_backupRead(MOD.backup_daily).seq; + for (let i = 0; i < 5; i++) MOD_makeBackup('rotation ' + i); + const l = MOD_backupList(); + return { reasons: l.filter(b => b.n <= MOD.backup_count).map(b => b.rec.reason), + dailyKept: MOD_backupRead(MOD.backup_daily).seq === dailyBefore }; +}); +check(rot.dailyKept, 'rotation never touches the daily backup'); +check(rot.reasons.length === 3, `${rot.reasons.length} backups kept`); +check(rot.reasons.includes('rotation 4') && !rot.reasons.includes('rotation 0'), `the newest survive (${rot.reasons.join(' / ')})`); + +console.log('\n--- no room is not an error'); +const full = await p.evaluate(() => { + const real = Storage.prototype.setItem; + Storage.prototype.setItem = function (k, v) { + if (String(k).indexOf('p23_backup_') === 0) throw new Error('QuotaExceededError'); + return real.apply(this, arguments); + }; + let n, threw = false; + const before = MOD_backupList().length; + try { n = MOD_makeBackup('no room'); } catch (e) { threw = true; } + Storage.prototype.setItem = real; + return { n, threw, before, after: MOD_backupList().length }; +}); +check(!full.threw && full.n === 0, 'a full localStorage skips the backup quietly'); +check(full.before - 1 <= full.after && full.after >= 2, + `and costs at most the one it was replacing (${full.before} -> ${full.after}); it once deleted them all`); + +console.log('\n--- a backup as a file, and back'); +const dl = p.waitForEvent('download'); +const want = await p.evaluate(() => { const b = MOD_backupList()[0]; MOD_downloadBackup(b.n); return b.rec.live; }); +const file = await dl; +const text = await (await import('node:fs')).promises.readFile(await file.path(), 'utf8'); +let parsed = null; try { parsed = JSON.parse(text); } catch (e) {} +check(/^proto23-backup-\d{4}-\d\d-\d\d-v[\d.]+\.json$/.test(file.suggestedFilename()), `downloads as ${file.suggestedFilename()}`); +check(parsed && parsed.live === want, 'holding the save itself'); +await p.evaluate(() => { MOD_pickBackupFile(); }); +await p.setInputFiles('#mod_backup_file', { name: 'b.json', mimeType: 'application/json', buffer: Buffer.from(text) }); +await p.waitForTimeout(400); +const loaded = await p.evaluate(w => MOD_backupList().some(b => /from a file/.test(b.rec.reason) && b.rec.live === w), want); +check(loaded, 'and a downloaded file loads back into the list, ready to restore'); +const junk = await p.evaluate(() => MOD_loadBackupText('{"hello":1}')); +check(junk === 0, 'a file that is not a backup is refused'); + +console.log('\n--- the panel'); +const panel = await p.evaluate(() => { MOD_toggleSlotPanel(true); + const t = MOD_SLOTUI.panel.textContent; + const restores = [].slice.call(MOD_SLOTUI.panel.querySelectorAll('span')).filter(s => s.textContent === 'restore').length; + MOD_toggleSlotPanel(false); + return { hasList: /Backups/.test(t) && /back up now/.test(t), restores, n: MOD_backupList().length }; }); +check(panel.hasList, 'the saves panel lists the backups'); +check(panel.restores === panel.n, `with a restore button for each (${panel.restores})`); + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — a new version backs up every save first, and a backup can be put back.'); diff --git a/tests/basefixes.mjs b/tests/basefixes.mjs new file mode 100644 index 0000000..7ee0c31 --- /dev/null +++ b/tests/basefixes.mjs @@ -0,0 +1,118 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Section 46's three fixes to the author's code, and section 40's shop prices. +// +// Death energy kept on death rises with the skill instead of falling to +// zero at level 10 and below zero after it +// Shield the defence bracket is floored at zero, so defence can no longer be +// added to the damage you take -- and nothing changes where it was fine +// Luck the author's crit multiplier reaches the roll, measured through the +// real attack path, capped at 50% from luck, and seen by the enemy +// model's probe +// +// PORT=8080 node tests/basefixes.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +await p.evaluate(() => { + window.__real = random; + window.dice = v => { random = function () { return v; }; }; + window.undice = () => { random = window.__real; }; + window.__gse = giveSkExp; giveSkExp = function () {}; +}); + +console.log('--- Death'); +const death = await p.evaluate(() => { + const die = (lvl) => { skl.dth.lvl = lvl; skl.dth.exp = 0; you.satmax = 200; you.sat = 100; you.hp = 0; + you.res.death = 1; const d0 = global.stat.deadt; you.onDeath(); you.alive = true; + return { lvl, died: global.stat.deadt > d0, kept: you.sat / 100 }; }; + const rows = [0, 5, 10, 11, 20, 60, 110].map(die); + // the "you avoid death" branch runs the same function without dying + skl.dth.lvl = 20; you.sat = 100; you.res.death = 0; dice(0.5); you.onDeath(); undice(); + const avoided = you.sat; you.res.death = 1; + return { rows, avoided, original10: 0.55 * (1 - 10 * 0.1), original11: 0.55 * (1 - 11 * 0.1) }; +}); +death.rows.forEach(r => console.log(` level ${String(r.lvl).padStart(3)}: keeps ${(r.kept * 100).toFixed(1)}%`)); +check(Math.abs(death.rows[0].kept - 0.55) < 1e-9, 'level 0 keeps 55%, exactly as the original did'); +check(death.rows.every((r, i, a) => i === 0 || r.kept > a[i - 1].kept), 'and it only improves with level'); +check(death.rows.every(r => r.kept > 0 && r.kept < 1), 'never nothing, never everything'); +check(death.original10 === 0 && death.original11 < 0, + `where the original kept ${death.original10} at level 10 and ${death.original11.toFixed(3)} at 11`); +check(death.avoided === 100, 'avoiding death leaves your energy alone'); + +console.log('\n--- Shield Mastery'); +const shield = await p.evaluate(() => { + const z = area.frstn1a2; global.current_z = z; + const m = mon_gen(z.pop[0].crt); lvlup(m, 4); global.current_m = m; global.target = you.eqp[2]; global.t_n = 2; + you.eqp[1].aff = you.eqp[1].aff || {}; const at = m.atype, keep = you.eqp[1].aff[at]; + const hit = (fn) => { dice(0.5); try { return fn(m, you, abl.default); } finally { undice(); } }; + // fine before: a positive bracket must come out exactly as the original + you.eqp[1].aff[at] = 1; skl.shdc.lvl = 5; + const fineOrig = hit(MOD_dmg_calc_before_fixes), fineFixed = hit(MOD_dmg_calc_original); + // broken: a negative bracket + you.eqp[1].aff[at] = 4; skl.shdc.lvl = 110; + const floor = MOD_shieldFloorLevel(m); + const badOrig = hit(MOD_dmg_calc_before_fixes), badFixed = hit(MOD_dmg_calc_original); + // at the floor the whole defence term is zero: what is left is the attack alone + skl.shdc.lvl = floor; const atFloor = hit(MOD_dmg_calc_before_fixes); skl.shdc.lvl = 110; + const restored = skl.shdc.lvl; + you.eqp[1].aff[at] = keep; + return { fineOrig, fineFixed, floor, badOrig, badFixed, atFloor, restored }; +}); +check(shield.fineOrig === shield.fineFixed, `where the bracket was positive nothing changes (${shield.fineOrig})`); +check(shield.floor !== null && shield.badFixed < shield.badOrig, + `where it had gone negative, defence no longer adds damage (${shield.badOrig} -> ${shield.badFixed})`); +check(shield.badFixed === shield.atFloor, 'the fixed hit is the bracket held at exactly zero'); +check(shield.restored === 110, 'and the skill level is put back after the hit'); + +console.log('\n--- Luck'); +const luck = await p.evaluate(() => { + const z = area.frstn1a2; global.current_z = z; + const m = mon_gen(z.pop[0].crt); lvlup(m, 3); global.current_m = m; global.target = you.eqp[2]; global.t_n = 2; + you.sat = you.satmax; const crflt0 = you.mods.crflt; + const rows = [0, 10, 100].map(L => { you.luck = L; allbuff(you); let c = 0; const N = 30000; + for (let i = 0; i < N; i++) { global.flags.crti = false; abl.default.f(you, m); if (global.flags.crti) c++; } + return { L, measured: c / N, model: MOD_critRate(you) }; }); + // a lot of luck, a lot of base crit: capped at 50% from luck + const keepCrt = you.crt; you.crt = 0.2; you.luck = 223; const capped = MOD_critRate(you); you.crt = keepCrt; + // the probe's path is the fixed one, so enemies are sized against it + const probeSeesIt = MOD_meanDamage.toString().indexOf('MOD_dmg_calc_original') >= 0; + you.luck = 0; + return { rows, capped, crfltExact: you.mods.crflt === crflt0, probeSeesIt }; +}); +luck.rows.forEach(r => console.log(` luck ${String(r.L).padStart(3)}: crit ${(r.measured * 100).toFixed(2)}% measured, ${(r.model * 100).toFixed(2)}% modelled`)); +check(luck.rows[2].measured > luck.rows[0].measured * 2, 'luck now raises the crit chance on real swings'); +check(luck.rows.every(r => Math.abs(r.measured - r.model) < 0.004), "and the model the enemy probe weights by agrees with the swings"); +check(Math.abs(luck.capped - 0.5) < 1e-9, `luck alone cannot push crit past 50% (${luck.capped})`); +check(luck.crfltExact, 'the saved crflt field is put back exactly, not add-then-subtract'); +check(luck.probeSeesIt, "the enemy model's probe measures through the fixed path"); + +console.log('\n--- shop prices'); +const shop = await p.evaluate(async () => { + const tick = () => new Promise(r => setTimeout(r, 60)); + const big = chs('"Tribulation Pill" 7000000● x1', false); await tick(); + const drawn = big.textContent; + big.innerHTML = '"Tribulation Pill" 38000● x2'; await tick(); + const rewritten = big.textContent; + const small = chs('"Bread" 31● x3', false); await tick(); + setNumberFormat('game'); const raw = chs('"x" 45000●', false); await tick(); setNumberFormat('short'); + return { drawn, rewritten, small: small.textContent, raw: raw.textContent }; +}); +check(/7\.00M●/.test(shop.drawn), `a big price is formatted when the shop draws it (${shop.drawn})`); +check(/38\.0K●/.test(shop.rewritten), 'and when a purchase rewrites the line'); +check(/31●/.test(shop.small), "the base game's own prices, all under 10,000, are untouched"); +check(/45000●/.test(shop.raw), '"As the original" leaves every price raw'); + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — death, shield and luck do what their text says, and shop prices read.'); diff --git a/tests/baseline.mjs b/tests/baseline.mjs new file mode 100644 index 0000000..0de5f79 --- /dev/null +++ b/tests/baseline.mjs @@ -0,0 +1,122 @@ +import { launch, settle } from './lib/browser.mjs'; +import fs from 'node:fs'; + +// Balance baselines: the numbers that define the game's balance, written to +// tests/baselines/balance.json and compared on every run, so a change that +// shifts balance shows up as a diff in git instead of passing silently. +// +// Only numbers that are identical run to run go in: the skill curve, the dials, +// the realm and tribulation costs, prices, and each area's per-spawn fight +// targets (_modKillT / _modDieT), which come from the level band and the +// creature's shape rather than from any dice. A target that differs between two +// spawns of the same creature is left out and counted, not recorded -- a +// baseline that flaps is worse than none. +// +// PORT=8080 node tests/baseline.mjs compare +// UPDATE=1 PORT=8080 node tests/baseline.mjs accept the current numbers +// +// The player for the fight targets is allareas.mjs's own, lifted from that file +// so the two cannot describe different characters. + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const FILE = new URL('./baselines/balance.json', import.meta.url); +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); +// section 8's model is what this measures, so measure it everywhere: the +// default (Fights: Original) leaves the author's areas unscaled -- fights.mjs +const fightsDefault = await p.evaluate(() => getFights()); +await p.evaluate(() => setFights('scaled')); + +const aa = fs.readFileSync(new URL('./allareas.mjs', import.meta.url), 'utf8'); +const helpers = aa.slice(aa.indexOf(' const freshYou = () => {'), aa.indexOf(' // dmg_calc grants skill exp')); +if (!helpers.includes('buildPlayer')) { console.error('could not lift buildPlayer from allareas.mjs'); process.exit(1); } + +const now = await p.evaluate(`(() => { +${helpers} + const sig = x => (typeof x === 'number' && isFinite(x)) ? +x.toPrecision(5) : x; + const out = {}; + const put = (k, v) => { out[k] = sig(v); }; + + [1, 5, 9, 10, 15, 30, 60, 90, 109].forEach(l => put('curve.cost.lv' + l, MOD_expnextFor(l))); + MOD_TIERS.forEach(t => put('curve.total.cap' + t.cap, MOD_xpToReach(t.cap))); + put('curve.ratio', MOD_XP.ratio); put('curve.vanillaTo', MOD_XP.vanillaTo); + put('rate.skill_xp_mult', MOD.skill_xp_mult); put('rate.coin_drop', MOD_MONEY.chance); + + Object.keys(MOD_ENEMY).forEach(k => { if (typeof MOD_ENEMY[k] === 'number') put('enemy.' + k, MOD_ENEMY[k]); }); + put('rank.margin', MOD_RANK_MARGIN); put('rank.step', MOD_RANK_STEP); + put('luck.cap', MOD_LUCK_CAP); put('conv.sectionBonus', MOD_CONV.sectionBonus); + + MOD_REALMS.forEach(r => { if (!r.n) return; + put('realm' + r.n + '.qic', r.qic); put('realm' + r.n + '.mult', r.mult); put('realm' + r.n + '.hp', r.hp); + put('realm' + r.n + '.insight', MOD_insightNeed(r.n)); + put('realm' + r.n + '.seclusionSec', MOD_secludeFull(r.n)); + put('realm' + r.n + '.deviationMin', MOD_devMinutes(r.n)); + if (r.n >= MOD_CULT.tribFrom) put('realm' + r.n + '.tribulation', MOD_tribulationCost(r.n)); }); + Object.keys(MOD_CULT).forEach(k => { if (typeof MOD_CULT[k] === 'number') put('cult.' + k, MOD_CULT[k]); }); + + const keep = global.flags.mod_slipsused; + [0, 10, 50].forEach(u => { global.flags.mod_slipsused = u; put('price.slip.used' + u, MOD_slipPrice()); }); + global.flags.mod_slipsused = keep; + for (const k in item) { const it = item[k]; if (k.indexOf('mod_') === 0 && it && typeof it.v === 'number') put('value.' + k, it.v); } + const stock = (vk) => { const v = vendor[vk]; if (!v || !v.items) return; + v.items.forEach(s => { if (s.item && s.item.id && typeof s.p === 'number') put('shop.' + vk + '.' + s.item.id, s.p); }); }; + stock('pha1'); stock('mod_pltwr'); + put('meat.chance', MOD_MEAT.chance); + // the meat each fight setting drops (the default setting is read before + // this script switches to Scaled, below the page load) + put('meat.killSecOriginal', MOD_MEAT.killSecOriginal); + MOD_MEAT_DROPS.forEach((d, i) => { put('meat.drop' + i + '.author', d._modChance0); put('meat.drop' + i + '.scaled', d.chance); }); + + // per-spawn fight targets: every area's first creature, three story tiers + const quietG = giveSkExp; giveSkExp = function () {}; + let unstable = 0; + const areas = Object.keys(area).filter(k => area[k] && area[k].pop && area[k].pop.length && k !== 'nwh' && k !== 'tst'); + [0, 5, 9].forEach(ti => { + buildPlayer(ti, 'all'); + areas.forEach(ak => { + const z = area[ak], e = z.pop[0]; global.current_z = z; + const a = mon_gen(e.crt); lvlup(a, e.lvlmin); + const c = mon_gen(e.crt); lvlup(c, e.lvlmin); + if (!(a._modKillT > 0)) return; + if (sig(a._modKillT) !== sig(c._modKillT) || sig(a._modDieT) !== sig(c._modDieT)) { unstable++; return; } + put('target.t' + ti + '.' + ak + '.kill', a._modKillT); + put('target.t' + ti + '.' + ak + '.die', a._modDieT); + }); + }); + giveSkExp = quietG; + return { values: out, unstable }; +})()`); + +now.values['fights.default'] = fightsDefault; + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; +const keys = Object.keys(now.values).sort(); +console.log(`${keys.length} numbers recorded; ${now.unstable} per-spawn targets left out as unstable`); + +if (process.env.UPDATE || !fs.existsSync(FILE)) { + const sorted = {}; keys.forEach(k => { sorted[k] = now.values[k]; }); + fs.writeFileSync(FILE, JSON.stringify(sorted, null, 1) + '\n'); + console.log(`\nwrote ${FILE.pathname} — commit it; the diff is the balance change`); +} else { + const was = JSON.parse(fs.readFileSync(FILE, 'utf8')); + const changed = [], added = [], gone = []; + keys.forEach(k => { if (!(k in was)) added.push(k); else if (was[k] !== now.values[k]) changed.push(`${k}: ${was[k]} -> ${now.values[k]}`); }); + Object.keys(was).forEach(k => { if (!(k in now.values)) gone.push(k); }); + changed.slice(0, 40).forEach(c => console.log(' changed ' + c)); + added.slice(0, 20).forEach(c => console.log(' new ' + c)); + gone.slice(0, 20).forEach(c => console.log(' gone ' + c)); + check(changed.length === 0 && added.length === 0 && gone.length === 0, + `the balance matches the committed baseline (${changed.length} changed, ${added.length} new, ${gone.length} gone)` + + (changed.length + added.length + gone.length ? ' — if intended, run with UPDATE=1 and commit the file' : '')); +} +check(now.unstable < keys.length / 10, `almost every target is stable enough to record (${now.unstable} not)`); + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — the balance is what the baseline says it is.'); diff --git a/tests/baselines/balance.json b/tests/baselines/balance.json new file mode 100644 index 0000000..d731e90 --- /dev/null +++ b/tests/baselines/balance.json @@ -0,0 +1,348 @@ +{ + "conv.sectionBonus": 0.03, + "cult.brinkChance": 0.25, + "cult.brinkFrac": 0.2, + "cult.devBody": 0.7, + "cult.devMinutes": 720, + "cult.devStat": 0.55, + "cult.insightBase": 1, + "cult.insightStep": 2, + "cult.layerBody": 0.03, + "cult.layerMult": 0.02, + "cult.layers": 9, + "cult.meditateChance": 0.004, + "cult.secludeChance": 0.012, + "cult.secludeFull": 900, + "cult.secludeOdds": 0.3, + "cult.tribBase": 0.55, + "cult.tribBoltStep": 2, + "cult.tribBolts": 3, + "cult.tribFrom": 8, + "cult.tribStep": 0.15, + "cult.wallShare": 0.05, + "curve.cost.lv1": 55, + "curve.cost.lv10": 26311, + "curve.cost.lv109": 461570, + "curve.cost.lv15": 30407, + "curve.cost.lv30": 46933, + "curve.cost.lv5": 1003, + "curve.cost.lv60": 111810, + "curve.cost.lv9": 25561, + "curve.cost.lv90": 266360, + "curve.ratio": 1.0294, + "curve.total.cap10": 47986, + "curve.total.cap110": 15335000, + "curve.total.cap15": 187500, + "curve.total.cap20": 348730, + "curve.total.cap30": 750390, + "curve.total.cap40": 1286800, + "curve.total.cap50": 2003300, + "curve.total.cap60": 2960200, + "curve.total.cap75": 5029900, + "curve.total.cap90": 8224600, + "curve.vanillaTo": 10, + "enemy.areaScale": 1.4, + "enemy.armor": 1, + "enemy.atkSpread": 0.3, + "enemy.bossDie": 0.7, + "enemy.bossKill": 2, + "enemy.die": 20, + "enemy.expRate": 0.5, + "enemy.hit": 0.45, + "enemy.hpSpread": 0.6, + "enemy.kill": 8, + "enemy.margin": 1.7, + "fights.default": "original", + "luck.cap": 0.5, + "meat.chance": 0.18, + "meat.drop0.author": 0.06, + "meat.drop0.scaled": 0.18, + "meat.drop1.author": 0.06, + "meat.drop1.scaled": 0.18, + "meat.killSecOriginal": 5, + "price.slip.used0": 900, + "price.slip.used10": 1537, + "price.slip.used50": 13088, + "rank.margin": 1.4, + "rank.step": 0.07, + "rate.coin_drop": 0, + "rate.skill_xp_mult": 1, + "realm1.deviationMin": 900, + "realm1.hp": 1.2, + "realm1.insight": 1, + "realm1.mult": 1.1, + "realm1.qic": 1, + "realm1.seclusionSec": 900, + "realm10.deviationMin": 131670, + "realm10.hp": 8.5, + "realm10.insight": 1580, + "realm10.mult": 6, + "realm10.qic": 110, + "realm10.seclusionSec": 131670, + "realm10.tribulation": 0.85, + "realm2.deviationMin": 900, + "realm2.hp": 1.5, + "realm2.insight": 3, + "realm2.mult": 1.3, + "realm2.qic": 8, + "realm2.seclusionSec": 900, + "realm3.deviationMin": 5000, + "realm3.hp": 1.9, + "realm3.insight": 60, + "realm3.mult": 1.55, + "realm3.qic": 18, + "realm3.seclusionSec": 5000, + "realm4.deviationMin": 8667, + "realm4.hp": 2.4, + "realm4.insight": 104, + "realm4.mult": 1.85, + "realm4.qic": 30, + "realm4.seclusionSec": 8667, + "realm5.deviationMin": 16083, + "realm5.hp": 3, + "realm5.insight": 193, + "realm5.mult": 2.2, + "realm5.qic": 45, + "realm5.seclusionSec": 16083, + "realm6.deviationMin": 20833, + "realm6.hp": 3.8, + "realm6.insight": 250, + "realm6.mult": 2.65, + "realm6.qic": 58, + "realm6.seclusionSec": 20833, + "realm7.deviationMin": 27667, + "realm7.hp": 4.7, + "realm7.insight": 332, + "realm7.mult": 3.15, + "realm7.qic": 70, + "realm7.seclusionSec": 27667, + "realm8.deviationMin": 39083, + "realm8.hp": 5.8, + "realm8.insight": 469, + "realm8.mult": 3.8, + "realm8.qic": 82, + "realm8.seclusionSec": 39083, + "realm8.tribulation": 0.55, + "realm9.deviationMin": 39083, + "realm9.hp": 7, + "realm9.insight": 469, + "realm9.mult": 4.8, + "realm9.qic": 90, + "realm9.seclusionSec": 39083, + "realm9.tribulation": 0.7, + "shop.mod_pltwr.4215": 120000, + "shop.mod_pltwr.4216": 340000, + "shop.mod_pltwr.4217": 540000, + "shop.mod_pltwr.4218": 560000, + "shop.mod_pltwr.4219": 6500000, + "shop.mod_pltwr.4222": 90000, + "shop.mod_pltwr.4223": 480000, + "shop.pha1.3002": 40, + "shop.pha1.3003": 400, + "shop.pha1.3005": 70, + "shop.pha1.3006": 20, + "shop.pha1.3007": 230, + "shop.pha1.3008": 690, + "shop.pha1.3010": 20, + "shop.pha1.3013": 6, + "shop.pha1.40043": 100, + "shop.pha1.40044": 150, + "shop.pha1.40047": 480, + "shop.pha1.40048": 600, + "shop.pha1.4211": 900, + "shop.pha1.4212": 3200, + "shop.pha1.4213": 11000, + "shop.pha1.4214": 38000, + "shop.pha1.4220": 2600, + "shop.pha1.4221": 14000, + "shop.pha1.4230": 31000, + "shop.pha1.4231": 5200, + "shop.pha1.9011": 150, + "target.t0.clg.die": 19.983, + "target.t0.clg.kill": 4, + "target.t0.frstn1a2.die": 23.723, + "target.t0.frstn1a2.kill": 6.3176, + "target.t0.frstn1a3.die": 21.682, + "target.t0.frstn1a3.kill": 7.5634, + "target.t0.frstn1a4.die": 18.347, + "target.t0.frstn1a4.kill": 10.793, + "target.t0.frstn2a2.die": 24.657, + "target.t0.frstn2a2.kill": 5.8482, + "target.t0.frstn9a1.die": 16.998, + "target.t0.frstn9a1.kill": 9.9988, + "target.t0.hmbsmnt.die": 19.323, + "target.t0.hmbsmnt.kill": 4.1653, + "target.t0.mod_hollow.die": 16.89, + "target.t0.mod_hollow.kill": 4.5055, + "target.t0.mod_rank1.die": 48.926, + "target.t0.mod_rank1.kill": 34.947, + "target.t0.mod_rank10.die": 33.565, + "target.t0.mod_rank10.kill": 19.744, + "target.t0.mod_rank2.die": 42.942, + "target.t0.mod_rank2.kill": 30.673, + "target.t0.mod_rank3.die": 48.487, + "target.t0.mod_rank3.kill": 34.634, + "target.t0.mod_rank4.die": 42.67, + "target.t0.mod_rank4.kill": 30.478, + "target.t0.mod_rank5.die": 27.055, + "target.t0.mod_rank5.kill": 19.325, + "target.t0.mod_rank6.die": 38.495, + "target.t0.mod_rank6.kill": 27.497, + "target.t0.mod_rank7.die": 39.443, + "target.t0.mod_rank7.kill": 28.174, + "target.t0.mod_rank8.die": 25.742, + "target.t0.mod_rank8.kill": 18.387, + "target.t0.mod_rank9.die": 23.579, + "target.t0.mod_rank9.kill": 15.352, + "target.t0.mod_spire.die": 27.391, + "target.t0.mod_spire.kill": 4.5494, + "target.t0.mod_vigil.die": 27.41, + "target.t0.mod_vigil.kill": 4.5432, + "target.t0.trn.die": 35.104, + "target.t0.trn.kill": 4, + "target.t0.trn1.die": 21.66, + "target.t0.trn1.kill": 7.2754, + "target.t0.trn2.die": 26.667, + "target.t0.trn2.kill": 4.8, + "target.t0.trn3.die": 23.549, + "target.t0.trn3.kill": 6.1551, + "target.t0.trne1.die": 18.471, + "target.t0.trne1.kill": 9.8043, + "target.t0.trne2.die": 18.5, + "target.t0.trne2.kill": 9.7735, + "target.t0.trne3.die": 18.51, + "target.t0.trne3.kill": 9.7636, + "target.t0.trne4.die": 18.529, + "target.t0.trne4.kill": 9.7433, + "target.t0.trnf.die": 35.104, + "target.t0.trnf.kill": 4, + "target.t5.clg.die": 19.983, + "target.t5.clg.kill": 4, + "target.t5.frstn1a2.die": 23.723, + "target.t5.frstn1a2.kill": 6.3176, + "target.t5.frstn1a3.die": 21.682, + "target.t5.frstn1a3.kill": 7.5634, + "target.t5.frstn1a4.die": 18.347, + "target.t5.frstn1a4.kill": 10.793, + "target.t5.frstn2a2.die": 24.657, + "target.t5.frstn2a2.kill": 5.8482, + "target.t5.frstn9a1.die": 16.998, + "target.t5.frstn9a1.kill": 9.9988, + "target.t5.hmbsmnt.die": 19.323, + "target.t5.hmbsmnt.kill": 4.1653, + "target.t5.mod_hollow.die": 16.89, + "target.t5.mod_hollow.kill": 4.5055, + "target.t5.mod_rank1.die": 48.926, + "target.t5.mod_rank1.kill": 34.947, + "target.t5.mod_rank10.die": 33.565, + "target.t5.mod_rank10.kill": 19.744, + "target.t5.mod_rank2.die": 42.942, + "target.t5.mod_rank2.kill": 30.673, + "target.t5.mod_rank3.die": 48.487, + "target.t5.mod_rank3.kill": 34.634, + "target.t5.mod_rank4.die": 42.67, + "target.t5.mod_rank4.kill": 30.478, + "target.t5.mod_rank5.die": 27.055, + "target.t5.mod_rank5.kill": 19.325, + "target.t5.mod_rank6.die": 38.495, + "target.t5.mod_rank6.kill": 27.497, + "target.t5.mod_rank7.die": 39.443, + "target.t5.mod_rank7.kill": 28.174, + "target.t5.mod_rank8.die": 25.742, + "target.t5.mod_rank8.kill": 18.387, + "target.t5.mod_rank9.die": 23.579, + "target.t5.mod_rank9.kill": 15.352, + "target.t5.mod_spire.die": 27.391, + "target.t5.mod_spire.kill": 4.5494, + "target.t5.mod_vigil.die": 27.41, + "target.t5.mod_vigil.kill": 4.5432, + "target.t5.trn.die": 35.104, + "target.t5.trn.kill": 4, + "target.t5.trn1.die": 21.66, + "target.t5.trn1.kill": 7.2754, + "target.t5.trn2.die": 26.667, + "target.t5.trn2.kill": 4.8, + "target.t5.trn3.die": 23.549, + "target.t5.trn3.kill": 6.1551, + "target.t5.trne1.die": 18.471, + "target.t5.trne1.kill": 9.8043, + "target.t5.trne2.die": 18.5, + "target.t5.trne2.kill": 9.7735, + "target.t5.trne3.die": 18.51, + "target.t5.trne3.kill": 9.7636, + "target.t5.trne4.die": 18.529, + "target.t5.trne4.kill": 9.7433, + "target.t5.trnf.die": 35.104, + "target.t5.trnf.kill": 4, + "target.t9.clg.die": 19.983, + "target.t9.clg.kill": 4, + "target.t9.frstn1a2.die": 23.723, + "target.t9.frstn1a2.kill": 6.3176, + "target.t9.frstn1a3.die": 21.682, + "target.t9.frstn1a3.kill": 7.5634, + "target.t9.frstn1a4.die": 18.347, + "target.t9.frstn1a4.kill": 10.793, + "target.t9.frstn2a2.die": 24.657, + "target.t9.frstn2a2.kill": 5.8482, + "target.t9.frstn9a1.die": 16.998, + "target.t9.frstn9a1.kill": 9.9988, + "target.t9.hmbsmnt.die": 19.323, + "target.t9.hmbsmnt.kill": 4.1653, + "target.t9.mod_hollow.die": 16.89, + "target.t9.mod_hollow.kill": 4.5055, + "target.t9.mod_rank1.die": 48.926, + "target.t9.mod_rank1.kill": 34.947, + "target.t9.mod_rank10.die": 33.565, + "target.t9.mod_rank10.kill": 19.744, + "target.t9.mod_rank2.die": 42.942, + "target.t9.mod_rank2.kill": 30.673, + "target.t9.mod_rank3.die": 48.487, + "target.t9.mod_rank3.kill": 34.634, + "target.t9.mod_rank4.die": 42.67, + "target.t9.mod_rank4.kill": 30.478, + "target.t9.mod_rank5.die": 27.055, + "target.t9.mod_rank5.kill": 19.325, + "target.t9.mod_rank6.die": 38.495, + "target.t9.mod_rank6.kill": 27.497, + "target.t9.mod_rank7.die": 39.443, + "target.t9.mod_rank7.kill": 28.174, + "target.t9.mod_rank8.die": 25.742, + "target.t9.mod_rank8.kill": 18.387, + "target.t9.mod_rank9.die": 23.579, + "target.t9.mod_rank9.kill": 15.352, + "target.t9.mod_spire.die": 27.391, + "target.t9.mod_spire.kill": 4.5494, + "target.t9.mod_vigil.die": 27.41, + "target.t9.mod_vigil.kill": 4.5432, + "target.t9.trn.die": 35.104, + "target.t9.trn.kill": 4, + "target.t9.trn1.die": 21.66, + "target.t9.trn1.kill": 7.2754, + "target.t9.trn2.die": 26.667, + "target.t9.trn2.kill": 4.8, + "target.t9.trn3.die": 23.549, + "target.t9.trn3.kill": 6.1551, + "target.t9.trne1.die": 18.471, + "target.t9.trne1.kill": 9.8043, + "target.t9.trne2.die": 18.5, + "target.t9.trne2.kill": 9.7735, + "target.t9.trne3.die": 18.51, + "target.t9.trne3.kill": 9.7636, + "target.t9.trne4.die": 18.529, + "target.t9.trne4.kill": 9.7433, + "target.t9.trnf.die": 35.104, + "target.t9.trnf.kill": 4, + "value.mod_bp1": 390, + "value.mod_bp10": 2117500, + "value.mod_bp2": 1014, + "value.mod_bp3": 2636, + "value.mod_bp4": 6855, + "value.mod_bp5": 17822, + "value.mod_bp6": 46337, + "value.mod_bp7": 120480, + "value.mod_bp8": 313240, + "value.mod_bp9": 814430, + "value.mod_rootpill": 26000, + "value.mod_settlepill": 4200, + "value.mod_slip": 225 +} diff --git a/tests/capreach.mjs b/tests/capreach.mjs new file mode 100644 index 0000000..278d6a2 --- /dev/null +++ b/tests/capreach.mjs @@ -0,0 +1,87 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Can a skill actually REACH the story cap? Measures the game's own exp curve +// against the xp a skill really receives per tick through the mod's grant path +// (skill_xp_mult, parent pull, milestone xpBonus), for each cap tier. +// PORT=8080 node tests/capreach.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +const R = await p.evaluate(() => { + const probe = skl.wsdm; // a plain counter-driven added skill + + // ---- total xp to reach level L from 0, using the game's own curve -------- + const totalTo = (L) => { let t = 0; const s = probe; const keep = s.lvl; + for (let l = 0; l < L; l++) { s.lvl = l; t += s.expnext(); } + s.lvl = keep; return t; }; + + // ---- what does a skill ACTUALLY receive per tick? ----------------------- + // intercept the uncapped grant so we see post-multiplier, post-parent xp + let got = 0; + const realGrant = MOD_giveSkExp_uncapped; + MOD_giveSkExp_uncapped = function (sk, exp, res) { if (sk === probe) got += exp; return realGrant(sk, exp, res); }; + const perTick = (nominal) => { got = 0; probe.lvl = 5; giveSkExp(probe, nominal); return got; }; + const measured = { nominal_1: perTick(1), nominal_0_2: perTick(0.2) }; + MOD_giveSkExp_uncapped = realGrant; + + // rates actually used by the mod's own tick, per skill archetype + const rates = MOD_EXTRA.map(d => ({ + key: d.key, name: d.name, + perGrant: d.xp, // xp per counter-delta or per tick + driver: d.counter ? ('counter ' + d.counter + (d.cap ? ' (cap ' + d.cap + '/tick)' : '')) : ('pred ' + d.pred) + })); + + // effective xp per second for a *well-played* skill: predicate skills fire + // every tick; counter skills fire on their per-tick cap when you are active. + const eff = MOD_EXTRA.map(d => { + const perEvent = d.xp * MOD.skill_xp_mult; + const best = d.counter ? perEvent * (d.cap || 1) : perEvent; // optimistic + return { key: d.key, bestPerTick: best }; + }); + + const curve = MOD_TIERS.map(t => ({ cap: t.cap, name: t.name, totalXp: totalTo(t.cap) })); + + return { curve, measured, rates, eff, + xpMult: MOD.skill_xp_mult, maxSpeed: MOD.max_speed, + singleLevelAt: [10, 20, 30, 50, 80, 109].map(l => { + const keep = probe.lvl; probe.lvl = l; const c = probe.expnext(); probe.lvl = keep; + return { fromLevel: l, cost: c }; }) }; +}); + +const fmt = n => n >= 1e6 ? n.toExponential(2) : Math.round(n).toLocaleString(); +const hours = s => s / 3600; +const human = h => h < 24 ? h.toFixed(1) + ' h' + : h < 24 * 365 ? (h / 24).toFixed(1) + ' days' + : (h / 24 / 365).toExponential(2) + ' years'; + +console.log(`skill xp multiplier: ${R.xpMult}x max game speed: ${R.maxSpeed}x`); +console.log(`grant path check: nominal 1.0 xp arrives as ${R.measured.nominal_1}; nominal 0.2 arrives as ${R.measured.nominal_0_2}`); + +console.log('\n=========== COST OF A SINGLE LEVEL ==========='); +R.singleLevelAt.forEach(s => console.log(` lv ${String(s.fromLevel).padStart(3)} -> ${String(s.fromLevel + 1).padStart(3)} ${fmt(s.cost)} xp`)); + +console.log('\n=========== TOTAL XP TO REACH EACH CAP, AND TIME TO GET THERE ==========='); +console.log(' (best-case xp/tick per skill, 1 tick/s; "at 20x" = max game speed)'); +console.log(' cap total xp best xp/s time @1x time @20x'); +const best = Math.max(...R.eff.map(e => e.bestPerTick)); +R.curve.forEach(c => { + const t1 = hours(c.totalXp / best), t20 = hours(c.totalXp / (best * R.maxSpeed)); + console.log(` ${String(c.cap).padStart(3)} ${fmt(c.totalXp).padStart(14)} ${best.toFixed(2).padStart(10)} ${human(t1).padStart(14)} ${human(t20).padStart(14)} ${c.name}`); +}); + +console.log('\n=========== TIME FOR EACH SKILL TO REACH LEVEL 110 ==========='); +const to110 = R.curve[R.curve.length - 1].totalXp; +console.log(' skill xp/tick @1x @20x'); +R.eff.sort((a, b) => b.bestPerTick - a.bestPerTick).forEach(e => { + const h1 = hours(to110 / e.bestPerTick), h20 = h1 / R.maxSpeed; + const mo = h => (h / 24 / 30.44).toFixed(1) + ' mo'; + console.log(` ${e.key.padEnd(6)} ${e.bestPerTick.toFixed(2).padStart(8)} ${human(h1).padStart(12)} (${mo(h1).padStart(7)}) ${human(h20).padStart(12)}`); +}); + +console.log('\nerrors:', errs.length ? errs : 'none'); +await b.close(); diff --git a/tests/catchup.mjs b/tests/catchup.mjs new file mode 100644 index 0000000..ac39698 --- /dev/null +++ b/tests/catchup.mjs @@ -0,0 +1,63 @@ +import { launch, settle } from './lib/browser.mjs'; + +// A hidden tab throttles the game's chained setTimeout loop to once a minute, so +// the mod replays the missed ticks (and the running action's seconds) when the +// loop next runs. Combat is never replayed. Time away is faked by moving the +// loop's "last tick" back, which is what a throttled tab looks like from inside. +// +// PORT=8080 node tests/catchup.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await (await b.newContext()).newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +const fail = []; +const check = (c, w) => { if (!c) fail.push(w); console.log(` ${c ? 'ok ' : 'FAIL'} ${w}`); }; + +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +// Run one tick by hand after pretending `away` seconds went by. +const away = (sec, opts = {}) => p.evaluate(({ sec, opts }) => { + const a = { active: true, uses: 0, use() { this.uses++; } }; + const keep = global.current_a; + if (opts.action) global.current_a = a; + const t0 = time.minute; + MOD_CATCHUP.running = false; + MOD_CATCHUP.last = Date.now() - sec * 1000; + if (opts.battle) global.flags.btl = true; + ontick(); + const out = { dMin: time.minute - t0, uses: a.uses, replayed: MOD_CATCHUP.replayed }; + global.current_a = keep; global.flags.btl = false; + return out; +}, { sec, opts }); + +console.log('--- a normal tick replays nothing'); +let r = await away(1); +check(r.dMin === 1, `one second away is one tick (${r.dMin})`); + +console.log('\n--- ten minutes away'); +r = await away(600, { action: true }); +check(r.dMin >= 599 && r.dMin <= 601, `the clock moves the ten minutes (${r.dMin} ticks)`); +check(r.uses >= 598 && r.uses <= 600, `and the running action gets its seconds (${r.uses})`); + +console.log('\n--- capped'); +r = await away(86400); +check(r.dMin <= 3601, `a day away replays the cap, an hour, not a day (${r.dMin})`); + +console.log('\n--- never replays combat'); +r = await away(600, { action: true, battle: true }); +check(r.uses === 0 && r.dMin > 500, `in a fight the world moves (${r.dMin}) but the action is not replayed (${r.uses})`); + +console.log('\n--- off'); +await p.evaluate(() => setCatchUp(0)); +r = await away(600); +check(r.dMin === 1, `setCatchUp(0) turns it off (${r.dMin})`); +check(await p.evaluate(() => localStorage.getItem('p23_mod_catchup')) === '0', 'and it persists'); +check(await p.evaluate(() => MOD_settingsSnapshot().catchup) === 0, 'and travels in the save'); +await p.evaluate(() => setCatchUp(3600)); + +check(errs.length === 0, 'no page errors ' + errs.join(' | ')); +await b.close(); +console.log(fail.length ? `\nFAILED (${fail.length})` : '\nall passed'); +process.exit(fail.length ? 1 : 0); diff --git a/tests/combat.mjs b/tests/combat.mjs new file mode 100644 index 0000000..3a3f729 --- /dev/null +++ b/tests/combat.mjs @@ -0,0 +1,197 @@ +import { launch, settle } from './lib/browser.mjs'; + +// The whole story ladder, measured through the game's OWN combat math. +// +// realbal/tierfit judge a fight by the ratio enemyHP/playerSTR and playerHP/enemySTR. +// That proxy is wrong, because dmg_calc is SUBTRACTIVE: an attack does +// attacker.str - defender.str (+ weapon, +1, crits) +// so the defender's STR is armour. Multiplying an enemy's STR raises its offence +// AND its armour at the same time, and once the armour term passes the attacker's +// STR the damage does not get small, it goes to zero. This script drives dmg_calc +// and hit_calc directly and reports what actually happens. +// +// PORT=8080 node tests/combat.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); +// section 8's model is what this measures, so measure it everywhere: the +// default (Fights: Original) leaves the author's areas unscaled -- fights.mjs +await p.evaluate(() => setFights('scaled')); + +const R = await p.evaluate(() => { + const SAMPLES = 200; + + const freshYou = () => { + you.lvl = 1; + you.str_r = 1; you.agl_r = 1; you.int_r = 1; you.spd_r = 1; + you.hp_r = 39; you.hpmax = 39; you.hp = 39; + you.sat_r = 200; you.satmax = 200; you.sat = 200; + you.stra = you.agla = you.inta = you.spda = you.hpa = you.sata = 0; + you.strm = you.aglm = you.intm = you.spdm = you.hpm = you.satm = 1; + you.stat_p = [1, 1, 1, 1]; + you.mods.sbonus = 0; you.mods.cpwr = 1; + for (const k in skl) { const s = skl[k]; + if (!s || typeof s !== 'object') continue; + s.lvl = 0; s.exp = 0; if (s.mlstn) s.mlstn.forEach(m => { m.g = false; }); } + Object.keys(ttl).forEach(k => { ttl[k].have = false; }); + try { MOD_updateRenown(); } catch (e) {} + you.stat_r(); + }; + const neutral = () => { + global.flags.inside = true; global.flags.isday = true; global.flags.btl = false; + global.flags.iscold = false; global.flags.iswet = false; global.flags.isdark = false; + you.hp = you.hpmax; you.sat = you.satmax; + }; + const setTier = (ti) => { + global.flags.tr3_win = ti >= 1; + global.flags.mod_t_forest = ti >= 2; global.flags.mod_t_deep = ti >= 3; + global.flags.mod_t_cata = ti >= 4; global.flags.trne1e1 = ti >= 5; global.flags.trne3e1 = ti >= 6; + global.flags.mod_prog = { hollow: ti >= 7 ? 99 : 0, spire: ti >= 8 ? 99 : 0, vigil: ti >= 9 ? 99 : 0 }; + MOD_CAP.current = -1; return MOD_levelCap(); + }; + // a player who has actually played to that tier: character level from the game's + // own lvlup, skills sitting at the tier cap + const buildPlayer = (ti, charLvl) => { + freshYou(); const cap = setTier(ti); + for (let i = 1; i < charLvl; i++) { try { lvlup(you, 1); } catch (e) {} } + for (const k in skl) { const s = skl[k]; + if (!s || typeof s !== 'object' || k === 'rnwn') continue; + s.lvl = cap; + if (s.mlstn) s.mlstn.forEach(m => { m.g = false; + if (m.lv <= cap) { try { m.f(); m.g = true; } catch (e) {} } }); } + you.stat_r(); neutral(); allbuff(you); + return { cap, str: you.str, hp: you.hpmax, agl: you.agl }; + }; + + // dmg_calc grants skill exp (seye on a crit, the affinity skills on incoming + // damage) and can set the crit flag. Sampling it thousands of times would + // level the player mid-sweep and quietly invalidate everything measured after + // — the mod's own MOD_scaleEnemy parks these for the same reason. + const quiet = (fn) => { + const g = giveSkExp, c = global.flags.crti; + giveSkExp = function () {}; + try { return fn(); } finally { giveSkExp = g; global.flags.crti = c; } + }; + + // global.current_z is the anchor MOD_scaleEnemy measures a spawn against, + // so it has to be the area the creature is coming out of + // Your damage is two tight clusters, not one spread: a normal swing varies by + // +-10%, a crit runs about 12x that at cap 110 and lands a fifth of the time. + // A plain sample mean is therefore mostly measuring "how many crits happened", + // and reports balance failures that are really estimator noise. Averaging the + // two strata separately and recombining with the crit rate the game will + // actually roll against removes that. Written out here rather than calling the + // mod's MOD_meanDamage, so the test is not just agreeing with itself. + // Tops up until both strata have shown. Falling back to a plain mean when n + // swings roll no crit DROPS the crits rather than averaging them -- they are + // ~30% of all damage at cap 30 -- so a zero-crit sample (0.3% at a 9% crit + // rate and 60 swings) read a fight ~40% long. Across 2,790 matchups that was + // often enough to fail a thin-margin rank duel on a coin flip: the + // intermittent allareas failure. Extra swings only when needed. + const meanDamage = (att, def, n) => { + let critN = 0, critSum = 0, plainN = 0, plainSum = 0, zeros = 0, i = 0; + const rate = MOD_critRate(att); + for (; i < n * 8; i++) { + if (i >= n && (critN >= 2 || rate <= 0) && plainN >= 2) break; + global.flags.crti = false; + const d = Math.max(0, Math.round(abl.default.f(att, def))); + if (d <= 0) zeros++; + if (global.flags.crti) { critN++; critSum += d; } else { plainN++; plainSum += d; } + } + const mean = (critN && plainN) + ? (1 - rate) * (plainSum / plainN) + rate * (critSum / critN) + : (critSum + plainSum) / i; + return { mean, zeroPct: zeros / i * 100 }; + }; + + const matchup = (z, crt, lvl) => { + global.current_z = z; + const m = mon_gen(crt); lvlup(m, lvl); + global.current_m = m; try { update_m(); } catch (e) {} + global.target = you.eqp[2]; global.t_n = 2; + allbuff(you); allbuff(m); + const hitYou = Math.max(0, Math.min(100, hit_calc(1))) / 100; + const hitMon = Math.max(0, Math.min(100, hit_calc(2))) / 100; + let dOut = 0, dIn = 0, zOut = 0, zIn = 0; + quiet(() => { + const out = meanDamage(you, m, SAMPLES); + const inc = meanDamage(m, you, SAMPLES); + dOut = out.mean; dIn = inc.mean; zOut = out.zeroPct; zIn = inc.zeroPct; + }); + const oOut = dOut * hitYou, oIn = dIn * hitMon; + return { name: m.name, lvl, + ehp: Math.round(m.hpmax), estr: Math.round(m.str), + hitYou: Math.round(hitYou * 100), hitMon: Math.round(hitMon * 100), + dOut: +dOut.toFixed(1), dIn: +dIn.toFixed(1), + whiffOut: Math.round(zOut), whiffIn: Math.round(zIn), + kill: oOut > 0 ? Math.ceil(m.hpmax / oOut) : Infinity, + die: oIn > 0 ? Math.ceil(you.hpmax / oIn) : Infinity }; + }; + + // tier index, representative area, the character level a player plausibly has there + const LADDER = [ + [0, 'trn1', 1, 'Tutorial'], + [0, 'frstn1a2', 6, 'Western forest'], + [1, 'frstn1a3', 12, 'W forest grind'], + [2, 'hmbsmnt', 18, 'Basement'], + [3, 'frstn9a1', 24, 'Southern forest'], + [4, 'trne1', 32, 'Golem arena I'], + [5, 'trne2', 40, 'Golem arena II'], + [6, 'trne4', 50, 'Golem arena IV'], + [7, 'mod_hollow', 62, 'Sunken Hollow'], + [8, 'mod_spire', 76, 'Ashen Spire'], + [9, 'mod_vigil', 92, 'Long Vigil'] + ]; + + // every creature in the area, at both ends of its level band — one pop[0] + // sample hides exactly the spread this model is supposed to produce + const rows = LADDER.map(([ti, ak, charLvl, nm]) => { + if (!area[ak]) return { nm, ak, missing: true }; + const P = buildPlayer(ti, charLvl); + const fs = []; + area[ak].pop.forEach(e => { + fs.push(matchup(area[ak], e.crt, e.lvlmin)); + if (e.lvlmax !== e.lvlmin) fs.push(matchup(area[ak], e.crt, e.lvlmax)); + }); + return { nm, ak, ti, cap: P.cap, charLvl, + pstr: Math.round(P.str), php: Math.round(P.hp), fs }; + }); + + setTier(0); + return { rows, enemy: JSON.parse(JSON.stringify(MOD_ENEMY)) }; +}); + +const pad = (s, n) => String(s).padStart(n); +const inf = v => (v === Infinity ? 'never' : String(v)); + +console.log(`targets: kill ${R.enemy.kill}, die ${R.enemy.die}, enemy hit rate ${R.enemy.hit}, ` + + `spread hp^${R.enemy.hpSpread} atk^${R.enemy.atkSpread}`); +console.log('\n kill = swings to kill it, die = swings until it kills you (both include miss chance)'); +console.log(' whiff = share of LANDED hits that deal zero, because the defender\'s STR ate the damage\n'); +console.log(' tier area cap clv you STR you HP | n | kill lo-hi | die lo-hi | worst matchup'); +const fails = []; +R.rows.forEach(r => { + if (r.missing) { console.log(` ${r.nm}: MISSING (${r.ak})`); return; } + const ks = r.fs.map(f => f.kill), ds = r.fs.map(f => f.die); + const kmin = Math.min(...ks), kmax = Math.max(...ks); + const dmin = Math.min(...ds), dmax = Math.max(...ds); + const worst = r.fs.reduce((a, f) => (f.die < a.die ? f : a), r.fs[0]); + const bad = dmin <= 4 || kmax > 60 || r.fs.some(f => f.whiffOut > 25); + console.log(` ${bad ? '!!' : ' '}${pad(r.ti, 2)} ${r.nm.padEnd(17)} ${pad(r.cap, 3)} ${pad(r.charLvl, 4)} ` + + `${pad(r.pstr.toLocaleString(), 10)} ${pad(r.php.toLocaleString(), 9)} | ${pad(r.fs.length, 2)} | ` + + `${pad(inf(kmin) + '-' + inf(kmax), 12)} | ${pad(inf(dmin) + '-' + inf(dmax), 12)} | ` + + `${worst.name.slice(0, 14).padEnd(14)} lv${pad(worst.lvl, 3)} kill ${inf(worst.kill)} die ${inf(worst.die)} whiff ${worst.whiffOut}%`); + if (bad) fails.push(`${r.nm}: kill ${kmin}-${kmax}, die ${dmin}-${dmax}`); +}); +console.log('\nerrors:', errs.length ? errs : 'none'); +await b.close(); + +if (fails.length) { + console.error('\nFAIL — these are coin flips or slogs:\n - ' + fails.join('\n - ')); + process.exit(1); +} +console.log('PASS — every tier is a real fight: nothing kills in under 5 swings, nothing takes over 60.'); diff --git a/tests/convergence.mjs b/tests/convergence.mjs new file mode 100644 index 0000000..0290864 --- /dev/null +++ b/tests/convergence.mjs @@ -0,0 +1,247 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Skill rarity, and the convergence. +// +// Two features, one test, because they share a page and a screen. +// +// RARITY is derived: the rank of the level you have taken a skill to, through +// the same MOD_RANK_AT thresholds that rank titles. Nothing to keep in step. +// +// CONVERGENCE is the load-bearing one. Folding a skill HIDES it rather than +// removing it from you.skls, and that is not an implementation detail -- it is +// the whole reason the feature is safe: +// +// * the game's skill panel only rebuilds when you.skls GROWS +// (`let sklsize=you.skls.length; ... if(sklsize errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +console.log('--- the ten converged skills'); +const conv = await p.evaluate(() => { + const rows = []; + for (let s = 1; s <= 10; s++) { + const sk = MOD_CONVERGED[s]; + rows.push({ + s, id: sk.id, name: sk.name, type: sk.type, + milestones: (sk.mlstn || []).length, + capMapped: MOD_KEY_BY_ID[sk.id] === 'mod_conv' + s, + grouped: MOD_PARENT_OF['mod_conv' + s] === MOD_PARENT_KEY[s], + section: MOD_sectionOf(sk) === s + }); + } + // ids unique across the whole of skl + const seen = {}, dup = []; + for (const k in skl) { const i = skl[k].id; if (seen[i]) dup.push(i); seen[i] = k; } + // exp and p are saved POSITIONALLY over `for..in skl`, so the aggregates must + // sit at the END of skl or they shift every slot after them in an old save. + const keys = Object.keys(skl); + const convAt = keys.map((k, i) => skl[k]._modConverged ? i : -1).filter(i => i >= 0); + const tail = convAt.length === 10 && Math.min(...convAt) === keys.length - 10; + // an aggregate must take no titles -- section 24 skips anything without mlstn + let titles = 0; + for (const k in ttl) if (/^mod_t_mod_conv/.test(k)) titles++; + return { rows, dup, tail, titles, total: keys.length }; +}); +conv.rows.forEach(r => console.log(` ${r.s} ${r.id} ${r.name}`)); +check(conv.rows.length === 10, 'one per section (10)'); +check(conv.rows.every(r => r.section && r.type === r.s), 'each lands in its own section'); +check(conv.dup.length === 0, `no id clashes anywhere in skl (${conv.dup.join(',') || 'none'})`); +check(conv.rows.every(r => r.capMapped), 'all ten are registered with the cap map'); +check(conv.rows.every(r => r.grouped), 'and with the skill-panel grouping map'); +check(conv.rows.every(r => r.milestones === 0), 'none has a perk ladder — they are not trained'); +check(conv.titles === 0, `and none generates titles (${conv.titles})`); +check(conv.tail, 'all ten sit at the END of skl, where positional exp slots only append'); + +console.log('\n--- rarity is derived, not assigned'); +const rar = await p.evaluate(() => { + const out = MOD_RANK_AT.map(lv => { skl.fgt.lvl = lv; return MOD_skillRarityName(skl.fgt); }); + skl.fgt.lvl = 110; + const top = { rank: MOD_skillRank(skl.fgt), viaTitle: MOD_rankForLevel(110) }; + skl.fgt.lvl = 1; + return { names: MOD_SKILL_RAR.slice(), out, top, styles: MOD_SKILL_RAR.every((n, i) => !!MOD_rankStyle(i + 1).c) }; +}); +console.log(' ' + rar.out.join(' < ')); +check(rar.names.length === 10, `ten rarities for the ten ranks (${rar.names.length})`); +check(rar.top.rank === rar.top.viaTitle, + `rarity comes off MOD_rankForLevel, the same call titles use (both ${rar.top.rank})`); +check(new Set(rar.out).size === 10, 'each MOD_RANK_AT threshold lands on a different rarity'); +check(rar.styles, 'and every rank has a colour to paint with'); + +console.log('\n--- folding costs a slip, lowest level first'); +const fold = await p.evaluate(() => { + global.flags.mod_folded = {}; global.flags.mod_slipsused = 0; + // Upkeep: the smallest section, 3 skills + const before = MOD_sectionFold(9); + const none = MOD_fold(9); // no slips in hand + giveItem(item.mod_slip, 2); + const order = MOD_sectionSkills(9).slice() + .sort((a, c) => (a.skill.lvl || 0) - (c.skill.lvl || 0)).map(e => e.key); + const did = MOD_fold(9, 2); + const map = MOD_foldedMap(); + return { + before, none, did, held: MOD_slipsHeld(), used: MOD_slipsUsed(), + after: MOD_sectionFold(9), + tookLowestTwo: map[order[0]] === 1 && map[order[1]] === 1 && map[order[2]] !== 1, + converged: MOD_sectionConverged(9), + onSheet: you.skls.indexOf(MOD_CONVERGED[9]) >= 0 + }; +}); +check(fold.none === 0, 'folding with no slips in hand is refused'); +check(fold.did === 2 && fold.after.folded === 2, `two slips folded two skills (${fold.did})`); +check(fold.held === 0 && fold.used === 2, `and both slips were spent (${fold.held} left)`); +check(fold.tookLowestTwo, 'it took the two lowest-level skills, not the first it found'); +check(!fold.converged, 'a partly folded section is not converged'); +check(fold.onSheet, 'the converged skill is on the sheet as soon as anything folds'); + +console.log('\n--- a fold never shortens you.skls'); +const grow = await p.evaluate(() => { + const before = you.skls.length; + // Only the skills that were ON the sheet can be preserved -- a skill enters + // you.skls the first time it levels, so most sit at level 0 and were never + // there. The claim is that folding REMOVES nothing, not that everything is + // present. + const wasListed = MOD_sectionSkills(9).filter(e => you.skls.indexOf(e.skill) >= 0) + .map(e => e.key); + giveItem(item.mod_slip, 3); + MOD_fold(9); // finish Upkeep + const after = you.skls.length; + const keptListed = wasListed.every(k => you.skls.indexOf(skl[k]) >= 0); + const hidden = MOD_sectionSkills(9).every(e => MOD_isFolded(e.skill)); + return { before, after, wasListed: wasListed.length, keptListed, hidden, + converged: MOD_sectionConverged(9) }; +}); +check(grow.after >= grow.before, + `you.skls did not shrink across a fold (${grow.before} -> ${grow.after})`); +check(grow.keptListed, + `every folded skill that was on the sheet is still on it (${grow.wasListed} were) — hidden, not removed`); +check(grow.hidden, 'and every one of them reports as folded, so the panel hides the row'); +check(grow.converged, 'the section is converged once the last one folds'); + +console.log('\n--- a folded skill keeps levelling, and the fold survives it'); +const lvl = await p.evaluate(() => { + const e = MOD_sectionSkills(9)[0]; + const was = e.skill.lvl; + giveSkExp(e.skill, 1e7); // giveSkExp re-pushes on level-up + return { was, now: e.skill.lvl, stillFolded: MOD_isFolded(e.skill), + inList: you.skls.filter(s => s === e.skill).length }; +}); +check(lvl.now > lvl.was, `it still levels while folded (${lvl.was} -> ${lvl.now})`); +check(lvl.stillFolded, "and giveSkExp's re-add does not unfold it"); +check(lvl.inList === 1, 'and it is in you.skls exactly once, not twice'); + +console.log('\n--- folding costs nothing mechanical: all of the buffs still apply'); +const buffs = await p.evaluate(() => { + global.flags.mod_folded = {}; + you.stat_r(); allbuff(you); + const open = { str: you.str, int: you.int, agl: you.agl, spd: you.spd, hp: you.hpmax }; + // fold everything, every section + for (let s = 1; s <= 10; s++) { + const all = MOD_sectionSkills(s), map = MOD_foldedMap(); + all.forEach(e => { map[e.key] = 1; }); + } + MOD_refreshConverged(); + you.stat_r(); allbuff(you); + const folded = { str: you.str, int: you.int, agl: you.agl, spd: you.spd, hp: you.hpmax }; + const bonus = MOD_convergenceMult(); + // every stat must be exactly the open value times the convergence bonus -- + // nothing lost, nothing double-counted + const exact = ['str', 'int', 'agl', 'spd'].every(k => + Math.abs(folded[k] - open[k] * bonus) < Math.max(open[k] * 1e-9, 1e-6)); + return { open, folded, bonus, exact, hpSame: folded.hp === open.hp }; +}); +console.log(` STR ${buffs.open.str.toFixed(2)} -> ${buffs.folded.str.toFixed(2)} ` + + `(convergence x${buffs.bonus.toFixed(4)})`); +check(buffs.exact, + 'every stat is exactly its unfolded value times the convergence bonus — no buff was lost'); +check(buffs.bonus > 1, `converging all ten sections is worth something (x${buffs.bonus.toFixed(4)})`); +check(buffs.hpSame, 'and convergence deliberately leaves the body alone'); + +console.log('\n--- the bonus does not compound over refreshes or reloads'); +const stable = await p.evaluate(() => { + you.stat_r(); allbuff(you); + const one = you.str; + for (let i = 0; i < 200; i++) allbuff(you); + const many = you.str; + const bonusBefore = MOD_convergenceMult(); + const blob = save(); + let threw = ''; + try { load(blob); } catch (e) { threw = String(e).slice(0, 120); } + MOD_refreshConverged(); + you.stat_r(); allbuff(you); + // The claim is about the BONUS, not about you.str: load() re-fires every + // milestone and re-seeds the parent skills, so the absolute stat legitimately + // moves for reasons that have nothing to do with convergence. What must not + // happen is the multiplier itself growing across a reload -- the way you.res + // once did -- and that is what this measures. + return { one, many, threw, bonusBefore, bonusAfter: MOD_convergenceMult(), + foldedKept: Object.keys(MOD_foldedMap()).length }; +}); +check(Math.abs(stable.many - stable.one) < Math.max(stable.one * 1e-9, 1e-6), + `200 allbuff refreshes do not stack it (drift ${(stable.many - stable.one).toExponential(1)})`); +check(!stable.threw, `save/load round-trips (${stable.threw || 'clean'})`); +check(stable.foldedKept > 0, `the folded state survives a reload (${stable.foldedKept} skills)`); +check(stable.bonusAfter === stable.bonusBefore, + `the bonus is the same size after the reload, not doubled ` + + `(x${stable.bonusBefore.toFixed(4)} -> x${stable.bonusAfter.toFixed(4)})`); + +console.log('\n--- unfolding puts it all back'); +const undo = await p.evaluate(() => { + const n = MOD_unfold(9); + return { n, left: MOD_sectionFold(9), anyFolded: MOD_sectionSkills(9).some(e => MOD_isFolded(e.skill)), + offSheet: you.skls.indexOf(MOD_CONVERGED[9]) === -1 }; +}); +check(undo.n > 0 && undo.left.folded === 0, `unfold restores the section (${undo.n} skills)`); +check(!undo.anyFolded, 'and nothing there reports as folded any more'); +check(undo.offSheet, 'the converged skill leaves the sheet with it'); + +console.log('\n--- the Archive, and the slip economy'); +const shop = await p.evaluate(() => { + global.flags.mod_slipsused = 0; + const p0 = MOD_slipPrice(); + global.flags.mod_slipsused = 40; + const p40 = MOD_slipPrice(); + global.flags.mod_slipsused = 100000; + const capped = MOD_slipPrice(); + global.flags.mod_slipsused = 0; + // the screen draws, and its choice lines fit the 22px row + global.flags.dj1rw6 = true; global.flags.dj1end = true; + chss.mod_slips.sl(); + const rows = [].slice.call(dom.ctr_2.children).map(c => c.textContent.trim()); + const tall = [].slice.call(dom.ctr_2.children) + .filter(c => c.className && /chs/.test(c.className)) + .map(c => Math.round(c.scrollHeight / 22)).filter(n => n > 1).length; + return { p0, p40, capped, cap: MOD_CONV.slipCap, rows, + longest: Math.max(...rows.map(r => r.length)), tall, + id: chss.mod_slips.id, item: { id: item.mod_slip.id, cls: MOD_itemClass(item.mod_slip.id) } }; +}); +shop.rows.forEach(r => console.log(` "${r}"`)); +check(shop.p40 > shop.p0, `the slip price climbs with use (${shop.p0} -> ${shop.p40})`); +check(shop.capped === shop.cap, `and stops at the cap (${shop.capped})`); +check(shop.longest < 55, `no choice line is long enough to wrap (longest ${shop.longest} chars)`); +check(shop.tall === 0, `and none of them actually wraps its 22px row (${shop.tall} do)`); +check(shop.item.cls === 'Material/Misc', + `a Jade Slip is a tool, and its id says so (${shop.item.id} -> ${shop.item.cls})`); + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — rarity is derived, and folding hides a skill without costing it anything.'); diff --git a/tests/crafting.mjs b/tests/crafting.mjs new file mode 100644 index 0000000..fb4beac --- /dev/null +++ b/tests/crafting.mjs @@ -0,0 +1,246 @@ +import { launch, settle } from './lib/browser.mjs'; + +// The crafting ladder (section 35). +// +// The audit that prompted it: across all 62 base-game recipes, by output +// rarity and by whether any giveRcp call can actually hand the recipe over, +// crafting reached 2 stars in practice. The only 3-star recipe is one nothing +// can teach and 5 stars had nothing at all. This checks the ladder that fills +// that in, and re-runs the audit so the claim stays true. +// +// Two of these checks are guarding real traps rather than being thorough: +// +// * load() resolves a saved item id back to its namespace with +// itemgroup[(id+1)/10000<<0]. An id in the wrong block restores as the +// wrong object, or not at all. +// * equip() does `w.wc = global.text.wecs[w.rar][0]`, and wecs has seven +// entries. A rarity past 6 throws at the moment you put the thing on. +// +// PORT=8080 node tests/crafting.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +console.log('--- the audit, re-run against the live game'); +const audit = await p.evaluate(() => { + let src = ''; + for (const sc of document.scripts) if (!sc.src) src += sc.textContent; + const base = {}, all = {}, orphans = []; + for (const k in rcp) { + const r = rcp[k]; + if (!r || typeof r !== 'object' || !r.res || !r.res.length) continue; + let star = 1; + r.res.forEach(e => { if (e.item && (e.item.rar || 1) > star) star = e.item.rar; }); + const mine = k.indexOf('mod_') === 0; + const reachable = mine || r._modTaught || new RegExp('giveRcp\\(\\s*rcp\\.' + k + '\\b').test(src); + all[star] = (all[star] || 0) + 1; + if (!mine) { base[star] = base[star] || { n: 0, ok: 0 }; base[star].n++; if (reachable) base[star].ok++; } + if (!reachable) orphans.push(k); + } + return { base, all, orphans }; +}); +console.log(' base game, by output stars:', + Object.entries(audit.base).map(([s, v]) => `${s}★ ${v.ok}/${v.n}`).join(' ')); +check((audit.base[3] || { ok: 0 }).ok === 0, + 'the base game still cannot teach its only 3-star recipe (Trinity)'); +check(!audit.base[5], 'and it has no 5-star recipe at all'); +check(audit.orphans.slice().sort().join() === 'jln4,sshl,test,trr', + `only the placeholder and his three late accessories still have no way to be taught (${audit.orphans.join(', ')}); the fifteen dishes are taught by Cooking level`); +check((audit.all[5] || 0) >= 5, `with the ladder, ${audit.all[5] || 0} recipes now make 5-star things`); +check([2, 3, 4, 5].every(s => (audit.all[s] || 0) >= 5), + `every rung from 2 to 5 has a full set (${[2, 3, 4, 5].map(s => audit.all[s]).join('/')})`); + +console.log('\n--- ids land in the block their namespace is restored from'); +// itemgroup = [item, wpn, eqp, sld, acc], indexed by (id+1)/10000<<0 +const ids = await p.evaluate(() => { + const blk = id => ((id + 1) / 10000) << 0; + const out = { bad: [], rar: [] }; + const want = { item: 0, wpn: 1, eqp: 2, sld: 3, acc: 4 }; + MOD_MATERIALS.forEach(m => { if (blk(m[1]) !== 0) out.bad.push('item ' + m[3] + ' id ' + m[1]); }); + MOD_CRAFT_TIERS.forEach(T => { + [['wpn', wpn['mod_w' + T.t]], ['eqp', eqp['mod_a' + T.t]], ['sld', sld['mod_s' + T.t]], + ['acc', acc['mod_c' + T.t]], ['item', item['mod_t' + T.t]]].forEach(([ns, o]) => { + if (blk(o.id) !== want[ns]) out.bad.push(ns + ' ' + o.name + ' id ' + o.id); + if ((o.rar || 1) > 6) out.rar.push(o.name + ' rar ' + o.rar); + }); + }); + out.wecs = global.text.wecs.length; + out.maxRar = Math.max(...MOD_CRAFT_TIERS.map(T => wpn['mod_w' + T.t].rar)); + return out; +}); +check(ids.bad.length === 0, + ids.bad.length ? `wrong id block: ${ids.bad.join(', ')}` : 'all 32 new things sit in their own block'); +check(ids.rar.length === 0 && ids.maxRar <= ids.wecs - 1, + `top rarity is ${ids.maxRar} against ${ids.wecs} wecs entries — equip() cannot run off the end`); + +console.log('\n--- the ladder is gated, and the door is on the Village Center'); +const gate = async (flags) => p.evaluate(f => { + Object.assign(global.flags, f); + const draw = c => { clr_chs(); c.sl(); + return [...dom.ctr_2.querySelectorAll('.chs')].map(n => ({ + t: n.textContent.trim(), lines: Math.round(n.scrollHeight / 22) })); }; + return { village: draw(chss.lsmain1).map(x => x.t), digs: draw(chss.mod_digs) }; +}, flags); + +let g = await gate({ mod_t_deep: false, mod_t_cata: false, trne4e1: false, + mod_prog: { hollow: 0, spire: 0, vigil: 0 } }); +check(!g.village.some(t => /Diggings/.test(t)), 'no Diggings before the deep forest'); +check(g.digs.filter(r => /^"=>/.test(r.t)).length === 0, 'and every node inside is sealed'); +check(g.digs.some(r => /sealed/.test(r.t)), 'each says so rather than being omitted'); + +g = await gate({ mod_t_deep: true }); +check(g.village.some(t => /Diggings/.test(t)), 'the door opens with the catacombs rung'); +check(g.village.some(t => /Enter Dojo/.test(t)) && g.village.some(t => /Message Board/.test(t)), + "and the Village Center's own choices are untouched"); +check(g.digs.filter(r => /^"=>/.test(r.t)).length === 1, 'exactly the 2-star node is open'); + +g = await gate({ mod_t_cata: true, trne4e1: true, mod_prog: { hollow: 99, spire: 99, vigil: 99 } }); +check(g.digs.filter(r => /^"=>/.test(r.t)).length === 4, 'all four once the story is done'); + +console.log('\n--- and every line fits a 22px row'); +const rows = await p.evaluate(() => { + const out = []; + const draw = c => { clr_chs(); c.sl(); + [...dom.ctr_2.querySelectorAll('.chs')].forEach(n => out.push({ + t: n.textContent.trim(), chars: n.textContent.trim().length, + lines: Math.round(n.scrollHeight / 22) })); }; + Object.assign(global.flags, { mod_t_deep: true, mod_t_cata: false, trne4e1: false, + mod_prog: { hollow: 0, spire: 0, vigil: 0 } }); + draw(chss.mod_digs); + Object.assign(global.flags, { mod_t_cata: true, trne4e1: true, + mod_prog: { hollow: 99, spire: 99, vigil: 99 } }); + draw(chss.mod_digs); + MOD_CRAFT_TIERS.forEach(T => draw(chss['mod_node' + T.t])); + return out; +}); +const wrapped = rows.filter(r => r.lines > 1); +const longest = rows.reduce((a, r) => r.chars > a.chars ? r : a, { chars: 0, t: '' }); +check(wrapped.length === 0, wrapped.length + ? `these wrap: ${wrapped.map(r => `"${r.t}"`).join(', ')}` + : `none of the ${rows.length} rows drawn wraps to a second line`); +check(longest.chars <= 55, `longest is ${longest.chars} chars — "${longest.t}"`); + +console.log('\n--- gathering: only at a node, and it teaches the rung it belongs to'); +const gather = await p.evaluate(() => { + const out = {}; + global.lst_loc = 101; // the village + out.condAway = act.mod_gather.cond(false); + global.lst_loc = chss.mod_node3.id; + out.condAtNode = act.mod_gather.cond(false); + out.here = MOD_nodeHere() && MOD_nodeHere().t; + skl.mng.lvl = 0; skl.glg.lvl = 0; skl.hvt.lvl = 0; + out.chanceRaw = MOD_gatherChance(); + skl.mng.lvl = 110; skl.glg.lvl = 110; skl.hvt.lvl = 110; + out.chanceMax = MOD_gatherChance(); + const known = () => MOD_CRAFT_RECIPES.filter(e => e.recipe.have).map(e => e.key); + out.before = known(); + for (let i = 0; i < 300; i++) act.mod_gather.use(); + out.after = known(); + out.mats3 = ['ore', 'weave', 'ess'].map(l => MOD_MAT[l + 3].amount); + out.mats5 = ['ore', 'weave', 'ess'].map(l => MOD_MAT[l + 5].amount); + return out; +}); +check(gather.condAway === false, 'the action refuses to run away from a node'); +check(gather.condAtNode === true, 'and runs at one'); +check(gather.chanceMax > gather.chanceRaw && gather.chanceMax <= 0.2, + `find chance runs ${Math.round(gather.chanceRaw * 100)}% → ${Math.round(gather.chanceMax * 100)}% with the three skills`); +check(gather.mats3.every(n => n > 0), `the 3-star node yields all three of its lines (${gather.mats3.join('/')})`); +check(gather.mats5.every(n => n === 0), 'and none of another rung\'s'); +check(gather.after.length - gather.before.length === 5, + `the first find taught that rung's five blueprints (${gather.after.length - gather.before.length})`); + +console.log('\n--- all twenty recipes make what they say, and the ladder chains'); +const made = await p.evaluate(() => { + MOD_MATERIALS.forEach(m => { try { giveItem(item[m[0]], 80); } catch (e) {} }); + [2, 3, 4, 5].forEach(t => MOD_learnTier(t)); + const out = { ok: [], bad: [], chain: null }; + // the 3-star weapon must be unmakeable until the 2-star one exists + const held = o => { for (const k in inv) if (inv[k] && inv[k].id === o.id) return true; return false; }; + const test3 = make(rcp.mod_w3, true); + out.chain = { needsPrev: rcp.mod_w3.rec.some(c => c.item === wpn.mod_w2), + blockedNow: !rcp.mod_w3.rec.every((c, i) => test3.z[i] >= c.amount || test3.b[i] === true), + haveW2: held(wpn.mod_w2) }; + [2, 3, 4, 5].forEach(t => ['w', 'a', 's', 'c', 't'].forEach(k => { + const r = rcp['mod_' + k + t]; + try { make(r); out.ok.push(r.name); } catch (e) { out.bad.push(r.name + ': ' + e.message); } + })); + return out; +}); +check(made.chain.needsPrev, 'the 3-star blade lists the 2-star blade as an ingredient'); +check(made.chain.blockedNow && !made.chain.haveW2, + 'and cannot be made before you have one'); +check(made.bad.length === 0, + made.bad.length ? `these threw: ${made.bad.join('; ')}` : `all ${made.ok.length} recipes made their output`); + +console.log('\n--- and every piece can actually be worn'); +const worn = await p.evaluate(() => { + const out = []; + [2, 3, 4, 5].forEach(t => { + [['wpn', 'mod_w'], ['eqp', 'mod_a'], ['sld', 'mod_s'], ['acc', 'mod_c']].forEach(([ns, pre]) => { + const o = window[ns][pre + t]; + let held = null; + for (const k in inv) if (inv[k] && inv[k].id === o.id) { held = inv[k]; break; } + if (!held) { try { held = giveItem(o, 1); } catch (e) { out.push({ n: o.name, r: 'no copy' }); return; } } + try { equip(held); out.push({ n: o.name, r: you.eqp[o.slot - 1].id === o.id ? 'ok' : 'not worn' }); } + catch (e) { out.push({ n: o.name, r: 'THREW ' + e.message }); } + }); + }); + return out; +}); +check(worn.every(w => w.r === 'ok'), + worn.filter(w => w.r !== 'ok').length + ? `these did not: ${worn.filter(w => w.r !== 'ok').map(w => w.n + ' ' + w.r).join(', ')}` + : `all ${worn.length} pieces equip, 5-star included`); + +console.log('\n--- the economy points at crafting rather than at selling the ore'); +const econ = await p.evaluate(() => { + const rows = MOD_CRAFT_TIERS.map(T => { + const r = rcp['mod_w' + T.t]; + const inputs = r.rec.reduce((a, c) => a + MOD_itemValue(c.item) * c.amount, 0); + return { star: T.star, inputs: Math.round(inputs), + out: Math.round(MOD_itemValue(wpn['mod_w' + T.t])), + mat: Math.round(MOD_itemValue(MOD_MAT['ore' + T.t])) }; + }); + skl.mng.lvl = skl.glg.lvl = skl.hvt.lvl = 110; + const top = MOD_CRAFT_TIERS[MOD_CRAFT_TIERS.length - 1]; + const avg = ['ore', 'weave', 'ess'].reduce((a, l) => a + MOD_itemValue(MOD_MAT[l + top.t]), 0) / 3; + return { rows, gatherPerSec: avg * MOD_gatherChance() }; +}); +econ.rows.forEach(r => console.log( + ` ${r.star}★ material ${String(r.mat).padStart(5)} inputs ${String(r.inputs).padStart(6)} blade ${String(r.out).padStart(6)}`)); +check(econ.rows.every(r => r.out > r.inputs), + 'a finished blade is worth more than the pile it was made from, at every rung'); +check(econ.gatherPerSec < 60, + `gathering the top rung pays ~${Math.round(econ.gatherPerSec)}/s gross, ~${Math.round(econ.gatherPerSec * 0.25)}/s sold ` + + `(endgame combat is ~1/s, so it is better but not a press)`); + +console.log('\n--- materials and gear survive a save/load'); +const round = await p.evaluate(() => { + const before = { ore5: MOD_MAT.ore5.amount, + blade: (() => { let n = 0; for (const k in inv) if (inv[k] && inv[k].id === wpn.mod_w5.id) n++; return n; })(), + worn: you.eqp[0].id }; + const blob = save(true); load(blob); + const after = { ore5: MOD_MAT.ore5.amount, + blade: (() => { let n = 0; for (const k in inv) if (inv[k] && inv[k].id === wpn.mod_w5.id) n++; return n; })(), + worn: you.eqp[0].id }; + return { before, after, recipesKept: MOD_CRAFT_RECIPES.filter(e => e.recipe.have).length }; +}); +check(round.after.ore5 === round.before.ore5, + `the 5-star ore came back (${round.before.ore5} → ${round.after.ore5})`); +check(round.after.blade === round.before.blade, + `and the blade with it (${round.before.blade} → ${round.after.blade})`); +check(round.after.worn === round.before.worn, 'still wearing what it was wearing'); +check(round.recipesKept === 20, `all ${round.recipesKept} blueprints kept`); + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — the ladder runs 2★ to 5★, is gated, gathered, craftable, wearable and saved.'); diff --git a/tests/creaturegear.mjs b/tests/creaturegear.mjs new file mode 100644 index 0000000..ca8bcb8 --- /dev/null +++ b/tests/creaturegear.mjs @@ -0,0 +1,46 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Every creature held the one shared eqp.dummy, onto which the author wrote +// per-creature affinities, so all of them carried the last writer's and the +// player's fists (which are that object) leaked the player's level into every +// creature's damage. Each now has its own gear, with the values he wrote. +// +// PORT=8080 node tests/creaturegear.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await (await b.newContext()).newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); +const fail = []; +const check = (c, w) => { if (!c) fail.push(w); console.log(` ${c ? 'ok ' : 'FAIL'} ${w}`); }; + +const r = await p.evaluate(() => { + const ks = Object.keys(creature).filter(k => creature[k].eqp); + const shared = ks.filter(k => creature[k].eqp.some(e => e === eqp.dummy)); + const slots = new Set(); let dup = 0; + ks.forEach(k => creature[k].eqp.forEach(e => { if (slots.has(e)) dup++; slots.add(e); })); + const m = mon_gen(creature.bat); + // the leak: the player's fists scale with level; no creature may feel it + const before = JSON.stringify(ks.map(k => [creature[k].eqp[0].aff, creature[k].eqp[0].cls])); + you.eqp[0] = eqp.dummy; you.lvl = 110; + eqp.dummy.cls[2] = you.lvl / 4 << 0; eqp.dummy.aff[0] = you.lvl / 5 << 0; + const after = JSON.stringify(ks.map(k => [creature[k].eqp[0].aff, creature[k].eqp[0].cls])); + return { n: ks.length, shared: shared.length, dup, applied: MOD_CREATURE_GEAR.applied, + bat: creature.bat.eqp[0].aff, batCls: creature.bat.eqp[0].cls, + wolf: creature.wolf1.eqp[0].aff, spawnOwn: m.eqp[0] === creature.bat.eqp[0], + unaffected: before === after, sharedFists: eqp.dummy.aff[0] }; +}); +console.log(` ${r.n} creatures, ${r.applied} of the author's affinity lines applied`); +check(r.shared === 0, `no creature holds the shared dummy (${r.shared})`); +check(r.dup === 0, `and no two creatures share a slot (${r.dup} repeats)`); +check(r.applied >= 28, `his per-creature values were read back (${r.applied})`); +check(JSON.stringify(r.bat) === '[0,12,-10,0,0,-5,5]', `the bat has its own affinities (${JSON.stringify(r.bat)})`); +check(r.spawnOwn, 'a spawned bat carries the bat\'s gear, not a shared one'); +check(r.unaffected, 'the player\'s fists scaling with level reach no creature'); +check(r.sharedFists === 22, 'while the fists themselves still scale (the player keeps the original object)'); +check(errs.length === 0, 'no page errors ' + errs.join(' | ')); +await b.close(); +console.log(fail.length ? `\nFAILED (${fail.length})` : '\nall passed'); +process.exit(fail.length ? 1 : 0); diff --git a/tests/cultivation.mjs b/tests/cultivation.mjs new file mode 100644 index 0000000..2785ba1 --- /dev/null +++ b/tests/cultivation.mjs @@ -0,0 +1,243 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Cultivation realms and elemental mastery. +// +// The realm ladder is the genre's standard one (Qi Refining -> Foundation +// Establishment -> Core Formation -> ...), and the point of it is the +// BOTTLENECK: reaching the level does not advance the realm, it puts you at a +// wall that costs a pill to break. A realm you get for free is just a second +// name for a level. +// +// The masteries are the other half: six skills whose techniques actually fire +// in combat. Combat is automatic here -- fght calls battle_ai, there is no +// ability picker -- so a technique is a proc on the swing, and this checks it +// lands at the advertised rate through the real attack path. +// +// PORT=8080 node tests/cultivation.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +console.log('--- the ladder'); +const ladder = await p.evaluate(() => ({ + count: MOD_REALMS.length - 1, + realms: MOD_REALMS.map(r => `${r.n} ${r.name} (Qi ${r.qic}, x${r.mult})`), + // the realms are DERIVED from MOD_RANK_AT, so realm N must be the level that + // earns a rank N title, and each realm's own title must land at rank N. + // Asserting it here is what stops the two ladders drifting apart later. + aligned: MOD_REALMS.slice(1).every(r => + r.qic === MOD_RANK_AT[r.n - 1] && + MOD_rankForLevel(r.qic) === r.n && + ttl['mod_realm' + r.n] && ttl['mod_realm' + r.n].rar === r.n && + !!item['mod_bp' + r.n]), + ranks: MOD_RANK_AT, + start: MOD_realm().name, + pills: MOD_BREAK_PILLS.map(b => item['mod_bp' + b[0]].name), + masteries: MOD_MASTERY.map(m => m.skill.name), + perks: MOD_MASTERY.map(m => m.skill.mlstn.length), + wired: MOD_MASTERY.every(m => MOD_KEY_BY_ID[m.skill.id] === m.key && !!MOD_PARENT_OF[m.key]) +})); +ladder.realms.forEach(r => console.log(' ' + r)); +const MOD_REALM_COUNT = ladder.count; +const MOD_REALMS_NAME3 = ladder.realms[3]; +check(ladder.count === ladder.ranks.length, + `one realm per title rank (${ladder.count} realms, ${ladder.ranks.length} ranks)`); +check(ladder.aligned, + 'realm N sits at the level that earns a rank N title, and its own title is rank N'); +check(ladder.start === 'Mortal', 'a new character starts Mortal'); +check(ladder.pills.length === MOD_REALM_COUNT, `one breakthrough pill per realm (${ladder.pills.length})`); +check(ladder.masteries.length === 6, `six mastery skills (${ladder.masteries.join(', ')})`); +check(ladder.perks.every(n => n === 7), 'each has the same perk ladder every other skill got'); +check(ladder.wired, 'and all six are registered with the cap and skill-panel maps'); + +console.log('\n--- every breakthrough pill has a source in the game'); +// The realm system shipped once with none of them obtainable: the ten pills +// existed as items and nothing gave, dropped or sold them, so the whole ladder +// was console-only. The test called MOD_breakthrough directly and never asked +// where a pill came from — the gap a test that mocks its inputs leaves open. +const sources = await p.evaluate(() => { + const src = {}; + for (let n = 1; n <= MOD_REALMS.length - 1; n++) src[n] = []; + // the instructor, on the Qi unlock + if (/mod_bp1/.test(String(MOD_checkQiUnlock))) src[1].push('dojo (Qi unlock)'); + // the Herbalist's stock list + (vendor.pha1.items || []).forEach(e => { + const m = /^mod_bp(\d+)$/.exec(Object.keys(item).find(k => item[k] === e.item) || ''); + if (m) src[+m[1]].push('Herbalist ' + e.p + 'c'); + }); + // the dojo's continuation rungs + MOD_DOJO_RUNGS.forEach(r => { if (r.realmPill) src[r.realmPill].push('dojo lv ' + r.lv); }); + return { src, spirit: (vendor.pha1.items || []) + .filter(e => [item.sp4, item.sp5, item.sp6, item.sp7].indexOf(e.item) !== -1) + .map(e => e.item.name) }; +}); +Object.keys(sources.src).forEach(n => + console.log(` realm ${String(n).padStart(2)} ${sources.src[n].join(', ') || 'NOWHERE'}`)); +const orphans = Object.keys(sources.src).filter(n => !sources.src[n].length); +check(orphans.length === 0, + `every realm's pill is obtainable in play (${orphans.length ? 'orphaned: ' + orphans.join(',') : 'all ' + Object.keys(sources.src).length})`); +check(sources.src[1].some(s => /dojo/.test(s)), + 'realm 1 comes from the dojo, not the marketplace — the marketplace is gated behind a 40% encounter'); +check(sources.spirit.length >= 2, `the Herbalist also stocks the new spirit pills (${sources.spirit.join(', ')})`); + +console.log('\n--- reaching the level does NOT advance the realm'); +const wall = await p.evaluate(() => { + global.flags.mod_realm = 0; skl.qic.lvl = 30; + return { realm: MOD_realm().name, eligible: MOD_realmEligible().name, atWall: MOD_atBottleneck() }; +}); +check(wall.realm === 'Mortal' && wall.eligible !== 'Mortal', + `Qi Circulation 30 makes ${wall.eligible} reachable but you are still ${wall.realm}`); +check(wall.atWall, 'the bottleneck is reported'); + +console.log('\n--- you cannot skip a realm, or break through under-levelled'); +const rules = await p.evaluate(() => { + // read the requirements rather than hardcoding them: the thresholds come from + // MOD_RANK_AT and change whenever the title ranks are retuned + const two = MOD_REALMS[2]; + global.flags.mod_realm = 0; skl.qic.lvl = 999; + const skip = MOD_breakthrough(3, { amount: 1 }); // realm 3 from realm 0 + global.flags.mod_realm = 1; skl.qic.lvl = two.qic - 1; // one short of realm 2 + const under = MOD_breakthrough(2, { amount: 1 }); + return { skip, under, realm: MOD_realm().name, needed: two.qic }; +}); +check(rules.skip === undefined, 'skipping a realm is refused'); +check(rules.under === undefined, `breaking through one level short of Qi ${rules.needed} is refused`); +check(rules.realm !== MOD_REALMS_NAME3, 'and the realm did not move'); + +console.log('\n--- breaking through costs the pill whether or not it works'); +const attempt = await p.evaluate(() => { + global.flags.mod_realm = 0; skl.qic.lvl = MOD_REALMS[1].qic; // exactly at the wall + let consumed = 0, wins = 0, losses = 0; + for (let i = 0; i < 200; i++) { + global.flags.mod_realm = 0; + global.flags.mod_insight = 99; // section 38: insight is the other half of the price + global.flags.mod_qidev = 0; // ... and a failure in the open deviates you + const pill = { amount: 1 }; + const r = MOD_breakthrough(1, pill); + if (pill.amount === 0) consumed++; + r ? wins++ : losses++; + } + return { consumed, wins, losses, odds: Math.min(0.55 + 0 * 0.05, 0.95) }; +}); +check(attempt.consumed === 200, `the pill is spent every time (${attempt.consumed}/200)`); +check(attempt.losses > 0, `failure is possible (${attempt.losses} of 200 failed)`); +check(Math.abs(attempt.wins / 200 - attempt.odds) < 0.12, + `success ~${Math.round(attempt.odds * 100)}% at the threshold (got ${Math.round(attempt.wins / 200 * 100)}%)`); + +console.log('\n--- consolidating past the requirement improves the odds'); +const odds = await p.evaluate(() => [0, 4, 8].map(over => { + skl.qic.lvl = MOD_REALMS[1].qic + over; + let wins = 0; + for (let i = 0; i < 300; i++) { + global.flags.mod_realm = 0; global.flags.mod_insight = 99; global.flags.mod_qidev = 0; + if (MOD_breakthrough(1, { amount: 1 })) wins++; + } + return { over, pct: Math.round(wins / 300 * 100) }; +})); +odds.forEach(o => console.log(` +${o.over} past the requirement: ${o.pct}%`)); +check(odds[2].pct > odds[0].pct, 'training past the wall makes it likelier'); + +console.log('\n--- a realm is worth something, and does not compound over reloads'); +const worth = await p.evaluate(() => { + global.flags.mod_realm = 0; skl.qic.lvl = 1; global.flags.mod_qidev = 0; + you.stat_r(); allbuff(you); + const mortal = { str: you.str, hp: you.hpmax }; + global.flags.mod_realm = 3; + you.stat_r(); allbuff(you); + const core = { str: you.str, hp: you.hpmax }; + for (let i = 0; i < 20; i++) allbuff(you); // many refreshes + const after = { str: you.str, hp: you.hpmax }; + return { name: MOD_REALMS[3].name, want: MOD_REALMS[3].mult, + strX: +(core.str / mortal.str).toFixed(2), hpX: +(core.hp / mortal.hp).toFixed(2), + top: MOD_REALMS[MOD_REALMS.length - 1].mult, + stable: Math.abs(after.str - core.str) < 1e-6 && after.hp === core.hp }; +}); +check(Math.abs(worth.strX - worth.want) < 0.06, + `${worth.name} is worth the x${worth.want} it advertises (measured x${worth.strX} STR, x${worth.hpX} HP)`); +check(worth.top >= 5, `and the top realm is worth x${worth.top}`); +check(worth.stable, 'twenty allbuff refreshes do not stack it'); + +console.log('\n--- no technique fires below Qi Refining'); +const closed = await p.evaluate(() => { + global.flags.mod_realm = 0; + MOD_MASTERY.forEach(m => { m.skill.lvl = 60; }); + return { chances: MOD_MASTERY.map(m => MOD_techChance(m)), desc: MOD_EFFECTS.mod_fire(skl.mod_fire) }; +}); +check(closed.chances.every(c => c === 0), 'every technique is inert at Mortal'); +check(/channels are closed/.test(closed.desc), 'and the skill says why'); + +console.log('\n--- techniques fire in real combat at the advertised rate'); +const combat = await p.evaluate(() => { + global.flags.mod_realm = 2; + MOD_MASTERY.forEach(m => { m.skill.lvl = 0; }); + skl.mod_fire.lvl = 40; + const expected = MOD_techChance(MOD_MASTERY[0]); + global.current_z = area.frstn1a2; + const e = area.frstn1a2.pop[0]; + const mon = mon_gen(e.crt); lvlup(mon, 5); + global.current_m = mon; global.target = you.eqp[2]; + global.flags.m_blh = true; + let procs = 0, swings = 0; + const orig = attack; + attack = function (x, y, atk, pow) { + swings++; + if (atk && atk.id >= 2001 && atk.id <= 2006) procs++; + return orig(x, y, atk, pow); + }; + for (let i = 0; i < 600; i++) { + mon.hp = mon.hpmax; you.hp = you.hpmax; global.flags.btl = true; + you.battle_ai(you, mon); + } + attack = orig; global.flags.btl = false; global.flags.m_blh = false; + return { swings, procs, rate: procs / swings, expected, + statType: abl.mod_fire.stt, element: abl.mod_fire.aff }; +}); +console.log(` ${combat.procs} of ${combat.swings} swings became a technique ` + + `(${Math.round(combat.rate * 100)}%, advertised ${Math.round(combat.expected * 100)}%)`); +check(combat.procs > 0, 'techniques actually reach the real attack path'); +check(Math.abs(combat.rate - combat.expected) < 0.07, 'at close to the advertised rate'); +check(combat.statType === 2, 'and route through the INT branch of dmg_calc, not the weapon one'); + +console.log('\n--- a swing is never lost to a broken proc'); +const safe = await p.evaluate(() => { + const orig = MOD_pickTechnique; + MOD_pickTechnique = function () { throw new Error('deliberate'); }; + const mon = mon_gen(area.frstn1a2.pop[0].crt); lvlup(mon, 5); + global.current_m = mon; global.target = you.eqp[2]; + global.flags.btl = true; global.flags.m_blh = true; + let landed = 0; + for (let i = 0; i < 20; i++) { mon.hp = mon.hpmax; if (you.battle_ai(you, mon) !== undefined) landed++; } + MOD_pickTechnique = orig; global.flags.btl = false; global.flags.m_blh = false; + return landed; +}); +check(safe === 20, `all 20 swings still landed with the proc throwing (${safe})`); + +console.log('\n--- a realm pill is in stock in reasonable time'); +// A game minute is a real second, so a day is 24 minutes and the Tower restocks +// every `timeorig` days. A pill that is in stock with chance c is there after +// 1/c restocks on average (measured: 1 day = 1440 real seconds at speed 1). +const tower = await p.evaluate(() => ({ + days: vendor.mod_pltwr.timeorig, + rows: vendor.mod_pltwr.items.filter(e => /^Breakthrough|bp/.test(e.item.name) || e.item.id >= 9120 && e.item.id <= 9129 || e.item.id >= 4210 && e.item.id <= 4219) + .map(e => ({ name: e.item.name, c: e.c })) +})); +check(tower.rows.length >= 5, `found the Tower's realm pills (${tower.rows.length})`); +let prev = 0; +for (const r of tower.rows) { + const hours = tower.days * 24 / 60 / r.c; + console.log(` ${r.name}: ${Math.round(r.c * 100)}%, about ${hours.toFixed(1)} h`); + check(hours <= 8.01, `${r.name} is in stock within 8 real hours on average (${hours.toFixed(1)})`); +} + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — realms gate on a bottleneck that costs something, and techniques land in combat.'); diff --git a/tests/dishes.mjs b/tests/dishes.mjs new file mode 100644 index 0000000..b2d3d16 --- /dev/null +++ b/tests/dishes.mjs @@ -0,0 +1,51 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Fifteen of the author's dishes had no giveRcp anywhere. They are taught by +// Cooking level, simplest first, on the tick when the level changes. +// +// PORT=8080 node tests/dishes.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await (await b.newContext()).newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); +const fail = []; +const check = (c, w) => { if (!c) fail.push(w); console.log(` ${c ? 'ok ' : 'FAIL'} ${w}`); }; + +const r = await p.evaluate(() => { + const keys = MOD_DISHES.map(d => d.key); + const lvs = MOD_DISHES.map(d => d.lv); + const asc = lvs.every((v, i) => i === 0 || v >= lvs[i - 1]); + MOD_DISHES.forEach(d => { d.recipe.have = false; }); + const first = MOD_DISHES[0], last = MOD_DISHES[MOD_DISHES.length - 1]; + skl.cook.lvl = first.lv - 1; MOD_teachDishes(); + const before = MOD_DISHES.filter(d => d.recipe.have).length; + skl.cook.lvl = first.lv; MOD_teachDishes(); + const atFirst = MOD_DISHES.filter(d => d.recipe.have).map(d => d.key); + skl.cook.lvl = 110; MOD_teachDishes(); + const all = MOD_DISHES.every(d => d.recipe.have); + const before2 = global.rec_d.length; MOD_teachDishes(); + const idem = global.rec_d.length === before2; + // through the real tick, as the game runs it + MOD_DISHES.forEach(d => { d.recipe.have = false; }); skl.cook.lvl = 0; MOD_dishLv = -1; ontick(); + const t0 = MOD_DISHES.filter(d => d.recipe.have).length; + skl.cook.lvl = 110; ontick(); + const viaTick = MOD_DISHES.every(d => d.recipe.have); + return { n: keys.length, keys, lvs, asc, before, atFirst, firstKey: first.key, all, idem, t0, viaTick, + lastLv: last.lv, firstLv: first.lv, noMod: keys.every(k => !/^mod_/.test(k)) }; +}); +console.log(` ${r.n} dishes, levels ${r.lvs.join(' ')}`); +check(r.n === 15, `all fifteen found (${r.n}: ${r.keys.join(', ')})`); +check(!r.keys.includes('test') && !r.keys.includes('trr') && !r.keys.includes('jln4') && !r.keys.includes('sshl'), 'the placeholder and the accessories are left alone'); +check(r.asc && r.firstLv >= 6 && r.lastLv <= 44, `taught in order across cooking ${r.firstLv}-${r.lastLv}`); +check(r.before === 0, 'nothing is taught before its level'); +check(r.atFirst.includes(r.firstKey), 'a dish is taught at its level'); +check(r.all, 'every dish is taught by the top'); +check(r.idem, 'teaching again hands out nothing twice'); +check(r.t0 === 0 && r.viaTick, 'and the real tick does it when the level changes'); +check(errs.length === 0, 'no page errors ' + errs.join(' | ')); +await b.close(); +console.log(fail.length ? `\nFAILED (${fail.length})` : '\nall passed'); +process.exit(fail.length ? 1 : 0); diff --git a/tests/docs.mjs b/tests/docs.mjs new file mode 100644 index 0000000..e2e6c18 --- /dev/null +++ b/tests/docs.mjs @@ -0,0 +1,46 @@ +import { readFileSync, readdirSync } from 'node:fs'; + +// The docs state numbers that drift: how many sections, how many scripts, which +// version, how long the suite takes. This reads each from the source and fails +// on a doc that disagrees. No browser; runs in a second. +// +// node tests/docs.mjs + +const read = f => readFileSync(new URL('../' + f, import.meta.url), 'utf8'); +const mod = read('mod.js'), readme = read('README.md'), treadme = read('tests/README.md'); +const claude = read('CLAUDE.md'), notes = read('MOD-NOTES.md'), runsh = read('tests/run.sh'); +const fail = []; +const check = (c, w) => { if (!c) fail.push(w); console.log(` ${c ? 'ok ' : 'FAIL'} ${w}`); }; + +const ONES = 'zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen'.split(' '); +const TENS = { twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60 }; +const words = n => n < 20 ? ONES[n] : Object.keys(TENS).find(k => TENS[k] === n - n % 10) + (n % 10 ? '-' + ONES[n % 10] : ''); +const cap = s => s[0].toUpperCase() + s.slice(1); + +// sections: the highest "N. TITLE" header in mod.js +const secs = [...mod.matchAll(/^ (\d{1,2})\. [A-Z]/gm)].map(m => +m[1]); +const nSec = Math.max(...secs); +check(readme.includes(cap(words(nSec)) + ' sections'), `README says ${words(nSec)} sections (mod.js has headers up to ${nSec})`); + +// version +const ver = mod.match(/version: '([\d.]+)'/)[1]; +check(new RegExp('Version ' + ver.replace(/\./g, '\\.') + '\\.').test(readme), `README names version ${ver}`); +check(notes.includes('v' + ver) || notes.includes(ver), `MOD-NOTES mentions ${ver}`); + +// scripts +const files = readdirSync(new URL('../tests/', import.meta.url)).filter(f => f.endsWith('.mjs')).map(f => f.replace('.mjs', '')); +const listed = [...runsh.matchAll(/TESTS=\(([^)]*)\)/g)].pop()[1].trim().split(/\s+/); +const missing = files.filter(f => !listed.includes(f)), ghost = listed.filter(f => !files.includes(f)); +check(!missing.length && !ghost.length, `run.sh lists every script (unlisted: ${missing}; no file: ${ghost})`); +for (const [doc, re] of [[readme, /all (\d+) scripts/], [readme, /(\d+) Playwright scripts/], [treadme, /All (\d+) take/]]) { + const m = doc.match(re); + check(m && +m[1] === listed.length, `"${m && m[0]}" matches the ${listed.length} scripts run`); +} + +// the suite's duration: every "about N seconds" agrees, and none says it another way +const secsClaims = [...[readme, treadme, claude, notes].join('\n').matchAll(/about (\d+) seconds/g)].map(m => +m[1]); +check(new Set(secsClaims).size <= 1, `every "about N seconds" agrees (${[...new Set(secsClaims)]})`); +check(!/minute and a half/.test(claude + notes + readme + treadme), 'no doc gives the suite time as "a minute and a half"'); + +console.log(fail.length ? `\nFAILED (${fail.length})` : '\nall passed'); +process.exit(fail.length ? 1 : 0); diff --git a/tests/dojo.mjs b/tests/dojo.mjs new file mode 100644 index 0000000..ddc2a65 --- /dev/null +++ b/tests/dojo.mjs @@ -0,0 +1,140 @@ +import { launch, settle } from './lib/browser.mjs'; + +// The dojo's Level Advancement ladder, carried to character level 110. +// +// The game already has the ladder and the instructor already promises it +// continues -- "After every 5 levels you reach, come here and receive your +// share!" -- and then it stops at level 30, which is a third of the way through +// the mod's progression. This checks the sixteen rungs that finish it, the +// spirit pills sized to what a level actually costs up there, and the manual +// choice at 50/75/100. +// +// PORT=8080 node tests/dojo.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); + +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +// put the player in the ordinary lobby state, past everything the base game gates on +const lobby = (lvl, extra) => p.evaluate(([l, ex]) => { + Object.assign(global.flags, { + nbtfail: false, dj1end: true, trnex1: true, trnex2: true, + trne4e1: true, trne4e1b: true, dj1rw6: true + }, ex || {}); + you.lvl = l; + try { chss.t3.sl(); } catch (e) { return ['THREW: ' + e.message]; } + return [...dom.ctr_2.querySelectorAll('.chs')].map(n => n.textContent.trim()); +}, [lvl, extra]); + +const click = (text) => p.evaluate(t => { + const el = [...dom.ctr_2.querySelectorAll('.chs')].find(n => n.textContent.includes(t)); + if (!el) return ['NOT FOUND: ' + t]; + el.click(); + return [...dom.ctr_2.querySelectorAll('.chs')].map(n => n.textContent.trim()); +}, text); + +console.log('--- the pills are sized against what a level actually costs'); +const pills = await p.evaluate(() => { + const cost = l => 4 * l ** 3 + l ** 2; + return { rows: MOD_PILLS.map(x => ({ name: item[x[0]].name, exp: x[3], + at50: +(x[3] / cost(50)).toFixed(2), at110: +(x[3] / cost(110)).toFixed(2) })), + base: [500, 2500, 15000].map(e => +(e / cost(110)).toFixed(4)), + cost110: cost(110) }; +}); +pills.rows.forEach(r => console.log(` ${r.name.padEnd(26)} ${String(r.exp).padStart(9)} exp ` + + `${String(r.at50).padStart(5)} levels at 50 ${String(r.at110).padStart(5)} at 110`)); +console.log(` (a level at 110 costs ${pills.cost110.toLocaleString()}; the game's best pill is ${pills.base[2]} of one)`); +check(pills.rows[pills.rows.length - 1].at110 > 1, 'the top pill is worth more than a level at 110'); +check(pills.rows[0].at50 < 1, 'the first new pill is not worth a level at 50 — it is a step, not a jump'); + +console.log('\n--- the continuation does not appear before the base ladder is done'); +let rows = await lobby(50, { dj1rw6: false }); +check(!rows.some(r => /continued/.test(r)), 'hidden until the base six rungs are claimed'); +rows = await lobby(50, { dj1end: false }); +check(!rows.some(r => /continued/.test(r)), 'and not on the first-skillbook screen'); +rows = await lobby(50, { nbtfail: true }); +check(!rows.some(r => /continued/.test(r)), 'nor on the "beaten up by a dummy" screen'); + +console.log('\n--- and the base lobby is left intact'); +rows = await lobby(50); +check(rows.some(r => /infoboard/i.test(r)), 'the infoboard is still there'); +check(rows.some(r => /Level Advancement"/.test(r)), 'the game\'s own Level Advancement is still there'); +check(rows.some(r => /continued/.test(r)), 'and the continuation sits alongside it'); + +console.log('\n--- one rung at a time, in order, gated on level'); +let adv = await click('continued'); +check(adv.some(r => /Level 35 reward/.test(r)), 'at level 50, the next unclaimed rung is 35'); +await p.evaluate(() => { global.flags.mod_djrw35 = true; }); +adv = await (async () => { await lobby(50); return click('continued'); })(); +check(adv.some(r => /Level 40 reward/.test(r)), 'claiming 35 moves it to 40'); + +await p.evaluate(() => { for (let l = 40; l <= 110; l += 5) global.flags['mod_djrw' + l] = false; }); +await lobby(36); +adv = await click('continued'); +check(adv.some(r => /Next: level 40/.test(r)), 'at level 36 it says to come back at 40'); + +console.log('\n--- claiming pays out'); +const paid = await p.evaluate(async () => { + Object.assign(global.flags, { nbtfail: false, dj1end: true, trnex1: true, trnex2: true, + trne4e1: true, trne4e1b: true, dj1rw6: true }); + for (let l = 35; l <= 110; l += 5) global.flags['mod_djrw' + l] = l < 40; + you.lvl = 45; you.wealth = 0; + const before = inv.filter(i => i.id === item.sp4.id).reduce((a, i) => a + (i.amount || 1), 0); + chss.t3.sl(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /continued/.test(n.textContent)).click(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /Level 40 reward/.test(n.textContent)).click(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /Accept/.test(n.textContent)).click(); + const after = inv.filter(i => i.id === item.sp4.id).reduce((a, i) => a + (i.amount || 1), 0); + return { coin: you.wealth, pillsGained: after - before, flag: !!global.flags.mod_djrw40 }; +}); +check(paid.coin > 0, `coin paid (${paid.coin})`); +check(paid.pillsGained >= 2, `spirit pills given (${paid.pillsGained})`); +check(paid.flag, 'the rung is marked claimed'); + +console.log('\n--- the level 50 rung offers a manual, the way the first skillbook choice does'); +const manual = await p.evaluate(() => { + Object.assign(global.flags, { nbtfail: false, dj1end: true, trnex1: true, trnex2: true, + trne4e1: true, trne4e1b: true, dj1rw6: true }); + for (let l = 35; l <= 110; l += 5) global.flags['mod_djrw' + l] = l < 50; + you.lvl = 55; + chss.t3.sl(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /continued/.test(n.textContent)).click(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /Level 50 reward/.test(n.textContent)).click(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /Accept/.test(n.textContent)).click(); + const offered = [...dom.ctr_2.querySelectorAll('.chs')].map(n => n.textContent.trim()); + const claimedBefore = !!global.flags.mod_djrw50; + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /Sword Saint/.test(n.textContent)).click(); + return { offered, claimedBefore, claimedAfter: !!global.flags.mod_djrw50, + got: inv.some(i => i.id === item.mod_srdc_manual.id), + rate: MOD_MANUAL_RATE }; +}); +console.log(' offered: ' + manual.offered.filter(o => /Manual/.test(o)).join(', ')); +check(manual.offered.filter(o => /Manual/.test(o)).length === 6, 'six manuals offered, one per weapon'); +check(!manual.claimedBefore, 'the rung is not claimed until a manual is picked'); +check(manual.claimedAfter && manual.got, `picking one gives it and claims the rung (+${Math.round(manual.rate*100)}% exp)`); + +console.log('\n--- the ladder runs to 110 and then stops'); +const end = await p.evaluate(() => { + Object.assign(global.flags, { nbtfail: false, dj1end: true, trnex1: true, trnex2: true, + trne4e1: true, trne4e1b: true, dj1rw6: true }); + for (let l = 35; l <= 110; l += 5) global.flags['mod_djrw' + l] = true; + you.lvl = 110; + chss.t3.sl(); + [...dom.ctr_2.querySelectorAll('.chs')].find(n => /continued/.test(n.textContent)).click(); + return { rungs: MOD_DOJO_RUNGS.length, top: MOD_DOJO_RUNGS[MOD_DOJO_RUNGS.length - 1].lv, + text: [...dom.ctr_2.querySelectorAll('.chs')].map(n => n.textContent.trim()) }; +}); +check(end.rungs === 16 && end.top === 110, `${end.rungs} rungs, 35 through ${end.top}, in fives`); +check(end.text.some(t => /Nothing further/.test(t)), 'and it says so when there is nothing left'); + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors: ' + JSON.stringify(errs)); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — the instructor keeps his promise all the way to 110.'); diff --git a/tests/dojowall.mjs b/tests/dojowall.mjs new file mode 100644 index 0000000..703bb47 --- /dev/null +++ b/tests/dojowall.mjs @@ -0,0 +1,50 @@ +import { launch, settle } from './lib/browser.mjs'; + +// The dojo's realm 6-10 pills come at character levels; the walls are Circulate +// Qi levels. Stuck at a wall without the pill, the instructor gives it once, and +// the level reward for that realm does not give a second. +// +// PORT=8080 node tests/dojowall.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await (await b.newContext()).newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); +const fail = []; +const check = (c, w) => { if (!c) fail.push(w); console.log(` ${c ? 'ok ' : 'FAIL'} ${w}`); }; + +const r = await p.evaluate(() => { + const out = {}; + // stand at the realm-6 wall: Circulate Qi at the level that earns rank 6, realm 5 held + const qi = skl.mod_qi || Object.values(skl).find(s => s && /Qi Circulation/i.test(s.name)); + out.hasQi = !!qi; + global.flags.mod_realm = 5; + qi.lvl = MOD_RANK_AT[5]; + out.atWall = MOD_atBottleneck(); + out.eligible = MOD_realmEligible().n; + out.offer6 = MOD_wallPillOffer(); + const before = item.mod_bp6.amount || 0; + out.claimed = MOD_claimWallPill(); + out.second = MOD_wallPillOffer(); + out.third = MOD_claimWallPill(); + // not at a wall, or a realm the dojo does not carry: no offer + global.flags.mod_realm = 0; qi.lvl = 1; + out.idle = MOD_wallPillOffer(); + global.flags.mod_realm = 1; qi.lvl = MOD_RANK_AT[2]; + out.lowRealm = MOD_wallPillOffer(); + return out; +}); +console.log(r); +check(r.hasQi, 'found the Qi Circulation skill'); +check(r.atWall && r.eligible === 6, `at the realm 6 wall (${r.eligible})`); +check(r.offer6 === 6, 'the instructor offers the realm 6 pill'); +check(r.claimed === true, 'and hands it over'); +check(r.second === 0 && r.third === false, 'once only'); +check(r.idle === 0, 'no offer when you are not at a wall'); +check(r.lowRealm === 0, 'none for walls the village herbalist already covers'); +check(errs.length === 0, 'no page errors ' + errs.join(' | ')); +await b.close(); +console.log(fail.length ? `\nFAILED (${fail.length})` : '\nall passed'); +process.exit(fail.length ? 1 : 0); diff --git a/tests/earlybal.mjs b/tests/earlybal.mjs new file mode 100644 index 0000000..fb25763 --- /dev/null +++ b/tests/earlybal.mjs @@ -0,0 +1,239 @@ +import { launch, settle } from './lib/browser.mjs'; + +// EARLY GAME balance. Every other balance script models the player sitting AT a +// story cap; this one walks the first few hours instead, when the character is +// level 1-20 with barely-levelled skills and no equipment worth the name. +// +// It does not use the str-vs-hp ratio the later scripts use, because that proxy +// is wrong down here: dmg_calc is SUBTRACTIVE (the defender's STR comes off the +// attacker's damage), so at small numbers an enemy multiplier does not make a +// fight slower, it makes it impossible. This drives the game's own dmg_calc and +// hit_calc instead and reports real swings-to-kill / swings-to-die. +// +// PORT=8080 node tests/earlybal.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const p = await b.newPage(); +const errs = []; p.on('pageerror', e => errs.push(String(e).slice(0, 200))); +await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); +await settle(p); +// section 8's model is what this measures, so measure it everywhere: the +// default (Fights: Original) leaves the author's areas unscaled -- fights.mjs +await p.evaluate(() => setFights('scaled')); + +const R = await p.evaluate((BASELINE) => { + const SAMPLES = 300; + + // BASELINE=1 turns the mod's enemy scaling off entirely so the same harness + // can print what the base game's own numbers look like at the same levels. + // lvlup resolves MOD_scaleEnemy by name at call time, so this takes effect. + if (BASELINE) { MOD_scaleEnemy = function () {}; } + + // ---- a genuinely fresh character, then levelled the way the game levels it -- + const freshYou = () => { + you.lvl = 1; + you.str_r = 1; you.agl_r = 1; you.int_r = 1; you.spd_r = 1; + you.hp_r = 39; you.hpmax = 39; you.hp = 39; + you.sat_r = 200; you.satmax = 200; you.sat = 200; + you.stra = you.agla = you.inta = you.spda = you.hpa = you.sata = 0; + you.strm = you.aglm = you.intm = you.spdm = you.hpm = you.satm = 1; + you.stat_p = [1, 1, 1, 1]; + you.mods.sbonus = 0; you.mods.cpwr = 1; + for (const k in skl) { const s = skl[k]; + if (!s || typeof s !== 'object') continue; + s.lvl = 0; s.exp = 0; if (s.mlstn) s.mlstn.forEach(m => { m.g = false; }); } + Object.keys(ttl).forEach(k => { ttl[k].have = false; }); + try { MOD_updateRenown(); } catch (e) {} + you.stat_r(); + }; + + const neutral = () => { + global.flags.inside = true; global.flags.isday = true; global.flags.btl = false; + global.flags.iscold = false; global.flags.iswet = false; global.flags.isdark = false; + you.hp = you.hpmax; you.sat = you.satmax; + }; + + // character level via the game's own lvlup, averaged so one lucky roll does + // not decide the verdict + const buildPlayer = (charLvl, skillLvl, tierIdx) => { + freshYou(); + global.flags.tr3_win = tierIdx >= 1; + global.flags.mod_t_forest = tierIdx >= 2; + global.flags.mod_t_deep = tierIdx >= 3; + global.flags.mod_t_cata = tierIdx >= 4; + global.flags.trne1e1 = tierIdx >= 5; + global.flags.trne3e1 = tierIdx >= 6; + global.flags.mod_prog = { hollow: 0, spire: 0, vigil: 0 }; + MOD_CAP.current = -1; + const cap = MOD_levelCap(); + for (let i = 1; i < charLvl; i++) { try { lvlup(you, 1); } catch (e) {} } + const lv = Math.min(skillLvl, cap); + for (const k in skl) { const s = skl[k]; + if (!s || typeof s !== 'object' || k === 'rnwn') continue; + s.lvl = lv; + if (s.mlstn) s.mlstn.forEach(m => { m.g = false; + if (m.lv <= lv) { try { m.f(); m.g = true; } catch (e) {} } }); } + you.stat_r(); neutral(); allbuff(you); + return { cap, skillLvl: lv, str: you.str, hp: you.hpmax, agl: you.agl, spd: you.spd }; + }; + + // ---- one matchup, measured through the game's real combat math ------------- + // dmg_calc grants skill exp (seye on a crit, the affinity skills on incoming + // damage) and can set the crit flag. Sampling it thousands of times would + // level the player mid-sweep and quietly invalidate everything measured after + // — the mod's own MOD_scaleEnemy parks these for the same reason. + const quiet = (fn) => { + const g = giveSkExp, c = global.flags.crti; + giveSkExp = function () {}; + try { return fn(); } finally { giveSkExp = g; global.flags.crti = c; } + }; + + // global.current_z is the anchor MOD_scaleEnemy measures a spawn against, + // so it has to be the area the creature is coming out of + // Your damage is two tight clusters, not one spread: a normal swing varies by + // +-10%, a crit runs about 12x that at cap 110 and lands a fifth of the time. + // A plain sample mean is therefore mostly measuring "how many crits happened", + // and reports balance failures that are really estimator noise. Averaging the + // two strata separately and recombining with the crit rate the game will + // actually roll against removes that. Written out here rather than calling the + // mod's MOD_meanDamage, so the test is not just agreeing with itself. + // Tops up until both strata have shown. Falling back to a plain mean when n + // swings roll no crit DROPS the crits rather than averaging them -- they are + // ~30% of all damage at cap 30 -- so a zero-crit sample (0.3% at a 9% crit + // rate and 60 swings) read a fight ~40% long. Across 2,790 matchups that was + // often enough to fail a thin-margin rank duel on a coin flip: the + // intermittent allareas failure. Extra swings only when needed. + const meanDamage = (att, def, n) => { + let critN = 0, critSum = 0, plainN = 0, plainSum = 0, zeros = 0, i = 0; + const rate = MOD_critRate(att); + for (; i < n * 8; i++) { + if (i >= n && (critN >= 2 || rate <= 0) && plainN >= 2) break; + global.flags.crti = false; + const d = Math.max(0, Math.round(abl.default.f(att, def))); + if (d <= 0) zeros++; + if (global.flags.crti) { critN++; critSum += d; } else { plainN++; plainSum += d; } + } + const mean = (critN && plainN) + ? (1 - rate) * (plainSum / plainN) + rate * (critSum / critN) + : (critSum + plainSum) / i; + return { mean, zeroPct: zeros / i * 100 }; + }; + + const matchup = (z, crt, lvl) => { + global.current_z = z; + const m = mon_gen(crt); lvlup(m, lvl); + global.current_m = m; update_m && update_m(); + global.target = you.eqp[2]; global.t_n = 2; + allbuff(you); allbuff(m); + + const hitYou = Math.max(0, Math.min(100, hit_calc(1))) / 100; // you -> it + const hitMon = Math.max(0, Math.min(100, hit_calc(2))) / 100; // it -> you + + let dOut = 0, dIn = 0, zeroOut = 0; + quiet(() => { + const out = meanDamage(you, m, SAMPLES); + const inc = meanDamage(m, you, SAMPLES); + dOut = out.mean; dIn = inc.mean; zeroOut = out.zeroPct; + }); + + const dpsOut = dOut * hitYou, dpsIn = dIn * hitMon; + return { + name: m.name, lvl, + ehp: Math.round(m.hpmax), estr: Math.round(m.str), + hitYou: +(hitYou * 100).toFixed(0), hitMon: +(hitMon * 100).toFixed(0), + dOut: +dOut.toFixed(2), dIn: +dIn.toFixed(2), + whiff: +zeroOut.toFixed(0), // % of landed hits that do nothing + swingsToKill: dpsOut > 0 ? Math.ceil(m.hpmax / dpsOut) : Infinity, + swingsToDie: dpsIn > 0 ? Math.ceil(you.hpmax / dpsIn) : Infinity, + spdRatio: +(you.spd / Math.max(m.spd, 0.01)).toFixed(2) + }; + }; + + // ---- the actual opening route, in the order a new character walks it ------- + const ROUTE = [ + ['Tutorial fight 1', 'trn1', 1, 0, 0], + ['Tutorial fight 2', 'trn2', 2, 1, 0], + ['Tutorial fight 3', 'trn3', 3, 2, 0], + ['Training grounds', 'trn', 5, 4, 1], + ['W forest, first', 'frstn1a2', 6, 5, 1], + ['Damp cellar', 'clg', 8, 6, 1], + ['W forest, second', 'frstn2a2', 10, 8, 1], + ['W forest, grind', 'frstn1a3', 12, 10, 2], + ['W forest, hidden', 'frstn1a4', 15, 12, 2], + ['Your basement', 'hmbsmnt', 18, 14, 2], + ['S forest', 'frstn9a1', 20, 15, 3] + ]; + + const rows = ROUTE.map(([nm, ak, charLvl, skillLvl, ti]) => { + if (!area[ak]) return { nm, ak, missing: true }; + const P = buildPlayer(charLvl, skillLvl, ti); + const fights = area[ak].pop.map(e => { + const lo = matchup(area[ak], e.crt, e.lvlmin); + const hi = e.lvlmax !== e.lvlmin ? matchup(area[ak], e.crt, e.lvlmax) : null; + return { w: e.c, lo, hi }; + }); + return { nm, ak, charLvl, player: P, fights }; + }); + + // ---- how long to the first skill levels, on the mod's curve --------------- + const firstLevels = [1, 3, 5, 10].map(L => { + let t = 0; const s = skl.wsdm; const keep = s.lvl; + for (let l = 0; l < L; l++) { s.lvl = l; t += s.expnext(); } + s.lvl = keep; + return { L, xp: Math.round(t), minsAt02: +(t / 0.2 / 60).toFixed(1) }; + }); + + // report the multiplier as a brand-new character sees it, not as the last row left it + global.flags.tr3_win = global.flags.mod_t_forest = global.flags.mod_t_deep = + global.flags.mod_t_cata = global.flags.trne1e1 = global.flags.trne3e1 = false; + MOD_CAP.current = -1; MOD_levelCap(); + + return { rows, firstLevels, + enemy: JSON.parse(JSON.stringify(MOD_ENEMY)) }; +}, process.env.BASELINE ? 1 : 0); + +const pad = (s, n) => String(s).padStart(n); +const inf = v => (v === null || v === Infinity ? ' never' : pad(v, 6)); + +console.log(`targets: kill ${R.enemy.kill}, die ${R.enemy.die}, enemy hit rate ${R.enemy.hit}, ` + + `spread hp^${R.enemy.hpSpread} atk^${R.enemy.atkSpread}, areaScale ${R.enemy.areaScale}`); + +console.log('\n=========== FIRST SKILL LEVELS ==========='); +R.firstLevels.forEach(f => console.log(` lv ${pad(f.L, 2)} ${pad(f.xp.toLocaleString(), 8)} xp ~${f.minsAt02} min at 0.2 xp/s`)); + +console.log('\n=========== THE OPENING ROUTE ==========='); +console.log(' each row is one enemy type at the bottom and top of its level range'); +console.log(' swings = expected attacks including miss chance; whiff% = landed hits that deal 0\n'); + +const fails = []; +R.rows.forEach(r => { + if (r.missing) { console.log(` ${r.nm}: AREA MISSING (${r.ak})`); return; } + console.log(` ${r.nm} — char lv ${r.charLvl}, skills ${r.player.skillLvl}/${r.player.cap}, ` + + `STR ${Math.round(r.player.str)}, HP ${Math.round(r.player.hp)}`); + console.log(' enemy lv eHP eSTR hit% dmg whiff kill die'); + r.fights.forEach(f => { + [f.lo, f.hi].forEach(x => { + if (!x) return; + // The fight has to be winnable: both sides swing at roughly the same rate + // early on, so kill > die means the dummy wins. An exact tie is allowed — + // at tutorial scale these are single-digit integers coming out of a + // Math.ceil, and the base game's own opening fight is 15 against 15. + const bad = x.swingsToKill === Infinity || x.swingsToKill > 60 || + x.swingsToDie <= 3 || x.swingsToKill > x.swingsToDie || x.whiff > 25; + console.log(` ${bad ? '!!' : ' '} ${x.name.padEnd(18)} ${pad(x.lvl, 4)} ${pad(x.ehp, 6)} ${pad(x.estr, 6)}` + + ` ${pad(x.hitYou, 4)} ${pad(x.dOut.toFixed(1), 6)} ${pad(x.whiff + '%', 6)} ${inf(x.swingsToKill)} ${inf(x.swingsToDie)}`); + if (bad) fails.push(`${r.nm}: ${x.name} lv${x.lvl} — kill ${x.swingsToKill}, die ${x.swingsToDie}, whiff ${x.whiff}%`); + }); + }); + console.log(''); +}); + +console.log('errors:', errs.length ? errs : 'none'); +await b.close(); + +if (fails.length) { + console.error('UNPLAYABLE OR NEAR-UNPLAYABLE MATCHUPS:\n - ' + fails.join('\n - ')); + process.exit(1); +} +console.log('PASS — the opening route is winnable at every step.'); diff --git a/tests/econ.mjs b/tests/econ.mjs new file mode 100644 index 0000000..1d086fc --- /dev/null +++ b/tests/econ.mjs @@ -0,0 +1,203 @@ +import { launch, settle } from './lib/browser.mjs'; + +// The economy with the original's coin drop (none), measured against what the +// ladder asks you to pay. +// +// Income per hour is measured at every story tier from the sources actually +// open there -- fighting in each area (the real swings a kill takes, through +// dmg_calc, for a player at that tier's cap, with the area's real drop table +// sold at the shops' rate) and gathering at each resource node -- and the best +// one is taken. Then every price the progression REQUIRES is held to one rule: +// +// no required price costs more than 10% of the climb it gates, +// counted in hours of the best income open when you get there +// +// (or one hour of it, where the climb is shorter than ten). A realm pill gates +// the Circulate Qi climb from the realm below. The Jade Slips gate nothing, so +// all hundred of them together are held to 10% of everything the game pays +// from the moment the Archive opens. +// +// Optional purchases -- the spirit pills and the Root Cleansing Pill -- are held +// to a day of that income from the tier where each becomes worth buying. The Qi +// Settling Pill costs the breakthrough pill it follows (section 48), so it is +// held with them. +// +// The rule is held on the Original preset, the default: his fights, no coin +// drop. "Mod before 4.2" (scaled fights, a 15% coin drop, and the old curve, +// whose early climbs are an hour long) is printed for comparison and not held +// to it -- it is kept exactly as it was, prices included. +// +// PORT=8080 node tests/econ.mjs + +const HOST = `http://127.0.0.1:${process.env.PORT || 8080}`; +const b = await launch(); +const errs = []; +const fail = []; +const check = (c, what) => { if (!c) fail.push(what); console.log(` ${c ? 'ok ' : 'FAIL'} ${what}`); }; + +async function run(preset) { + const p = await b.newPage(); + p.on('pageerror', e => errs.push(`[${preset}] ` + String(e).slice(0, 200))); + await p.goto(`${HOST}/index.html`, { waitUntil: 'load' }); + await settle(p); + const R = await p.evaluate((preset) => { + setPacing(preset); + const SAMPLES = 60; + const freshYou = () => { + you.lvl = 1; you.str_r = 1; you.agl_r = 1; you.int_r = 1; you.spd_r = 1; + you.hp_r = 39; you.hpmax = 39; you.hp = 39; you.sat_r = 200; you.satmax = 200; you.sat = 200; + you.stra = you.agla = you.inta = you.spda = you.hpa = you.sata = 0; + you.strm = you.aglm = you.intm = you.spdm = you.hpm = you.satm = 1; + you.stat_p = [1, 1, 1, 1]; you.mods.sbonus = 0; you.mods.cpwr = 1; + for (const k in skl) { const s = skl[k]; if (!s || typeof s !== 'object') continue; + s.lvl = 0; s.exp = 0; if (s.mlstn) s.mlstn.forEach(m => { m.g = false; }); } + global.flags.mod_hpm = 1; global.flags.mod_satm = 1; global.flags.mod_realm = 0; + you.stat_r(); + }; + const setTier = (ti) => { + global.flags.tr3_win = ti >= 1; + global.flags.mod_t_forest = ti >= 2; global.flags.mod_t_deep = ti >= 3; + global.flags.mod_t_cata = ti >= 4; global.flags.trne1e1 = ti >= 5; global.flags.trne3e1 = ti >= 6; + global.flags.trne4e1 = ti >= 6; + global.flags.mod_prog = { hollow: ti >= 7 ? 99 : 0, spire: ti >= 8 ? 99 : 0, vigil: ti >= 9 ? 99 : 0 }; + MOD_CAP.current = -1; return MOD_levelCap(); + }; + const CHARLVL = [1, 8, 14, 20, 28, 36, 46, 60, 76, 92]; + const buildPlayer = (ti) => { + freshYou(); const cap = setTier(ti); + for (let i = 1; i < CHARLVL[ti]; i++) { try { lvlup(you, 1); } catch (e) {} } + for (const k in skl) { const s = skl[k]; + if (!s || typeof s !== 'object' || k === 'rnwn') continue; + s.lvl = cap; + if (s.mlstn) s.mlstn.forEach(m => { m.g = false; + if (m.lv <= cap) { try { m.f(); m.g = true; } catch (e) {} } }); } + you.stat_r(); + global.flags.inside = true; global.flags.isday = true; global.flags.btl = false; + global.flags.iscold = global.flags.iswet = global.flags.isdark = false; + you.hp = you.hpmax; you.sat = you.satmax; allbuff(you); + return cap; + }; + const quiet = (fn) => { const g = giveSkExp, c = global.flags.crti; giveSkExp = function () {}; + try { return fn(); } finally { giveSkExp = g; global.flags.crti = c; } }; + const mean = (a, d) => { let s = 0; for (let i = 0; i < SAMPLES; i++) { global.flags.crti = false; + s += Math.max(0, Math.round(abl.default.f(a, d))); } return s / SAMPLES; }; + + // The author's coin drop, averaged over its own rand() -- zero on Original. + const coinPerKill = (m) => { + const ch = you.mods.enmondren; if (!(ch > 0)) return 0; + const lo = m.lvl << 0, hi = (m.lvl / 4) << 0, ex = (1 + m.rnk / 5) << 0; + let s = 0; for (let v = Math.min(lo, hi); v <= Math.max(lo, hi); v++) s += 1 + v ** ex * you.mods.enmondrts; + return ch * s / (Math.abs(lo - hi) + 1); + }; + // One exchange a tick (index.html's ontick drives fght once a second), a + // kill never shorter than a tick, and the next spawn on the next one. + // Time spent getting your health back is charged as the share of it the + // fight took: a kill at kill/die of 0.4 costs 40% more time. + const areaIncome = (ak) => { + const z = area[ak]; let perKill = 0, ticks = 0, wsum = 0; + z.pop.forEach((e, i) => { + const w = z.popc && z.popc[i] ? z.popc[i][1] - z.popc[i][0] : 1 / z.pop.length; + const lv = Math.round((e.lvlmin + e.lvlmax) / 2); + global.current_z = z; const m = mon_gen(e.crt); lvlup(m, lv); + global.current_m = m; global.target = you.eqp[2]; global.t_n = 2; + allbuff(you); allbuff(m); + const hy = Math.max(0, Math.min(100, hit_calc(1))) / 100, hm = Math.max(0, Math.min(100, hit_calc(2))) / 100; + let o, inc; quiet(() => { o = mean(you, m); inc = mean(m, you); }); + const kill = o * hy > 0 ? m.hpmax / (o * hy) : Infinity; + const die = inc * hm > 0 ? you.hpmax / (inc * hm) : Infinity; + const t = Math.max(1, Math.ceil(kill)) * (1 + Math.min(kill / die, 1)) + 1; + let sell = 0; (e.crt.drop || []).forEach(d => { if (d.item) sell += (d.chance || 0) * MOD_sellPrice(d.item); }); + perKill += w * (sell + coinPerKill(m)); ticks += w * t; wsum += w; + }); + (z.drop || []).forEach(d => { if (d.item) perKill += wsum * (d.c || 0) * MOD_sellPrice(d.item); }); + return { perKill: perKill / wsum, sec: ticks / wsum, perHour: perKill / wsum * 3600 / (ticks / wsum) }; + }; + // where each source first opens, by tier index (MOD_TIERS) + const AREAS = { trn: 1, frstn1a2: 1, frstn2a2: 1, frstn1a3: 2, hmbsmnt: 2, frstn9a1: 3, + mod_hollow: 6, mod_spire: 7, mod_vigil: 8 }; + const NODES = { 2: 3, 3: 4, 4: 6, 5: 9 }; // node rung -> tier its gate opens at + const tiers = []; + for (let ti = 0; ti < 10; ti++) { + const cap = buildPlayer(ti); + const src = []; + Object.keys(AREAS).forEach(ak => { if (AREAS[ak] <= ti && area[ak]) { + const r = areaIncome(ak); src.push({ what: area[ak].name + ' (' + ak + ')', perHour: r.perHour, sec: r.sec, perKill: r.perKill }); } }); + Object.keys(NODES).forEach(t => { if (NODES[t] <= ti) { + const ch = MOD_gatherChance(); + const per = [MOD_MAT['ore' + t], MOD_MAT['weave' + t], MOD_MAT['ess' + t]].map(it => MOD_sellPrice(it)); + const avg = per.reduce((a, x) => a + x, 0) / per.length; + src.push({ what: MOD_craftTier(+t).node + ' (gathering)', perHour: ch * avg * 3600 }); } }); + src.sort((x, y) => y.perHour - x.perHour); + tiers.push({ ti, cap, best: src[0] || { what: 'nothing', perHour: 0 }, src }); + } + // realm climbs on the live curve, at Circulate Qi's own 0.9 a second + const realms = MOD_REALMS.filter(r => r.n >= 2).map(r => { + const prev = MOD_REALMS[r.n - 1]; + const hours = (MOD_xpToReach(r.qic) - MOD_xpToReach(prev.qic)) / 0.9 / 3600; + // Neither shop is inside the dojo: the Herbalist is in the marketplace and + // the Pill Tower on the Village Center, so a pill is bought at tier 1 at + // the earliest, whatever tier its Qi Circulation level opens in. + const ti = Math.max(MOD_TIERS.findIndex(t => t.cap >= r.qic), 1); + const shops = []; + [vendor.pha1, vendor.mod_pltwr].forEach(v => (v.items || []).forEach(e => { + if (e.item === item['mod_bp' + r.n]) shops.push({ shop: v === vendor.pha1 ? 'Herbalist' : 'Pill Tower', p: e.p }); })); + return { n: r.n, name: r.name, hours, ti, shops }; + }); + // the Archive opens with dj1rw6, the character level 30 rung: tier 4 here + let slips = 0; for (let i = 0; i < 100; i++) slips += Math.min(Math.round(MOD_CONV.slipBase * Math.pow(MOD_CONV.slipStep, i)), MOD_CONV.slipCap); + setTier(0); + // Things you may buy rather than must: the spirit pills and the Root + // Cleansing Pill. A spirit pill matters from the tier whose character level + // costs at least what it grants (before that it is more than a whole level + // and nobody needs it); the root pill from tier 1, when the root is rolled. + const lvlCost = l => 4 * l ** 3 + l ** 2; + const tierFor = exp => { for (let ti = 0; ti < CHARLVL.length; ti++) if (lvlCost(CHARLVL[ti]) >= exp) return ti; return CHARLVL.length - 1; }; + const optional = []; + [vendor.pha1, vendor.mod_pltwr].forEach(v => (v.items || []).forEach(e => { + const pill = MOD_PILLS.find(pp => item[pp[0]] === e.item); + if (pill) optional.push({ name: e.item.name, p: e.p, ti: tierFor(pill[3]), why: `grants ${pill[3].toLocaleString()} exp` }); + else if (e.item === item.mod_rootpill) optional.push({ name: e.item.name, p: e.p, ti: 1, why: 'the root is rolled at tier 1' }); + })); + return { tiers, realms, slips, slipsFrom: 4, optional, fights: getFights(), coin: MOD_MONEY.chance }; + }, preset); + await p.close(); + return R; +} + +const fmt = n => n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e4 ? (n / 1e3).toFixed(1) + 'K' : Math.round(n).toLocaleString(); +for (const preset of ['original', 'fast']) { + const R = await run(preset); + console.log(`\n=== pacing '${preset}': fights ${R.fights}, coin drop ${Math.round(R.coin * 100)}%`); + console.log(' tier cap best income an hour, and where'); + R.tiers.forEach(t => console.log(` ${String(t.ti).padStart(4)} ${String(t.cap).padStart(3)} ${fmt(t.best.perHour).padStart(8)} ${t.best.what}` + + (t.best.sec ? ` (${t.best.sec.toFixed(1)}s a kill, ${t.best.perKill.toFixed(1)} a kill)` : ''))); + console.log(' realm pill climb budget price hours of income'); + R.realms.forEach(r => { + const inc = R.tiers[r.ti].best.perHour; + const budget = Math.max(r.hours * 0.10, 1) * inc; + r.shops.forEach(s => { + const hrs = s.p / inc; + console.log(` ${String(r.n).padStart(2)} ${r.name.padEnd(26)} ${(r.hours.toFixed(0) + 'h').padStart(6)} ${fmt(budget).padStart(8)} ${fmt(s.p).padStart(8)} ${hrs.toFixed(1)}h (${s.shop})`); + if (preset === 'original') check(s.p <= budget, `[${preset}] realm ${r.n} pill at the ${s.shop}: ${fmt(s.p)} is ${hrs.toFixed(1)}h of income, ` + + `inside 10% of a ${r.hours.toFixed(0)}h climb`); + }); + if (!r.shops.length) console.log(` ${String(r.n).padStart(2)} ${r.name.padEnd(26)} (not sold)`); + }); + // everything the game pays from the Archive opening to the top: each realm's + // climb at the income of the tier it opens in + let pays = 0; R.realms.forEach(r => { if (r.ti >= R.slipsFrom) pays += r.hours * R.tiers[r.ti].best.perHour; }); + console.log(` all 100 Jade Slips: ${fmt(R.slips)}, against ${fmt(pays)} earned from the Archive to the cap`); + console.log(' optional price from tier hours of income'); + R.optional.forEach(o => { + const hrs = o.p / R.tiers[o.ti].best.perHour; + console.log(` ${o.name.padEnd(30)} ${fmt(o.p).padStart(8)} ${String(o.ti).padStart(9)} ${hrs.toFixed(1)}h (${o.why})`); + if (preset === 'original') check(hrs <= 24, `[${preset}] ${o.name}: ${fmt(o.p)} is ${hrs.toFixed(1)}h of income when it becomes worth buying, inside a day`); + }); + if (preset === 'original') check(R.slips <= pays * 0.10, `[${preset}] folding every skill is ${(R.slips / pays * 100).toFixed(1)}% of that, inside 10%`); +} + +console.log('\nerrors:', errs.length ? errs : 'none'); +if (errs.length) fail.push('page errors'); +await b.close(); +if (fail.length) { console.error('\nFAIL:\n - ' + fail.join('\n - ')); process.exit(1); } +console.log('\nPASS — on Original pacing every price the ladder requires is inside 10% of the climb it gates.'); diff --git a/tests/fights.mjs b/tests/fights.mjs new file mode 100644 index 0000000..42b6b4f --- /dev/null +++ b/tests/fights.mjs @@ -0,0 +1,262 @@ +import { launch, settle } from './lib/browser.mjs'; + +// Fights: Original (section 47), against the author's own page. +// +// Two pages from one server; in the first the inside content closing the block early, so that is the\n one thing scrubbed. */\n safe: function (h) {\n return String(h === undefined || h === null ? '' : h).replace(/<\\/script/gi, '<\\\\/script');\n }\n};\n\n/* Descriptions carry real HTML (coloured spans, the game's separator div), so\n they are passed through rather than escaped — but the separator becomes a\n rule, since a wiki row is not a tooltip. */\nfunction MOD_wikiDesc(d) {\n var s;\n try { s = (typeof d === 'function') ? d() : d; } catch (e) { s = ''; }\n if (!s) return '';\n s = String(s).split('
 
').join('
');\n return MOD_WIKI.safe(s);\n}\n\nfunction MOD_wikiNum(n) {\n if (!isFinite(n)) return '—';\n return Math.round(n).toLocaleString();\n}\n\n/* An effect line at a level the player has not reached yet. The effect\n functions read the skill they are handed, so the level is borrowed and put\n back; anything that throws simply gets no projection. */\nfunction MOD_wikiEffectAt(key, sk, lvl) {\n var eff = MOD_EFFECTS[key];\n if (!eff) return '';\n var was = sk.lvl, out = '';\n try { sk.lvl = lvl; out = eff(sk) || ''; }\n catch (e) { out = ''; }\n finally { sk.lvl = was; }\n return MOD_WIKI.safe(out);\n}\n\n/* --- page builders --------------------------------------------------------\n Each returns the inner HTML of one page. `wk-e` marks a searchable entry and\n `wk-g` a group that hides itself when all its entries are filtered out.\n ------------------------------------------------------------------------- */\n\nvar MOD_WIKI_PAGES = [];\n\nfunction MOD_wikiPage(id, label, build) {\n MOD_WIKI_PAGES.push({ id: id, label: label, build: build });\n}\n\n/* A collapsible group.\n\n Not decoration: with every skill, item and title listed, three of the eleven\n pages ran past 40,000 pixels — a hundred screens of scrolling, which is the\n same as not having the content at all. Collapsed, each of those pages opens\n as an index you can read in one screen and expand where you want.\n\n
rather than a click handler, so it still works with JavaScript off,\n the browser's own find can be made to reach inside it, and the summary is\n keyboard-reachable for free. `level` is the heading level for the summary,\n since a group is sometimes a section of a page and sometimes a subsection. */\nfunction MOD_wikiGroup(heading, count, inner, level) {\n var hn = 'h' + (level || 2);\n return '
<' + hn + '>' + heading +\n (count !== null && count !== undefined\n ? ' (' + count + ')' : '') +\n '
' + inner + '
';\n}\n\n/* --- 1. orientation ------------------------------------------------------- */\nMOD_wikiPage('start', 'Start here', function () {\n var cap = MOD_levelCap();\n var realm = MOD_realm();\n var h = '

proto23

' +\n '

An idle RPG by truezhangwei, plus a mod. You train skills by ' +\n 'doing things, the things you can do open up as the story does, and none of ' +\n 'it is in a hurry.

' +\n\n '

Where you are right now

' +\n '' +\n '' +\n '' +\n '' +\n '' +\n '
Character level' + MOD_wikiNum(you.lvl) + '
Skill level cap' + cap + ' — ' +\n MOD_WIKI.safe(MOD_CAP.name) + '
Cultivation realm' + MOD_WIKI.safe(realm.name) +\n ' (' + realm.n + ' of ' + (MOD_REALMS.length - 1) + ')
Titles held' + MOD_titleCount() + ' — renown ' +\n (skl.rnwn ? skl.rnwn.lvl : 0) + '
Coin' + MOD_wikiNum(you.wealth || 0) + '
' +\n\n '

The four things worth knowing early

' +\n '
' +\n '
Your skill levels are capped by the story, not by grinding.
' +\n '
The cap starts at 10 and ends at 110, and it moves when you reach a new ' +\n 'place — not when you earn enough experience. If a skill has stopped ' +\n 'levelling, you are not doing it wrong; you need to go somewhere. See ' +\n 'Progression.
' +\n\n '
Armour is subtraction, so levels matter more than they look.
' +\n '
A hit does the attacker\\'s STR minus the defender\\'s STR. That means a ' +\n 'small gap in strength is a large gap in damage, and a big enough one takes ' +\n 'your damage to nothing at all rather than merely reducing it. See ' +\n 'Mechanics.
' +\n\n '
Most skills have a parent that pulls them along.
' +\n '
Every section has a parent skill that earns a share of everything its ' +\n 'children do, and any child lagging behind the parent trains faster — up to ' +\n 'twice as fast at ' + MOD_PARENT.maxGap + ' levels behind. A neglected skill is ' +\n 'cheap to bring up later, which is the only reason having this many is ' +\n 'workable.
' +\n\n '
Cultivation is a second ladder, and it is not automatic.
' +\n '
Circulating qi levels a skill; the skill does not raise your realm. ' +\n 'Reaching the level opens a bottleneck, and breaking through it costs a pill ' +\n 'and can fail. See Cultivation.
' +\n '
' +\n\n '

What is the mod and what is the game

' +\n '

The game is index.html and it is entirely truezhangwei\\'s ' +\n 'work. Everything in mod.js is a modification of it and is not ' +\n 'endorsed by the author. Pages here mark added content with ' +\n 'mod where the distinction matters. The full ' +\n 'list of changes is in the changelog, on the bottom bar.

';\n return h;\n});\n\n/* --- 2. progression ------------------------------------------------------- */\nMOD_wikiPage('progress', 'Progression', function () {\n var cap = MOD_levelCap();\n var h = '

Progression

' +\n '

Skill levels are capped by how far the story has gone. Ten ' +\n 'rungs, 10 through 110. The cap applies to every skill at once; Renown is ' +\n 'the single exception, since it is counted rather than trained.

' +\n '' +\n '';\n\n MOD_TIERS.forEach(function (t) {\n var got = null;\n try { got = MOD_tierFlag(t); } catch (e) {}\n var here = (t.cap === cap);\n var need = t.needText ? t.needText\n : (t.flags.length ? t.flags.join(' or ') : 'the start of the game');\n h += '' +\n '' +\n '' +\n '' +\n '';\n });\n h += '
CapStageWhat opens it
' + t.cap + '' + MOD_WIKI.safe(t.name) + '' + MOD_WIKI.safe(need) + '' + (here ? 'you are here'\n : got ? 'reached' : '') + '
' +\n\n '

The experience curve

' +\n '

The game\\'s own skill curve is super-exponential — level 109 to 110 ' +\n 'alone costs ' + MOD_wikiNum(MOD_vanillaExpnext(109)) + ' experience, so the top of the ladder ' +\n 'is unreachable by a factor of about ten trillion. But the author only ever ' +\n 'designed skills up to level 15, and below that his curve is fine.

' +\n (MOD_XP.vanillaTo > 0\n ? '

So the mod keeps it. Below level ' + MOD_XP.vanillaTo + ' a skill costs ' +\n 'exactly what the original charges; from there it rises by ×' +\n MOD_XP.ratio.toFixed(4) + ' a level, a ratio solved so the climb to 110 takes ' +\n 'the same time it always did.'\n : '

You have chosen the Mod before 4.2 pacing: a plain geometric curve, ' +\n '×' + MOD_XP.ratio.toFixed(3) + ' a level from level 0, much faster than the ' +\n 'original early on. The Original pacing in settings puts back the ' +\n 'author\\'s own cost below level 10.') +\n ' Character level uses the game\\'s own curve, untouched.

' +\n '' +\n '';\n [1, 5, 9, 10, 15, 25, 50, 75, 90, 109].forEach(function (l) {\n h += '';\n });\n h += '
LevelExperience for the nextOriginal game
' + l +\n '' + MOD_wikiNum(MOD_expnextFor(l)) + '' +\n MOD_wikiNum(MOD_vanillaExpnext(l)) + '
';\n return h;\n});\n\n/* --- 3. skills ------------------------------------------------------------ */\nMOD_wikiPage('skills', 'Skills', function () {\n var cap = MOD_levelCap();\n var bySection = {};\n var keys = [];\n for (var k in skl) {\n var s = skl[k];\n if (!s || typeof s !== 'object' || !s.name) continue;\n keys.push(k);\n }\n keys.forEach(function (k) {\n var s = skl[k];\n var sec = MOD_sectionOf(s);\n (bySection[sec] = bySection[sec] || []).push(k);\n });\n\n var h = '

Skills

' +\n '

' + keys.length + ' skills in ' +\n Object.keys(MOD_SECTIONS).length + ' sections. Every one has perks at ' +\n MOD_LADDER.join(', ') + ', and every one that has milestones grants titles at ' +\n MOD_TITLE_RUNGS.join(', ') + '. Levels and ticks below are yours.

';\n\n Object.keys(MOD_SECTIONS).forEach(function (sec) {\n var list = bySection[sec];\n if (!list || !list.length) return;\n var parentKey = MOD_PARENT_KEY[sec];\n var g = '';\n if (parentKey && skl[parentKey]) {\n g += '

Parent skill: ' +\n MOD_WIKI.safe(skl[parentKey].bname || skl[parentKey].name) +\n ' — earns ' + Math.round(MOD_PARENT.feed * 100) +\n '% of what its children earn, and speeds up any child that lags behind it.

';\n }\n list.sort(function (a, b) { return skl[a].name < skl[b].name ? -1 : 1; });\n list.forEach(function (k) {\n var s = skl[k];\n var lvl = Number(s.lvl) || 0;\n g += '
' +\n '

' + MOD_WIKI.safe(s.bname || s.name) +\n (s.bname ? ' (' + MOD_WIKI.safe(s.name) + ')' : '') +\n '= cap ? ' capped' : '') + '\">lv ' + lvl +\n (lvl >= cap ? ' — at the cap' : '') + '

' +\n '
' + MOD_wikiDesc(s.desc) + '
';\n\n var at110 = MOD_wikiEffectAt(k, s, 110);\n if (at110) g += '
At level 110: ' + at110 + '
';\n\n if (s.mlstn && s.mlstn.length) {\n g += '';\n s.mlstn.forEach(function (m) {\n g += '' +\n '' +\n '' +\n '';\n });\n g += '
' + m.lv + '' + MOD_WIKI.safe(m.p || '—') + '' + (m.g === true ? '✓' : '') + '
';\n }\n g += '
';\n });\n h += MOD_wikiGroup(MOD_WIKI.safe(MOD_SECTIONS[sec]), list.length, g, 2);\n });\n return h;\n});\n\n/* --- 4. cultivation ------------------------------------------------------- */\nMOD_wikiPage('cultivation', 'Cultivation', function () {\n var now = MOD_realm();\n var elig = 0;\n try { elig = MOD_realmEligible().n; } catch (e) {}\n var qic = skl.qic ? skl.qic.lvl : 0;\n\n var h = '

Cultivation

' +\n '

Ten realms above Mortal. Circulating qi levels the Qi ' +\n 'Circulation skill; the skill does not advance the realm. Reaching the level ' +\n 'opens a bottleneck, and breaking through it costs a pill and can fail — the ' +\n 'pill is spent either way.

' +\n '

Where you are

' +\n '' +\n '' +\n '' +\n '' +\n '' +\n '' +\n '
Realm' + MOD_WIKI.safe(MOD_realmTitle()) + '
Spiritual Root' + MOD_WIKI.safe(MOD_root().name) + '
Qi Circulationlevel ' + qic + '
Insight' + MOD_insight() + '
Bottleneck open' +\n (elig > now.n ? 'yes — ' + MOD_WIKI.safe(MOD_REALMS[now.n + 1].name) +\n ' is within reach' : 'no') + '
Qi Deviation' + (MOD_deviated()\n ? MOD_deviationLeft() + ' in-game minutes left' : 'no') + '
' +\n '

Realm thresholds are the same levels that earn a title rank, so realm N ' +\n 'is the level at which a rank N title arrives. Every realm multiplies all ' +\n 'stats and health, and the multipliers do not stack with each other — the ' +\n 'realm you are in is the one that applies.

' +\n '' +\n '';\n\n MOD_REALMS.forEach(function (r) {\n var pill = r.n >= 1 ? item['mod_bp' + r.n] : null;\n h += '' +\n '' +\n '' +\n '' +\n '' +\n '' +\n '';\n });\n h += '
RealmNameQi Circ.StatsHealthPill
' + r.n + '' + MOD_WIKI.safe(r.name) + '
' +\n MOD_WIKI.safe(r.desc) + '
' + r.qic + '×' + r.mult + '×' + r.hp + '' + (pill ? MOD_WIKI.safe(pill.name) : '—') +\n '
' +\n\n '

Where the pills come from

' +\n '

Every breakthrough pill has a source in normal play. None of them are ' +\n 'craftable, and none are cheap — a bottleneck you can buy in bulk is not a ' +\n 'bottleneck.

' +\n '' +\n '' +\n '' +\n '' +\n '
RealmsSource
1The dojo instructor, when qi ' +\n 'circulation is first taught
2–5The Herbalist in the village ' +\n '(vendor.pha1), restocked
6–10The dojo\\'s Level Advancement ' +\n 'ladder, and the Pill Tower
' +\n '

Realm 1 is deliberately not sold at the marketplace: ' +\n 'the marketplace is behind a 40%-a-visit chance encounter, and realm 1 opens ' +\n 'long before that is reliable.

' +\n\n '

Breaking through

' +\n '

A bottleneck wants three things, which is what the genre says it wants: ' +\n 'a pill, insight, and enough consolidation to make the ' +\n 'attempt worth making. The pill and the insight are both spent whether the ' +\n 'attempt works or not.

' +\n '

The odds improve the further past the threshold you are, plus your root, ' +\n 'plus time sat in Closed Door Training, and cap short of certain.

' +\n '' +\n '';\n [0, 2, 4, 6, 8].forEach(function (o) {\n var base = Math.min(0.55 + o * 0.05 + MOD_root().odds, 0.95);\n h += '';\n });\n h += '
Levels past the thresholdChanceAfter full seclusion
+' + o + '' +\n Math.round(base * 100) + '%' +\n Math.round(Math.min(base + MOD_CULT.secludeOdds, 0.95) * 100) + '%
' +\n\n '

Insight

' +\n '

Insight is the half of the price that cannot be bought. It comes from ' +\n 'three places, and they are the three the genre names: meditating, fights you ' +\n 'nearly lost, and going somewhere you have not been.

' +\n '' +\n '' +\n '' +\n '' +\n '' +\n '
SourceRate
Circulate Qi, at a wall' +\n (MOD_CULT.meditateChance * 100).toFixed(1) + '% a second
Closed Door Training' +\n (MOD_CULT.secludeChance * 100).toFixed(1) + '% a second
A kill made under ' +\n Math.round(MOD_CULT.brinkFrac * 100) + '% health — ' +\n (MOD_FIGHTS.mode === 'original'\n ? 'the author\\'s areas rarely come close once you reach them; the three past ' +\n 'the golem arena and the rank duels do, within a few fights, as does an area ' +\n 'you walk into early'\n : 'every fight costs a share of your health, so a few in a row without resting') +\n '' +\n Math.round(MOD_CULT.brinkChance * 100) + '% a kill
Entering an area for the first timealways
' +\n '

The insight a realm asks for is sized to the climb to it: about ' +\n Math.round(MOD_CULT.wallShare * 100) + '% of the Circulate Qi time the climb took, ' +\n 'spent at the wall. Seclusion earns it three times as fast, and full ' +\n 'consolidation takes as long as that on average.

' +\n '' +\n '' +\n '';\n var wkHours = function (sec) {\n return sec >= 7200 ? (sec / 3600).toFixed(sec >= 36000 ? 0 : 1) + ' h' : Math.round(sec / 60) + ' min';\n };\n MOD_REALMS.forEach(function (r) {\n if (!r.n) return;\n h += '' +\n '' +\n '' +\n '' +\n '' +\n '';\n });\n h += '
RealmClimb (Circulate Qi, 1x)Insight to enterFull seclusionQi Deviation (in-game)
' + MOD_WIKI.safe(r.name) + '' + wkHours(MOD_realmClimbSec(r.n)) + '' + MOD_insightNeed(r.n) + '' + wkHours(MOD_secludeFull(r.n)) + '' + (MOD_devMinutes(r.n) / 1440).toFixed(1) + ' days
' +\n\n '

The nine layers

' +\n '

Each realm is nine layers deep, derived from where Qi Circulation sits ' +\n 'between this realm\\'s threshold and the next one\\'s — so breaking through ' +\n 'puts you at the first layer of the new realm on its own. Layers are worth ' +\n '×' + MOD_CULT.layerMult + ' stats and ×' + MOD_CULT.layerBody +\n ' body each above the first, and the top realm has no rung above it to ' +\n 'measure against, so it stays at its first layer.

' +\n '' +\n '';\n for (var li = 1; li <= MOD_CULT.layers; li++) {\n h += '';\n }\n h += '
LayerCalled
' +\n li + '' + MOD_LAYER_ORD[li] + ' layer, ' +\n MOD_WIKI.safe(MOD_layerStage(li)) + '
' +\n '

At the threshold of the next realm and before breaking ' +\n 'through, you are a half step to it — the genre\\'s own name for the ' +\n 'state this mod calls a bottleneck.

' +\n\n '

Closed Door Training

' +\n '

Seclusion does two things. It consolidates — a full sitting ' +\n '(the table above) is worth +' + Math.round(MOD_CULT.secludeOdds * 100) +\n '% on the attempt — and it shelters: a breakthrough attempted while ' +\n 'you are still sitting in it cannot end in Qi Deviation, only in a lost ' +\n 'pill. It will not start unless you are actually at a wall.

' +\n\n '

Qi Deviation

' +\n '

Failing a breakthrough out in the open leaves your cultivation base ' +\n 'unstable for as long as the seclusion you skipped (the table above, never ' +\n 'under ' + MOD_CULT.devMinutes + ' in-game minutes): stats at ×' +\n MOD_CULT.devStat + ', body at ×' + MOD_CULT.devBody + ', and no second ' +\n 'attempt until it passes. It clears on its own, or at once with a ' +\n 'Qi Settling Pill. It never kills — the death system stays out of it.

' +\n\n '

Heavenly Tribulation

' +\n '

From realm ' + MOD_CULT.tribFrom + ' up, a successful breakthrough is ' +\n 'answered. The cost is a fraction of your maximum health, so it is a gate on ' +\n 'walking in healthy rather than a damage race; the realm bonus you are ' +\n 'standing on is what pays it. Fall to it and the realm does not open.

' +\n '' +\n '';\n MOD_REALMS.forEach(function (r) {\n if (!r.n || r.n < MOD_CULT.tribFrom) return;\n h += '' +\n '' +\n '';\n });\n h += '
RealmBoltsOf your max HP
' + MOD_WIKI.safe(r.name) + '' + MOD_tribulationBolts(r.n) + '' + Math.round(MOD_tribulationCost(r.n) * 100) + '%
' +\n\n '

Spiritual Root

' +\n '

Rolled once, when the dojo finishes teaching you to fight. It is never a ' +\n 'dead end: the worst grade costs cultivation speed, not access, and a ' +\n 'Root Cleansing Pill moves you up a grade.

' +\n '' +\n '';\n var rootTotal = 0;\n MOD_ROOTS.forEach(function (r) { rootTotal += r.weight; });\n MOD_ROOTS.forEach(function (r) {\n h += '' +\n '' +\n '' +\n '' +\n '' +\n '';\n });\n h += '
GradeRootChanceCultivationBreakthrough
' + r.n + '' + MOD_WIKI.safe(r.name) + '
' +\n MOD_WIKI.safe(r.desc) + '
' + Math.round(r.weight / rootTotal * 100) + '%×' + r.xp + '' + (r.odds >= 0 ? '+' : '') +\n Math.round(r.odds * 100) + '%
';\n return h;\n});\n\n/* --- 5. elemental mastery ------------------------------------------------- */\nMOD_wikiPage('mastery', 'Elemental mastery', function () {\n var h = '

Elemental mastery mod

' +\n '

Six masteries, each with a technique that fires on its own ' +\n 'during a fight. Combat is automatic and there is no ability picker, so a ' +\n 'technique is a chance on each of your swings to become something else — one ' +\n 'that scales with INT and the element rather than with your weapon.

' +\n '

Nothing fires while you are still Mortal: the channels have to be open ' +\n 'first. Chance is ' + (MOD_TECH.perLevel * 100).toFixed(1) + '% per mastery ' +\n 'level plus ' + (MOD_TECH.perRealm * 100).toFixed(1) + '% per realm, capped at ' +\n Math.round(MOD_TECH.cap * 100) + '%; a technique lands for ×' +\n MOD_TECH.power + ' of a normal strike.

' +\n '' +\n '';\n\n var realm = 0;\n try { realm = MOD_realm().n; } catch (e) {}\n MOD_MASTERY.forEach(function (m) {\n var lvl = Number(m.skill.lvl) || 0;\n var ch = realm < 1 ? 0\n : Math.min(lvl * MOD_TECH.perLevel + realm * MOD_TECH.perRealm, MOD_TECH.cap);\n h += '' +\n '' +\n '' +\n '';\n });\n h += '
MasteryTechniqueLevelChance now
' +\n MOD_WIKI.safe(m.skill.name) + '' + MOD_WIKI.safe(m.name) + '' + lvl + '' + (realm < 1 ? 'Mortal'\n : (ch * 100).toFixed(1) + '%') + '
';\n return h;\n});\n\n/* --- 6. titles ------------------------------------------------------------ */\nMOD_wikiPage('titles', 'Titles', function () {\n var perm = MOD_permanentRank();\n var held = MOD_titleCount();\n var total = 0; for (var kk in ttl) if (ttl[kk] && ttl[kk].name) total++;\n\n var h = '

Titles

' +\n '

A title\\'s rank runs 1 to 10 and is derived from the level ' +\n 'that earns it, so a rank is a statement about where in the game you are. ' +\n 'Only the title you are wearing applies — until renown makes the lower ranks ' +\n 'permanent.

' +\n '

Where you are

' +\n '' +\n '' +\n '' +\n '
Titles held' + held + ' of ' + total + '
Renown level' + (skl.rnwn ? skl.rnwn.lvl : 0) +\n ' of ' + MOD_RENOWN_MAX + '
Ranks that apply without wearing' +\n (perm >= 1 ? '1–' + perm : 'none yet') + '
' +\n\n '

Ranks

' +\n '';\n MOD_RANK_AT.forEach(function (lv, i) {\n var st = MOD_rankStyle(i + 1);\n h += '' +\n '';\n });\n h += '
RankEarned from levelColour
' + (i + 1) + '' + lv + '' + '●●●' + '
' +\n\n '

Renown

' +\n '

Renown is not trained. It counts the titles you hold, and each level ' +\n 'multiplies every stat by ' + (1 + MOD_RENOWN_RATE) + ' — compounding, so ' +\n 'level ' + MOD_RENOWN_MAX + ' is ×' +\n (Math.round(MOD_renownMult(MOD_RENOWN_MAX) * 1000) / 1000) + '. At renown N, ' +\n 'every title of rank N or below applies permanently.

' +\n '' +\n '';\n MOD_RENOWN_STEPS.forEach(function (n, i) {\n h += '= n ? 'done' : '') + '\">' +\n '';\n });\n h += '
RenownTitles neededAll statsPassive ranks
' + (i + 1) +\n '' + n + '×' +\n (Math.round(MOD_renownMult(i + 1) * 1000) / 1000) + '1–' + (i + 1) + '
' +\n\n '

Every title

' +\n '

Grouped by the skill that grants it. What is left is either ' +\n 'a ladder of its own — creatures killed, items gathered, the ' +\n 'cultivation realms — or a one-off from the story.

';\n\n var groups = {}, fams = {}, loose = [];\n for (var k in ttl) {\n var t = ttl[k];\n /* Unlike the picker, the wiki KEEPS the author's broken entries and says\n so — same call as area.clg, which is listed and tagged \"nothing spawns\"\n rather than quietly dropped. A reader looking for why a title never\n appears is better served by the row than by its absence. */\n if (!t || t.name === undefined || t.name === null) continue;\n var sk = MOD_TITLE_SKILL[k];\n if (sk && skl[sk]) { (groups[sk] = groups[sk] || []).push(k); continue; }\n var stem = MOD_titleStem(k);\n if (stem) { (fams[stem] = fams[stem] || []).push(k); continue; }\n loose.push(k);\n }\n /* Same rule as the picker: a family of one is not a family. */\n Object.keys(fams).forEach(function (stem) {\n if (fams[stem].length < 2) { loose.push(fams[stem][0]); delete fams[stem]; }\n });\n var order = Object.keys(groups).sort(function (a, b) {\n return skl[a].name < skl[b].name ? -1 : 1;\n });\n\n var row = function (k) {\n var t = ttl[k];\n var st = MOD_rankStyle(t.rar);\n var gen = null;\n for (var i = 0; i < MOD_SKILL_TITLES.length; i++) {\n if (MOD_SKILL_TITLES[i].key === k) { gen = MOD_SKILL_TITLES[i]; break; }\n }\n /* The author left a handful unfinished: ttl.ddcd carries the literal string\n \"null\" for both name and description, and shpt2/shpt3/mone3 are blank.\n None is granted by anything, so none can be earned. */\n var broken = !t.name || t.name === 'null';\n return '
' +\n '' +\n MOD_WIKI.safe(t.name || '(unnamed)') + '' +\n (broken ? 'unfinished' : '') +\n 'rank ' + (t.rar || 1) + '' +\n (gen ? 'lv ' + gen.rung + ' · +' +\n Math.round(gen.xp * 100) + '% ' + MOD_WIKI.safe(skl[gen.skill].name) +\n ' exp' : '') +\n (t.have === true ? 'held' : '') +\n '
' + MOD_wikiDesc(t.desc) + '
';\n };\n\n var byRank = function (a, b) { return (ttl[a].rar || 1) - (ttl[b].rar || 1); };\n order.forEach(function (sk) {\n groups[sk].sort(byRank);\n h += MOD_wikiGroup(MOD_WIKI.safe(skl[sk].bname || skl[sk].name),\n groups[sk].length, groups[sk].map(row).join(''), 3);\n });\n /* The ladders that belong to no skill, by the same derivation the picker\n uses — so the two cannot drift apart. Sorted by the label, since that is\n what the reader scans. */\n var famList = Object.keys(fams)\n .map(function (stem) {\n fams[stem].sort(byRank);\n return { stem: stem,\n label: MOD_titleFamilyLabel(stem, fams[stem].map(function (k) { return ttl[k]; })) };\n })\n .sort(function (a, b) { return a.label < b.label ? -1 : a.label > b.label ? 1 : 0; });\n if (famList.length) {\n /* Nested one level down rather than sitting beside the skills. Eleven more\n top-level summaries cost ~530px on a page that is already the tallest in\n the wiki, and these are a different KIND of thing from a skill anyway. */\n var famInner = '', famTotal = 0;\n famList.forEach(function (f) {\n famTotal += fams[f.stem].length;\n famInner += MOD_wikiGroup(MOD_WIKI.safe(f.label), fams[f.stem].length,\n fams[f.stem].map(row).join(''), 4);\n });\n h += MOD_wikiGroup('Ladders of their own', famTotal, famInner, 3);\n }\n if (loose.length) {\n loose.sort(byRank);\n /* The story titles are the one group with no skill to divide them, and\n there are enough of them to need dividing. Rank rather than the alphabet:\n it is the axis they are already sorted on, and the one a reader cares\n about — a rank is a statement about where in the game a title comes from. */\n var inner;\n if (loose.length <= MOD_WIKI_ALPHA_AT) {\n inner = loose.map(row).join('');\n } else {\n var byRankNo = {};\n loose.forEach(function (k) {\n var r = Math.min(Math.max(Number(ttl[k].rar) || 1, 1), 10);\n (byRankNo[r] = byRankNo[r] || []).push(k);\n });\n inner = '';\n Object.keys(byRankNo).sort(function (a, b) { return a - b; }).forEach(function (r) {\n var st = MOD_rankStyle(r);\n inner += MOD_wikiGroup('Rank ' + r + '', byRankNo[r].length,\n byRankNo[r].map(row).join(''), 4);\n });\n }\n h += MOD_wikiGroup('From the story', loose.length, inner, 3);\n }\n return h;\n});\n\n/* --- 7. areas ------------------------------------------------------------- */\nMOD_wikiPage('areas', 'Areas & enemies', function () {\n var h = '

Areas & enemies

' +\n '

Every area, what lives in it and at what levels, and what it ' +\n 'drops. Enemy strength is not read off these levels — see the note below the ' +\n 'table.

';\n\n /* Two of the game's 21 areas are not places and listing them misleads:\n nwh \"Somewhere\" — where global.current_z parks whenever you are NOT in\n a fight, populated by creature.default (\"Nothing\").\n tst \"Test\" — reached only from chss.tst, which has id -1 and is in no\n sector, so nothing in the game can travel there.\n The test for \"is this a place\" is whether you can get to it, and neither\n can be. Everything else is listed whether or not you have been. */\n var MOD_WIKI_NOT_PLACES = { nwh: 1, tst: 1 };\n\n var keys = [];\n for (var k in area) {\n if (area[k] && area[k].pop && !MOD_WIKI_NOT_PLACES[k]) keys.push(k);\n }\n /* Ordered by the middle of the level band, which is the closest thing the\n game has to a difficulty order. */\n keys.sort(function (a, b) {\n var mid = function (z) {\n if (!z.pop.length) return 0;\n var s = 0; z.pop.forEach(function (e) { s += (e.lvlmin + e.lvlmax) / 2; });\n return s / z.pop.length;\n };\n return mid(area[a]) - mid(area[b]);\n });\n\n keys.forEach(function (k) {\n var z = area[k];\n /* An area whose baked bands are not finite can never spawn anything — see\n the note rendered under its table. Derived rather than listed by name, so\n it stays true if the game changes. */\n var broken = !z.popc || z.popc.length !== z.pop.length ||\n z.popc.some(function (band) { return !band || !isFinite(band[1] - band[0]); });\n h += '

' + MOD_WIKI.safe(z.name) +\n ' ' + k + '' +\n (broken ? 'nothing spawns' : '') +\n '' + (z.size === -1 ? 'endless' : z.size + ' to clear') +\n '

';\n if (z.pop.length) {\n /* The spawn share is NOT pop[i].c. z_bake normalises those weights into\n area.popc — a list of [lo,hi] bands that mon_gen rolls a uniform\n random against — and the weights do not have to sum to 1, so they are\n off wherever they don't. The Southern forest's .35/.45/.25 are really\n 33/43/24%. Read the bands the game actually rolls against. */\n var bands = z.popc || [];\n h += '';\n z.pop.forEach(function (e, i) {\n var c = e.crt || {};\n var band = bands[i];\n var share = band ? band[1] - band[0] : NaN;\n h += '' +\n '' +\n '';\n });\n h += '
' + MOD_WIKI.safe(c.name || '?') + 'lv ' + e.lvlmin +\n (e.lvlmax !== e.lvlmin ? '–' + e.lvlmax : '') + '' + (isFinite(share)\n ? Math.round(share * 100) + '% of spawns'\n : '—') + '
';\n if (broken) {\n h += '
Nothing spawns here. The area\\'s entries carry ' +\n 'no spawn weight, so the table the game rolls against comes out as ' +\n 'NaN and no creature can ever match it. A bug in the base game, left ' +\n 'as it is — nothing in the game travels to this area either.
';\n }\n }\n if (z.drop && z.drop.length) {\n var d = z.drop.map(function (x) {\n return MOD_WIKI.safe((x.item && x.item.name) || '?') +\n ' ' + ((x.c || 0) * 100).toFixed(2) + '%';\n }).join(', ');\n h += '
Drops: ' + d + '
';\n }\n h += '
';\n });\n\n var fightsOrig = MOD_FIGHTS.mode === 'original';\n h += '

How hard an enemy is

' +\n '

It depends on the Fights setting, which is ' +\n (fightsOrig ? 'Original' : 'Scaled') + ' now. On Original (the default) ' +\n 'every area the author wrote is exactly as he wrote it: his creatures, his ' +\n 'level ranges, and you out-grow them as you do in his game. On Scaled every ' +\n 'area is sized to you. The three areas past the golem arena and the rank ' +\n 'duels are sized to you either way.

' +\n '

Sizing to you works like this. Enemies are not fitted to a level. Each spawn is solved against your ' +\n 'actual power at the moment it appears, aiming at roughly ' + MOD_ENEMY.kill +\n ' swings to kill it and ' + MOD_ENEMY.die + ' before it kills you, with a ' +\n 'guaranteed margin between the two so a fight is never a coin flip. Variety ' +\n 'comes from the area\\'s level band and each creature\\'s own stat weighting, ' +\n 'not from difficulty scaling.

' +\n '

The reason for that: within the first stage alone you go from 1 STR to ' +\n 'around 265, so anything anchored to a level is either trivial at the end of ' +\n 'a stage or impossible at the start of one.

';\n return h;\n});\n\n/* --- 8. items -------------------------------------------------------------\n The one place where \"list everything\" needs help: 321 of the game's 371\n plain items are the same inventory type, so grouping by type alone leaves a\n single group of 321. Anything past MOD_WIKI_ALPHA_AT gets an alphabetical\n sub-split as well — the oldest index pattern there is, and derived rather\n than invented, which a hand-made taxonomy would not be.\n ------------------------------------------------------------------------- */\n\nvar MOD_WIKI_ALPHA_AT = 60;\n\n/* The game's own inventory tabs (isort): ALL / WPN / EQP / USE / OTHER. Using\n its categories rather than new ones means a group here is a tab there. */\nvar MOD_WIKI_STYPE = { 2: 'Weapons', 3: 'Equipment', 4: 'Usable', 5: 'Other' };\n\nfunction MOD_wikiItemRow(it) {\n var val = it.v !== undefined ? it.v : (it.val !== undefined ? it.val : null);\n return '
' +\n '

' + MOD_WIKI.safe(it.name) +\n (it.rar > 1 ? 'rarity ' + it.rar + '' : '') +\n (val !== null ? '' + MOD_wikiNum(val) + ' coin' : '') +\n (it.amount > 0 ? '×' + it.amount + '' : '') +\n '

' + MOD_wikiDesc(it.desc) + '
';\n}\n\n/* The letter a name files under. Several items are quoted — the master's\n manuals are literally named \"Sword Saint Manual\" — and filing those under a\n quotation mark is no use to anyone looking for S. */\nfunction MOD_wikiLetter(name) {\n var m = String(name || '').replace(/^[^0-9A-Za-z]+/, '');\n return (m.charAt(0) || '?').toUpperCase();\n}\n\nfunction MOD_wikiByLetter(a, b) {\n var x = MOD_wikiLetter(a.name) + String(a.name || '').toLowerCase();\n var y = MOD_wikiLetter(b.name) + String(b.name || '').toLowerCase();\n return x < y ? -1 : (x > y ? 1 : 0);\n}\n\n/* Flat below the threshold, alphabetically sub-grouped above it. Ranges are cut\n from the entries actually present, so they stay even as content is added. */\nfunction MOD_wikiAlpha(entries, render, level) {\n if (entries.length <= MOD_WIKI_ALPHA_AT) return entries.map(render).join('');\n var per = Math.ceil(entries.length / Math.ceil(entries.length / 40));\n var out = '';\n for (var i = 0; i < entries.length; i += per) {\n var chunk = entries.slice(i, i + per);\n var first = MOD_wikiLetter(chunk[0].name);\n var last = MOD_wikiLetter(chunk[chunk.length - 1].name);\n out += MOD_wikiGroup(first === last ? first : first + '–' + last,\n chunk.length, chunk.map(render).join(''), level || 4);\n }\n return out;\n}\n\nMOD_wikiPage('items', 'Items', function () {\n var h = '

Items

' +\n '

Everything the game defines, whether or not you have seen ' +\n 'it. Grouped the way your inventory groups it. Values are the base sell ' +\n 'price where the game gives one.

';\n\n /* item[] first, split by the inventory tab it appears under. */\n var buckets = {};\n for (var k in item) {\n var it = item[k];\n if (!it || !it.name || it.name === 'dummy') continue;\n var lab = MOD_WIKI_STYPE[it.stype] || 'Uncategorised';\n (buckets[lab] = buckets[lab] || []).push(it);\n }\n ['Usable', 'Other', 'Weapons', 'Equipment', 'Uncategorised'].forEach(function (lab) {\n var list = buckets[lab];\n if (!list || !list.length) return;\n list.sort(MOD_wikiByLetter);\n h += MOD_wikiGroup(lab, list.length,\n MOD_wikiAlpha(list, MOD_wikiItemRow, 4), 2);\n });\n\n /* and the equipment objects, which live in their own globals */\n [['Weapons', wpn], ['Armour', eqp], ['Shields', sld], ['Accessories', acc]]\n .forEach(function (g) {\n var list = [];\n for (var kk in g[1]) {\n if (g[1][kk] && g[1][kk].name && g[1][kk].name !== 'dummy') list.push(g[1][kk]);\n }\n if (!list.length) return;\n list.sort(MOD_wikiByLetter);\n h += MOD_wikiGroup(g[0], list.length,\n MOD_wikiAlpha(list, MOD_wikiItemRow, 4), 2);\n });\n return h;\n});\n\n/* --- 9. actions ----------------------------------------------------------- */\nMOD_wikiPage('actions', 'Actions', function () {\n var unlock = {};\n MOD_ACTION_UNLOCKS.forEach(function (u) {\n if (u.act) unlock[u.act.id] = (skl[u.skill] ? skl[u.skill].name : u.skill) +\n ' level ' + u.lv;\n });\n if (typeof act.mod_qi !== 'undefined') {\n unlock[act.mod_qi.id] = 'clearing the dojo\\'s Easiest, Easy and Normal fights';\n }\n\n var h = '

Actions

' +\n '

What you can be doing. One at a time, and only where the ' +\n 'action makes sense — unless \"Unrestricted actions\" is ticked in settings, ' +\n 'which lets several run at once anywhere. Both limits are deliberate; the ' +\n 'switch is there because they are not to everyone\\'s taste.

';\n\n var keys = [];\n for (var k in act) if (act[k] && act[k].name && act[k].name !== 'dummy') keys.push(k);\n keys.sort(function (a, b) { return act[a].name < act[b].name ? -1 : 1; });\n\n keys.forEach(function (k) {\n var a = act[k];\n var how = unlock[a.id];\n h += '
' +\n '

' + MOD_WIKI.safe(a.name) +\n (a.have === true ? 'unlocked'\n : 'locked') +\n (how ? 'from ' + MOD_WIKI.safe(how) + '' : '') +\n '

' + MOD_wikiDesc(a.desc) + '
';\n });\n return h;\n});\n\n/* --- 10. the dojo --------------------------------------------------------- */\nMOD_wikiPage('dojo', 'The dojo', function () {\n var h = '

The dojo

' +\n '

The instructor promises a reward every five levels. The base ' +\n 'game stops delivering at 30; the mod carries the same ladder to 110, on the ' +\n 'same terms.

' +\n '

Only the lowest rung you have not claimed is offered, which is how the ' +\n 'first six behave. Rewards are sized against the character experience curve ' +\n '(4×level3 + level2) — a level at 110 costs 5.3 ' +\n 'million, which is why the base game\\'s best pill is worth 0.3% of one there.

' +\n '' +\n '';\n\n MOD_DOJO_RUNGS.forEach(function (r) {\n var pill = item[r.pill];\n var extra = [];\n if (r.realmPill) extra.push('breakthrough pill for realm ' + r.realmPill);\n if (r.manual) extra.push('a choice of master\\'s manual');\n h += '' +\n '' +\n '' +\n '' +\n '' +\n '';\n });\n h += '
LevelRewardCoinAlso
' + r.lv + '' + (pill ? MOD_WIKI.safe(pill.name) + ' ×' + r.n\n : '—') + '' + MOD_wikiNum(r.coin) + '' + (extra.join(', ') || '—') + '' + (global.flags[r.flag] ? 'claimed' : '') +\n '
' +\n\n '

Spirit pills

' +\n '

Character experience in a bottle. The four the mod adds continue the ' +\n 'game\\'s own series up to the new curve.

' +\n '' +\n '';\n MOD_PILLS.forEach(function (p) {\n h += '';\n });\n h += '
PillExperience
' + MOD_WIKI.safe(p[2]) + '' +\n p[3].toLocaleString() + '
' +\n\n '

Master\\'s manuals

' +\n '

A third grade of skillbook, offered at levels 50, 75 and 100 as a choice ' +\n 'the same way the first one was. Each is worth +' +\n Math.round(MOD_MANUAL_RATE * 100) + '% experience in its skill, permanently.

' +\n '' +\n '';\n MOD_MANUALS.forEach(function (m) {\n h += '';\n });\n h += '
ManualSkill
' + MOD_WIKI.safe(m[2]) + '' +\n MOD_WIKI.safe(skl[m[0]] ? (skl[m[0]].bname || skl[m[0]].name) : m[0]) +\n '
';\n return h;\n});\n\n/* --- 11. mechanics -------------------------------------------------------- */\nMOD_wikiPage('mechanics', 'Mechanics', function () {\n return '

Mechanics

' +\n\n '

Damage is subtractive, in both directions

' +\n '

A hit does the attacker\\'s strength minus the defender\\'s strength, plus ' +\n 'the weapon, plus one, before criticals. A defender\\'s STR is their ' +\n 'armour — there is no separate armour stat doing that job.

' +\n '

Two consequences worth carrying around:

' +\n '
    ' +\n '
  • Damage does not taper, it stops. Once a defender\\'s STR passes ' +\n 'yours, your hits do not get small; they land for nothing. Progress against ' +\n 'something too strong is not slow, it is zero.
  • ' +\n '
  • A stat ratio tells you nothing. Comparing your HP to an enemy\\'s ' +\n 'strength ignores the subtraction entirely and reads several times off in ' +\n 'either direction.
  • ' +\n '
' +\n '

This is also the reason enemies are never scaled by raising ' +\n 'their strength: it would raise their offence and their armour together, and ' +\n 'the second effect arrives first.

' +\n\n '

Criticals carry the late game

' +\n '

Critical rate reaches about a third at the top of the ladder and a ' +\n 'critical is roughly eight times a normal swing, so around three quarters of ' +\n 'all damage done at that point is critical damage. Anything that raises ' +\n 'critical rate or critical damage is worth more than it looks; Sharp Eye and ' +\n 'War are the two that do.

' +\n\n '

Hit chance

' +\n '

Landing a hit is a contest of agility. It is a ratio rather than a ' +\n 'subtraction, so agility behaves more gently than strength does — falling ' +\n 'behind on it costs you accuracy, not everything.

' +\n\n '

Skill sections and parents

' +\n '

Each of the ' + Object.keys(MOD_SECTIONS).length + ' sections has a parent ' +\n 'skill. A parent earns ' + Math.round(MOD_PARENT.feed * 100) + '% of everything ' +\n 'its children earn and grants no stats of its own. In return, a child below ' +\n 'the parent\\'s level trains ' + Math.round(MOD_PARENT.perLevel * 100) +\n '% faster per level of lag, up to ' + MOD_PARENT.maxGap + ' levels behind. ' +\n 'Bringing one section up makes every skill in it cheaper to catch up.

' +\n\n '

Saving

' +\n '

Three slots, switched from the bottom bar. The game builds its world once ' +\n 'at startup and cannot unload a save, so switching slots or starting a new ' +\n 'game reloads the page. Exporting a save before running anything unusual is ' +\n 'always the safe move.

';\n});\n\n/* --- the document ---------------------------------------------------------\n One string, no fetches, no dependencies: this has to work from file:// as\n readily as from a server.\n ------------------------------------------------------------------------- */\n\nfunction MOD_wikiCss() {\n return [\n ':root{color-scheme:dark}',\n '*{box-sizing:border-box}',\n /* Menlo and Consolas before the generic: with MS Gothic missing (any Mac)\n \"monospace\" is Courier, which Safari sets wider than Chrome does, and\n table cells wrapped on one and not the other. */\n 'body{margin:0;background:#060a1c;color:#dfe6f5;font:14px/1.6 \"MS Gothic\",' +\n '\"MS Mincho\",Menlo,Consolas,\"DejaVu Sans Mono\",monospace;display:flex;min-height:100vh}',\n 'a{color:#7cb0ff}',\n 'nav{width:210px;flex:0 0 210px;background:#081040;border-right:1px solid #46a;' +\n 'position:sticky;top:0;height:100vh;overflow:auto;padding:14px 0}',\n 'nav h2{font-size:13px;color:#9fb4d8;margin:0 0 10px 14px;letter-spacing:1px}',\n 'nav a{display:block;padding:6px 14px;text-decoration:none;color:#cfe0ff;' +\n 'border-left:3px solid transparent}',\n 'nav a:hover{background:#154080}',\n 'nav a.on{background:#123070;border-left-color:#7cb0ff;color:#fff}',\n 'nav .srch{margin:0 10px 12px;width:calc(100% - 20px);background:#050a20;' +\n 'color:#dfe6f5;border:1px solid #46a;padding:5px;font:inherit}',\n 'nav .xall{display:block;margin:12px 10px 0;color:#8296b8;font-size:12px;' +\n 'cursor:pointer;-webkit-user-select:none;user-select:none}',\n 'nav .xall input{vertical-align:-1px;margin-right:4px}',\n 'details.wk-g{border:1px solid #17224a;background:#0a1130;margin:8px 0}',\n 'details.wk-g>summary{cursor:pointer;padding:7px 12px;list-style:none;' +\n 'background:#0d1740}',\n 'details.wk-g>summary:hover{background:#154080}',\n 'details.wk-g>summary::-webkit-details-marker{display:none}',\n // a caret drawn rather than borrowed, so it is the same in every browser\n 'details.wk-g>summary::before{content:\"\\\\25B8\";color:#7cb0ff;' +\n 'display:inline-block;width:1em;transition:transform .12s}',\n 'details.wk-g[open]>summary::before{transform:rotate(90deg)}',\n 'details.wk-g>summary h2,details.wk-g>summary h3{display:inline;margin:0;' +\n 'font-size:15px;color:#fff}',\n '.wk-gi{padding:2px 12px 10px}',\n 'details.wk-g details.wk-g>summary h4{display:inline;margin:0;font-size:13px;' +\n 'color:#cfe0ff;font-weight:normal}',\n 'details.wk-g details.wk-g>summary{padding:5px 10px;background:#0b1435}',\n 'main{flex:1;min-width:0;padding:22px 30px 80px;max-width:1000px}',\n 'h1{font-size:26px;margin:0 0 6px;color:#fff;border-bottom:1px solid #46a;' +\n 'padding-bottom:8px}',\n 'h2{font-size:19px;color:#bcd4ff;margin:30px 0 8px}',\n 'h3{font-size:15px;color:#fff;margin:0 0 4px}',\n 'p.lede{color:#a8bcdc;margin:0 0 18px}',\n '.dim{color:#8296b8;font-weight:normal}',\n '.flav{font-size:12px;font-style:italic}',\n '.note{background:#0b1638;border-left:3px solid #46a;padding:8px 12px;' +\n 'color:#a8bcdc;margin:14px 0}',\n 'code,pre{background:#0b1638;border:1px solid #24356e;padding:1px 5px;' +\n 'color:#9fd0ff}',\n 'pre{padding:10px;overflow-x:auto}',\n 'dl dt{color:#fff;margin-top:14px}',\n 'dl dd{margin:2px 0 0 0;color:#a8bcdc}',\n 'ul{color:#a8bcdc}',\n 'table.wk-tbl{border-collapse:collapse;width:100%;margin:10px 0;' +\n 'display:block;overflow-x:auto}',\n 'table.wk-tbl th{text-align:left;color:#9fb4d8;font-weight:normal;' +\n 'border-bottom:1px solid #46a;padding:5px 9px;white-space:nowrap}',\n 'table.wk-tbl td{border-bottom:1px solid #17224a;padding:5px 9px;' +\n 'vertical-align:top}',\n 'table.wk-tbl tr.done td{color:#cfe0ff}',\n 'table.wk-tbl tr.here{background:#123070}',\n '.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}',\n '.wk-e{border:1px solid #17224a;background:#081040;padding:9px 12px;' +\n 'margin:7px 0;border-radius:2px}',\n 'tr.wk-e{border:0;background:none}',\n '.wk-e.done{border-color:#2a4a8c}',\n '.wk-e h3{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}',\n '.lv{margin-left:auto;font-size:12px;color:#8296b8;font-weight:normal}',\n '.lv.capped{color:#ffd24a}',\n '.rank{font-size:12px;color:#8296b8;font-weight:normal}',\n '.desc{color:#a8bcdc;font-size:13px}',\n '.proj{color:#ffd24a;font-size:12px;margin-top:3px}',\n '.tag{font-size:11px;border:1px solid #46a;color:#9fb4d8;padding:0 5px;' +\n 'border-radius:2px;font-weight:normal}',\n '.tag.mod{border-color:#7a4ea8;color:#c07bff}',\n '.tag.done{border-color:#2e7d4f;color:#6fdc9a}',\n '.tag.here{border-color:#ffd24a;color:#ffd24a}',\n '.tag.warn{border-color:#a8562e;color:#ff9a5c}',\n 'table.wk-perks{border-collapse:collapse;margin:6px 0 0;width:100%}',\n 'table.wk-perks td{padding:2px 8px 2px 0;font-size:12px;color:#8296b8;' +\n 'border-bottom:1px solid #101a3c}',\n 'table.wk-perks tr.done td{color:#cfe0ff}',\n 'table.wk-perks td.tick{color:#6fdc9a;width:1em;text-align:right}',\n // fixed columns so the level bands line up between one area and the next\n 'table.wk-pop{table-layout:fixed}',\n 'table.wk-pop td:first-child{width:60%}',\n 'table.wk-pop td:nth-child(2){width:20%}',\n '.wk-title .tname{font-weight:bold}',\n '.wk-now{background:#0b1638;border:1px solid #24356e;padding:10px 14px;' +\n 'margin:0 0 18px;display:inline-block;min-width:320px}',\n '.wk-now h3{color:#9fb4d8;font-weight:normal;font-size:13px;' +\n 'letter-spacing:1px;margin-bottom:6px}',\n '.wk-now td{padding:2px 16px 2px 0}',\n '.wk-now td:last-child{color:#fff;text-align:right}',\n '.pg{display:none}.pg.on{display:block}',\n 'footer{color:#5a6c8c;font-size:12px;border-top:1px solid #17224a;' +\n 'margin-top:40px;padding-top:12px}',\n '@media(max-width:760px){body{display:block}nav{width:auto;height:auto;' +\n 'position:static;flex:none}main{padding:16px}}'\n ].join('\\n');\n}\n\n/* Filtering, page switching and the hash. Small enough to inline, and inlining\n it is the point — the document has to stand on its own. */\nfunction MOD_wikiJs() {\n return [\n 'var pages=[].slice.call(document.querySelectorAll(\".pg\"));',\n 'var links=[].slice.call(document.querySelectorAll(\"nav a[data-p]\"));',\n 'function show(id){',\n ' var found=false;',\n ' pages.forEach(function(p){var on=p.id===\"pg-\"+id;if(on)found=true;',\n ' p.className=\"pg\"+(on?\" on\":\"\")});',\n ' if(!found&&pages.length){pages[0].className=\"pg on\";id=pages[0].id.slice(3)}',\n ' links.forEach(function(a){a.className=a.dataset.p===id?\"on\":\"\"});',\n ' window.scrollTo(0,0);return id;}',\n 'links.forEach(function(a){a.addEventListener(\"click\",function(e){',\n ' e.preventDefault();location.hash=a.dataset.p;show(a.dataset.p)})});',\n 'window.addEventListener(\"hashchange\",function(){show(location.hash.slice(1))});',\n 'show(location.hash.slice(1)||\"start\");',\n // Search filters entries, hides groups left with none, and OPENS the groups\n // that still have some — a match hidden inside a collapsed group reads as\n // no match at all, which is worse than not having search.\n 'var box=document.getElementById(\"srch\");',\n 'var xall=document.getElementById(\"xall\");',\n 'var groups=[].slice.call(document.querySelectorAll(\"details.wk-g\"));',\n 'function filter(){',\n ' var q=box.value.trim().toLowerCase();',\n ' pages.forEach(function(p){',\n ' [].slice.call(p.querySelectorAll(\".wk-e\")).forEach(function(e){',\n ' e.style.display=(!q||e.textContent.toLowerCase().indexOf(q)>=0)?\"\":\"none\"})});',\n // Every page, not just the one on screen: the nav says how many entries\n // match in each, dims the ones with none, and Enter jumps to the first\n // page that has any. A prose page with no entries counts as one if its text\n // contains the query.\n ' links.forEach(function(a){',\n ' var pg=document.getElementById(\"pg-\"+a.dataset.p),n=0;',\n ' if(a.dataset.l===undefined)a.dataset.l=a.textContent;',\n ' if(q&&pg){var es=[].slice.call(pg.querySelectorAll(\".wk-e\"));',\n ' if(es.length)es.forEach(function(e){if(e.textContent.toLowerCase().indexOf(q)>=0)n++});',\n ' else if(pg.textContent.toLowerCase().indexOf(q)>=0)n=1}',\n ' a.dataset.n=n;a.style.opacity=(q&&!n)?\".4\":\"\";',\n ' a.textContent=a.dataset.l+(q&&n?\" (\"+n+\")\":\"\")});',\n ' groups.forEach(function(g){',\n ' var any=[].slice.call(g.querySelectorAll(\".wk-e\")).some(function(e){',\n ' return e.style.display!==\"none\"});',\n ' g.style.display=any?\"\":\"none\";',\n ' g.open=xall.checked||(!!q&&any)}); }',\n 'box.addEventListener(\"input\",filter);',\n 'box.addEventListener(\"keydown\",function(e){',\n ' if(e.key===\"Escape\"){box.value=\"\";filter()}',\n ' if(e.key===\"Enter\"&&box.value.trim()){',\n ' var cur=links.filter(function(a){return a.className===\"on\"})[0];',\n ' if(cur&&+cur.dataset.n)return;',\n ' var hit=links.filter(function(a){return +a.dataset.n>0})[0];',\n ' if(hit){location.hash=hit.dataset.p;show(hit.dataset.p)}}});',\n // Everything is collapsed by default because three of these pages run past\n // 40,000px open. This is the way back to one long document, which is what\n // the browser's own find needs.\n 'xall.addEventListener(\"change\",filter);'\n ].join('\\n');\n}\n\nfunction MOD_wikiBuild() {\n var nav = '', body = '';\n MOD_WIKI_PAGES.forEach(function (pg) {\n nav += '' +\n MOD_WIKI.safe(pg.label) + '';\n var inner;\n /* One page throwing must not take the wiki down with it — the page says so\n and the rest still renders. */\n try { inner = pg.build(); }\n catch (e) {\n inner = '

' + MOD_WIKI.safe(pg.label) + '

This page ' +\n 'could not be built: ' + MOD_WIKI.safe(e.message) + '

';\n console.warn('[mod] wiki page \"' + pg.id + '\" failed: ' + e.message);\n }\n body += '
' + inner + '
';\n });\n\n var stamp = new Date().toLocaleString();\n return '' +\n '' +\n '' + MOD_WIKI.safe(MOD_WIKI.title) + '' +\n '' +\n '' +\n '
' + body +\n '
Generated from the running game at ' + MOD_WIKI.safe(stamp) +\n ' — mod ' + MOD_WIKI.safe(MOD.version) + '. Everything here is read from the ' +\n 'live data, including your own save, so it cannot fall out of step with the ' +\n 'game. The game is truezhangwei\\'s; the mod is not endorsed by the author.' +\n '
' +\n '