shiny_test.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
-- The shiny system, headless. -- -- luajit mods/DramaticShapeVoxelMod/tests/shiny_test.lua [--mod=DIR] -- -- Run from the PROJECT ROOT (it requires src.pokemon.Stats, the engine's own -- shiny predicate -- the point being that we agree with the engine rather -- than carry a second copy of the rule). -- -- Two halves: -- -- the DV model that a decided mon really does satisfy the Gen 2 -- pattern, that a miss really does not, that the odds -- are the number they claim, and that the derived HP DV -- is kept legal. -- the recolour against a fixture of real (normal, shiny) colour -- pairs lifted from the verified texture set, so the -- Lua transform is checked against the Python that -- produced the shipped colours rather than against -- itself. package.path = "./?.lua;./?/init.lua;" .. package.path local args = {} for _, a in ipairs({ ... }) do local k, v = a:match("^%-%-([%w_]+)=(.*)$") if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end end local MOD = args.mod or "mods/DramaticShapeVoxelMod" -- ------- the mod namespace, enough of it local loaded = {} local V = {} function V.require(name) if loaded[name] == nil then loaded[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(V) end return loaded[name] end function V.data(name) return assert(loadfile(MOD .. "/data/" .. name .. ".lua"))(V) end V.mod = { log = { warn = function() end, info = function() end } } local Shiny = V.require("Shiny") local ShinyPalette = V.require("ShinyPalette") local Stats = require("src.pokemon.Stats") -- ------- a tiny harness local pass, fail = 0, 0 local function ok(cond, what) if cond then pass = pass + 1 else fail = fail + 1 io.write("FAIL: " .. what .. "\n") end end local function eq(got, want, what) ok(got == want, ("%s (got %s, want %s)") :format(what, tostring(got), tostring(want))) end -- always-hit and always-miss stand-ins for the odds roll local function hit() return 1 end local function miss() return 2 end local function mon(dvs, level, species) return { species = species, level = level, dvs = dvs, statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } } end -- ------- the DV model do local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 }) Shiny.decide(m, hit) ok(Shiny.isShiny(m), "a hit produces a mon the ENGINE calls shiny") ok(Stats.isShiny(m.dvs), "and the engine's own predicate agrees") eq(m.dvs.defense, 10, "defense pinned to 10") eq(m.dvs.speed, 10, "speed pinned to 10") eq(m.dvs.special, 10, "special pinned to 10") eq(m.shiny, true, "the cached flag is set") end do -- nearest legal Attack, so a shiny encounter is not also a stat reroll local cases = { [0] = 2, [4] = 3, [5] = 6, [9] = 10, [13] = 14, [15] = 15 } for from, want in pairs(cases) do local m = mon({ attack = from, defense = 0, speed = 0, special = 0, hp = 0 }) Shiny.decide(m, hit) eq(m.dvs.attack, want, ("attack %d moves to the nearest legal %d") :format(from, want)) end end do -- the HP DV is derived from the low bits of the other four; a write that -- forgets to resync it produces a mon no real game could make for atk = 0, 15 do local m = mon({ attack = atk, defense = 3, speed = 7, special = 1, hp = 0 }) Shiny.decide(m, hit) local want = (m.dvs.attack % 2) * 8 + (m.dvs.defense % 2) * 4 + (m.dvs.speed % 2) * 2 + (m.dvs.special % 2) eq(m.dvs.hp, want, "HP DV stays derived from the other four") end end do -- a MISS must not leave an accidentally-shiny mon shiny, or the rate is -- the requested one and 1/8192 in parallel local m = mon({ attack = 2, defense = 10, speed = 10, special = 10, hp = 8 }) ok(Stats.isShiny(m.dvs), "fixture starts out shiny by luck") Shiny.decide(m, miss) ok(not Shiny.isShiny(m), "a miss clears an accidentally-shiny mon") eq(m.shiny, nil, "and clears the cached flag rather than storing false") end do -- the odds are the number they claim. 1/1 must be every time; a large -- denominator must essentially never fire on a fixed stub. local saved = Shiny.ODDS_DENOM Shiny.setOdds(1) local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 }) Shiny.decide(m, function(_, hi) return math.random(1, hi) end) ok(Shiny.isShiny(m), "odds of 1 make every mon shiny") Shiny.setOdds(0) eq(Shiny.ODDS_DENOM, 1, "a denominator below 1 is refused") Shiny.setOdds(saved) eq(Shiny.ODDS_DENOM, saved, "and a sane one is accepted") end do -- ------- the row on the menu -- -- The ladder's first rung is both the default and the fallback, so the -- canonical 1:8192 has to be it: a player who never opens the menu, and a -- corrupted options.lua, must both land on the games' own rate. local s = Shiny.setting eq(s.values[1], 8192, "the ladder starts at the canonical rate") eq(s.labels[1], "1:8192", "and says so in the 1:# the row shows") eq(s.labels[#s.labels], "1:1", "the last rung is every encounter") eq(#s.values, #s.labels, "every rung has a label") for i = 2, #s.values do eq(s.values[i] * 2, s.values[i - 1], ("rung %d is twice as often as the one above"):format(i)) end -- and the roll READS it. Pulled rather than pushed, so a value written by -- the mod manager's page -- which notifies nobody -- is seen too. local before = s:read() s:setValue(512) Shiny.unpinOdds() eq(Shiny.odds(), 512, "the roll follows the row without being told") eq(Shiny.ODDS_DENOM, 512, "and the live field is written through") s:setIndex(before) Shiny.setOdds(8192) -- and pinned again, for the blocks below end do -- stats must follow the DVs, and a full-health mon must stay full: a wild -- mon that appears at less than full HP is visible in the first frame local Data = require("src.core.Data") local haveData = type(Data) == "table" and type(Data.pokemon) == "table" and Data.pokemon.PIKACHU ~= nil if haveData then local m = mon({ attack = 0, defense = 0, speed = 0, special = 0, hp = 0 }, 10, "PIKACHU") m.stats = Stats.calc(Data.pokemon.PIKACHU, 10, m.dvs, m.statExp) m.hp = m.stats.hp local before = m.stats.hp Shiny.decide(m, hit) ok(m.stats.hp >= before, "max HP tracks the raised DVs") eq(m.hp, m.stats.hp, "a full-health mon stays full after a restat") else io.write("note: no ROM-backed data; skipped the restat check\n") end end -- ------- the recolour do eq(type(ShinyPalette.forDex(6)), "table", "colours load for Charizard") local ch = ShinyPalette.forDex(6) eq(ch.slide.h, -136, "Charizard's hue slide is the Stadium value") eq(ch.slide.s, -6, "and its saturation step") ok(ShinyPalette.forDex(130).lut ~= nil, "Gyarados carries an explicit table, not a slide") ok(ShinyPalette.forDex(6).lut == nil, "and Charizard does not") end do -- alpha is never touched, and a texture with nothing to do comes back -- unchanged rather than rebuilt local fn = ShinyPalette.transform(ShinyPalette.forDex(6)) local px = string.char(222, 131, 123, 77) .. string.char(0, 0, 0, 0) local out = ShinyPalette.recolorTexels(px, fn) eq(#out, #px, "the texel string keeps its length") eq(out:byte(4), 77, "alpha survives the transform") eq(out:byte(8), 0, "and so does a fully transparent texel's alpha") end do local fixture = loadfile(MOD .. "/tests/shiny_palette_fixture.lua") if not fixture then io.write("note: no colour fixture; skipped the cross-check\n") else local rows = fixture() local worst, bad = 0, 0 for _, r in ipairs(rows) do local fn = ShinyPalette.transform(ShinyPalette.forDex(r[1])) local gr, gg, gb = r[2], r[3], r[4] if fn then gr, gg, gb = fn(r[2], r[3], r[4]) end local d = math.max(math.abs(gr - r[5]), math.abs(gg - r[6]), math.abs(gb - r[7])) if d > worst then worst = d end if d > 2 then bad = bad + 1 end end eq(bad, 0, ("%d colour pairs reproduce the shipped textures"):format(#rows)) -- a unit or two of drift is the two languages' float rounding; more than -- that means the colour model itself has diverged ok(worst <= 2, ("worst channel drift is %d, within rounding"):format(worst)) end end -- ------- the read side -- -- The extraction writes NNNs.dsm beside NNN.dsm; this is the other half of -- that contract. Driven off a directory of real packs when one is given -- (--packs=DIR, e.g. the extract test's --out), because the interesting -- cases are a shiny pack that EXISTS and one that does not, and both have -- to be real files for the fallback to be worth testing. if args.packs then local Vp = { mod = { log = { warn = function() end, info = function() end } } } local loadedP = {} function Vp.require(name) if loadedP[name] == nil then loadedP[name] = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))(Vp) end return loadedP[name] end -- stand in for the mod's own reader, pointed at the pack directory function Vp.mod:read(rel) local name = rel:match("([^/]+)$") local fp = io.open(args.packs .. "/" .. name, "rb") if not fp then return nil end local b = fp:read("*a") fp:close() return b end local StadiumPack = Vp.require("StadiumPack") StadiumPack.DIR = "." local normal = StadiumPack.load(6, false) local shiny = StadiumPack.load(6, true) ok(normal ~= nil, "the normal Charizard pack loads") ok(shiny ~= nil, "and so does the shiny one") if normal and shiny then eq(shiny.shiny, true, "the shiny model is flagged as such") eq(normal.shiny, nil, "and the normal one is not") ok(normal ~= shiny, "they are SEPARATE models, not one shared table") eq(#normal.prims, #shiny.prims, "same geometry") eq(normal.texCount, shiny.texCount, "same texture count") -- the point of the whole exercise: different pixels local differs = false for i = 1, math.min(#normal.textures, #shiny.textures) do if normal.textures[i].rgba ~= shiny.textures[i].rgba then differs = true break end end ok(differs, "and different texels") end -- a species with no shiny pack must fall back rather than vanish: that is -- what an install from before rev 3 looks like local missing = StadiumPack.load(999, true) eq(missing, nil, "an out-of-range species is still nil") end -- ------- end to end, through the engine's own constructor -- -- The wrap is the whole feature: if Pokemon.new does not carry the verdict, -- nothing downstream has anything to draw. Exercised against the real -- constructor and the fixture dataset rather than a stub, because what is -- being tested is precisely that we hooked the thing the game calls. do local okKit, T = pcall(require, "tests.modkit") local Data = okKit and T.fixtures and T.fixtures.load() local okPk, Pokemon = pcall(require, "src.pokemon.Pokemon") local species = Data and Data.pokemon and (Data.pokemon.PIKACHU and "PIKACHU" or next(Data.pokemon)) if not (okKit and okPk and species) then io.write("note: no fixture dataset; skipped the end-to-end check\n") else local ShinyBattle = V.require("ShinyBattle") ShinyBattle.install() ShinyBattle.install() -- twice: the wrap must not stack local saved = Shiny.ODDS_DENOM Shiny.setOdds(1) local m = Pokemon.new(Data, species, 7) ok(Shiny.isShiny(m), "a mon built at odds 1 comes out shiny") eq(m.shiny, true, "and carries the flag") eq(m.hp, m.stats.hp, "and is at full health despite the restat") ok(Stats.isShiny(m.dvs), "and the ENGINE agrees it is shiny") -- and at long odds it essentially never is: 400 draws at 1/8192 would -- fire about 5% of the time, so a single failure here is signal Shiny.setOdds(8192) local shinies = 0 for _ = 1, 400 do if Shiny.isShiny(Pokemon.new(Data, species, 7)) then shinies = shinies + 1 end end ok(shinies <= 2, ("400 mons at 1/8192 produced %d shinies") :format(shinies)) Shiny.setOdds(saved) end end io.write(("\n%d passed, %d failed\n"):format(pass, fail)) os.exit(fail == 0 and 0 or 1)