mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 06:00:51 +02:00
290 lines
12 KiB
Lua
290 lines
12 KiB
Lua
-- Shiny Pokemon: the one fact, and everywhere that asks it.
|
|
--
|
|
-- WHAT MAKES A MON SHINY HERE IS ITS DVs, and nothing else. Gen 1 has no
|
|
-- shininess of its own, but it does have the four DVs Gen 2 later read to
|
|
-- decide it, and the engine already ships that reading:
|
|
-- src/pokemon/Stats.lua:90 isShiny(dvs) -- Defense, Speed and Special all
|
|
-- exactly 10, Attack one of 2/3/6/7/10/11/14/15. The engine's own comment
|
|
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
|
-- This is that mod.
|
|
--
|
|
-- Deriving rather than storing is the whole design, and it buys a great
|
|
-- deal:
|
|
--
|
|
-- * It persists for free. DVs are already in every save, every PC box,
|
|
-- every trade. No new save field, no migration, and a save made before
|
|
-- this mod was installed already HAS shiny Pokemon in it -- they were
|
|
-- always there, nothing was ever drawn differently.
|
|
-- * It survives evolution. Evolution.apply recalculates stats from the
|
|
-- same dvs table and never touches it (src/pokemon/Evolution.lua:99),
|
|
-- so a shiny Bulbasaur is a shiny Venusaur without being told.
|
|
-- * It cannot desync. A flag stored beside the DVs is a second copy of
|
|
-- the truth, and two copies drift -- most cruelly across a trade or a
|
|
-- box deposit, where the mon travels and the sidecar does not.
|
|
-- * PKHeX and the Gen 2 games agree with us, because it is their rule.
|
|
--
|
|
-- The odds, though, are ours to set, and that is the one thing DVs alone
|
|
-- cannot give: random DVs land on that pattern 1/16 * 1/16 * 1/16 * 8/16 =
|
|
-- exactly 1/8192, the classic rate, and there is no dial on it. So the roll
|
|
-- happens at encounter time and its VERDICT IS WRITTEN BACK INTO THE DVs
|
|
-- (forceShiny/forceCommon below). The mon does not carry a flag saying it
|
|
-- is shiny; it is made genuinely shiny by the game's own formula, and every
|
|
-- later reader -- ours, the engine's, a future mod's, PKHeX's -- reaches the
|
|
-- same answer without knowing we were involved.
|
|
--
|
|
-- mon.shiny is maintained too, but it is a CACHE and never the source: see
|
|
-- Shiny.mark.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
-- allowlisted for mods by name -- src/mods/Loader.lua:71 lists
|
|
-- src.pokemon.Stats precisely so an indicator mod can call isShiny
|
|
local Stats = require("src.pokemon.Stats")
|
|
|
|
local ModSetting = V.require("ModSetting")
|
|
|
|
local Shiny = {}
|
|
|
|
-- ------- the odds
|
|
--
|
|
-- One in ODDS_DENOM. The default is 8192 because that is what random DVs
|
|
-- already produce, so a player who never changes it gets the canonical rate
|
|
-- and the canonical feel -- this mod's default is not a buff.
|
|
--
|
|
-- The roll is made EXACT rather than additive. A naive implementation rolls
|
|
-- 1/N and forces shiny on a hit, but leaves the natural 1/8192 in place on a
|
|
-- miss, so the true rate is N and 8192 in parallel -- indistinguishable at
|
|
-- the default and quietly wrong at every other setting (at 1/100 you would
|
|
-- ship 1/99.99, and at 1/20000 you could never go rarer than 1/8192 no
|
|
-- matter what you set). forceCommon on a miss closes that: the rate is what
|
|
-- the number says.
|
|
Shiny.ODDS_DENOM = 8192
|
|
|
|
-- ------- the row the player cycles
|
|
--
|
|
-- A ladder that HALVES, so every step is exactly "twice as often as the one
|
|
-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
|
|
-- rate is what the number says, not an approximation of it, because the
|
|
-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
|
|
-- 1:2 really is every other encounter.
|
|
--
|
|
-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
|
|
-- and the fallback for an unreadable or unrecognised stored value, so the
|
|
-- canonical rate is what a player who never opens the menu gets and what a
|
|
-- corrupted options.lua comes back to.
|
|
--
|
|
-- No rung RARER than 8192. The mod's promise is that its default is not a
|
|
-- change to the game; making the game harder than it ships is a different
|
|
-- promise and nobody asked for it.
|
|
local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
|
|
|
|
local ODDS_LABELS = {}
|
|
for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
|
|
|
|
Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
|
|
|
|
-- ------- the setting is PULLED, not pushed
|
|
--
|
|
-- decide() asks this every roll rather than the menu telling us when it
|
|
-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
|
|
-- settings page -- and only the first has a change hook to hang on; the
|
|
-- manager writes through mod.options and calls ModSetting:sync, which
|
|
-- notifies nothing. Pulling is the only way both are seen, and the cost is
|
|
-- a table read on an event that happens once per encounter.
|
|
--
|
|
-- ODDS_DENOM stays the live value and is written through on every ask, so
|
|
-- anything already reading that field keeps reading the truth.
|
|
local pinned = false
|
|
|
|
function Shiny.odds()
|
|
if not pinned then
|
|
local ok, value = pcall(Shiny.setting.get, Shiny.setting)
|
|
local n = ok and tonumber(value)
|
|
if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
|
|
end
|
|
return Shiny.ODDS_DENOM
|
|
end
|
|
|
|
-- Set the denominator BY HAND, which also PINS it: a driver or a test that
|
|
-- has asked for 1:1 means it, and must not have the next roll quietly put
|
|
-- back to whatever the player left on the menu. Nothing in the game calls
|
|
-- this -- the row is how a player changes the rate.
|
|
--
|
|
-- Guards the degenerate values because a 0 or a negative here would
|
|
-- divide-by-zero or make every encounter shiny by accident rather than by
|
|
-- choice; 1 (always shiny) stays reachable because it is genuinely useful
|
|
-- for walking the whole model set.
|
|
function Shiny.setOdds(denom)
|
|
denom = tonumber(denom)
|
|
if not denom or denom < 1 then return Shiny.ODDS_DENOM end
|
|
Shiny.ODDS_DENOM = math.floor(denom)
|
|
pinned = true
|
|
return Shiny.ODDS_DENOM
|
|
end
|
|
|
|
-- Hand the row back control, for a test that pinned the odds and wants the
|
|
-- setting to mean something again afterwards.
|
|
function Shiny.unpinOdds()
|
|
pinned = false
|
|
return Shiny.odds()
|
|
end
|
|
|
|
-- ------- reading it
|
|
|
|
-- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
|
|
-- list as well as the engine's set because forceShiny has to CHOOSE one and
|
|
-- wants the nearest, not just any.
|
|
local SHINY_ATK = { 2, 3, 6, 7, 10, 11, 14, 15 }
|
|
|
|
-- The HP DV is not free: Gen 1 derives it from the low bit of each of the
|
|
-- other four (src/pokemon/Stats.lua:19). Any write to the four must
|
|
-- recompute it, or the mon ends up with an HP stat the real game could
|
|
-- never produce -- which is exactly what a save inspector flags as illegal.
|
|
local function syncHpDv(dvs)
|
|
dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 +
|
|
(dvs.speed % 2) * 2 + (dvs.special % 2)
|
|
return dvs
|
|
end
|
|
|
|
-- The single question. Everything visual in this mod routes here.
|
|
function Shiny.isShiny(mon)
|
|
if type(mon) ~= "table" then return false end
|
|
return Stats.isShiny(mon.dvs) == true
|
|
end
|
|
|
|
-- ------- writing it
|
|
|
|
-- Make these DVs satisfy the pattern, moving them as little as it allows.
|
|
--
|
|
-- Defense, Speed and Special have exactly one legal value each, so they are
|
|
-- simply pinned. Attack has eight, and the nearest one to whatever was
|
|
-- rolled is chosen -- a mon rolled at Attack 15 keeps 15, one rolled at 0
|
|
-- becomes 2. That is not cosmetic: DVs are stats, and a shiny encounter
|
|
-- should not also be a stat reroll any larger than the pattern demands.
|
|
local function forceShiny(dvs)
|
|
local want, best, bestd = dvs.attack or 0, SHINY_ATK[1], nil
|
|
for _, v in ipairs(SHINY_ATK) do
|
|
local d = math.abs(v - want)
|
|
if not bestd or d < bestd then bestd, best = d, v end
|
|
end
|
|
dvs.attack = best
|
|
dvs.defense, dvs.speed, dvs.special = 10, 10, 10
|
|
return syncHpDv(dvs)
|
|
end
|
|
|
|
-- Make these DVs NOT satisfy the pattern, moving them as little as
|
|
-- possible: one step on Special is enough to break it, and Special is the
|
|
-- choice because in Gen 1 it is a single stat rather than the two Gen 2
|
|
-- split it into, so the disturbance stays inside one number.
|
|
--
|
|
-- Only ever reached by a mon that rolled non-shiny and happened to be shiny
|
|
-- by luck, which is 1/8192 of the time -- so this touches almost nothing,
|
|
-- and what it does touch it moves by one point.
|
|
local function forceCommon(dvs)
|
|
if (dvs.special or 0) == 10 then
|
|
dvs.special = 9
|
|
elseif (dvs.defense or 0) == 10 then
|
|
dvs.defense = 9
|
|
end
|
|
return syncHpDv(dvs)
|
|
end
|
|
|
|
-- mon.shiny: the cache.
|
|
--
|
|
-- The requirement is a flag ON the Pokemon, and this is it -- but it is
|
|
-- written from the DVs every time we touch a mon, never read as the truth.
|
|
-- Keeping it one-directional is what stops it becoming the second copy the
|
|
-- header warns about: if it ever disagrees with the DVs, the DVs win and
|
|
-- this is overwritten. It exists so other code -- and a save inspector, and
|
|
-- a companion mod -- can ask the cheap question without importing Stats.
|
|
function Shiny.mark(mon)
|
|
if type(mon) ~= "table" then return false end
|
|
local is = Shiny.isShiny(mon)
|
|
mon.shiny = is or nil -- nil rather than false: absent keeps saves clean
|
|
return is
|
|
end
|
|
|
|
-- Recalculate the stats a DV write invalidated.
|
|
--
|
|
-- Split out because both decide() and set() move DVs, and a mon left
|
|
-- carrying stats computed from its old DVs is wrong in the only way the
|
|
-- player can actually see: its HP bar.
|
|
local function restat(mon)
|
|
if not (mon.level and mon.species) then return end
|
|
local ok, data = pcall(require, "src.core.Data")
|
|
local def = ok and data and data.pokemon and data.pokemon[mon.species]
|
|
if not def then return end
|
|
local wasFull = mon.hp and mon.stats and mon.hp >= (mon.stats.hp or 0)
|
|
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
|
-- A wild mon appears at full health, and a mon that WAS full stays full:
|
|
-- recomputing max HP without following it here would put a freshly
|
|
-- encountered mon on the field at less than full from its first frame.
|
|
-- A wounded mon keeps its damage, clamped to the new maximum.
|
|
if mon.hp then
|
|
mon.hp = wasFull and mon.stats.hp or math.min(mon.hp, mon.stats.hp)
|
|
end
|
|
end
|
|
|
|
-- ------- our own randomness
|
|
--
|
|
-- A PRIVATE stream, not love.math.random, and that is deliberate.
|
|
--
|
|
-- The game's RNG is a shared sequence: damage rolls, crits, encounter
|
|
-- slots and DV generation all draw from it in a fixed order. Taking a draw
|
|
-- out of it for a shiny check would shift every later draw, so installing
|
|
-- this mod would quietly change the outcome of fights that have nothing to
|
|
-- do with shininess -- and the manifest promises `affects_link = false`,
|
|
-- which a shifted stream would make untrue the moment two machines
|
|
-- disagreed about whose turn consumed what.
|
|
--
|
|
-- Seeded off the clock rather than the save, because shininess is a fact
|
|
-- about the encounter and not about the file: re-loading a save to re-roll
|
|
-- a Pokemon is the hunt, and a stream keyed to the save would hand back the
|
|
-- same answer every time.
|
|
local stream = nil
|
|
|
|
local function roll(n)
|
|
if not stream then
|
|
if love and love.math and love.math.newRandomGenerator then
|
|
stream = love.math.newRandomGenerator(os.time(), os.clock() * 1e6)
|
|
else
|
|
-- headless (tests): math.random is nobody's shared sequence there
|
|
stream = { random = function(_, a, b) return math.random(a, b) end }
|
|
end
|
|
end
|
|
return stream:random(1, n)
|
|
end
|
|
|
|
-- Decide a freshly-built mon, in place.
|
|
--
|
|
-- rng may be passed to pin the verdict -- a test hands us a stub. Left nil,
|
|
-- the private stream above is used.
|
|
function Shiny.decide(mon, rng)
|
|
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
|
-- same shape as love.math.random(lo, hi), so a caller can pass that or a
|
|
-- stub and the call below reads identically either way
|
|
rng = rng or function(_lo, hi) return roll(hi) end
|
|
local hit = rng(1, Shiny.odds()) == 1
|
|
|
|
if hit then
|
|
forceShiny(mon.dvs)
|
|
restat(mon)
|
|
elseif Stats.isShiny(mon.dvs) then
|
|
forceCommon(mon.dvs)
|
|
restat(mon)
|
|
end
|
|
return Shiny.mark(mon)
|
|
end
|
|
|
|
-- Force a specific verdict: for tests, and for a scripted gift mon that
|
|
-- wants to be shiny on purpose.
|
|
function Shiny.set(mon, on)
|
|
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
|
if on then forceShiny(mon.dvs) else forceCommon(mon.dvs) end
|
|
restat(mon)
|
|
return Shiny.mark(mon)
|
|
end
|
|
|
|
return Shiny
|