mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
1242 lines
52 KiB
Lua
1242 lines
52 KiB
Lua
-- The Game Corner: the slot machine's reels, bias table and payout ladder, card
|
|
-- flip's deck and 6x8 board, the three prize counters' refusal ladders, and the
|
|
-- coin case's clamps at both ends.
|
|
--
|
|
-- ROM-free and draw-free. Every decision these screens make is a pure function
|
|
-- taking a `random(n) -> 0..n-1`, so the odds tables can be driven with a
|
|
-- counted RNG rather than sampled: an assertion here says "this byte selects
|
|
-- this symbol", which is a statement about the transcription, not about luck.
|
|
-- The distribution checks that follow are the sanity pass over the same tables.
|
|
|
|
package.path = "./?.lua;" .. package.path
|
|
|
|
-- The UI modules pull love-side helpers in at load time. Stub what they touch;
|
|
-- nothing here draws.
|
|
love = love or {}
|
|
love.graphics = love.graphics or {
|
|
getColor = function() return 1, 1, 1, 1 end,
|
|
setColor = function() end,
|
|
rectangle = function() end,
|
|
print = function() end,
|
|
printf = function() end,
|
|
draw = function() end,
|
|
newQuad = function() return {} end,
|
|
newImage = function() return nil end,
|
|
getShader = function() return nil end,
|
|
setShader = function() end,
|
|
newShader = function() error("no shaders in this harness") end,
|
|
getDimensions = function() return 160, 144 end,
|
|
push = function() end, pop = function() end,
|
|
translate = function() end, scale = function() end,
|
|
circle = function() end, clear = function() end,
|
|
}
|
|
love.math = love.math or {
|
|
random = function(a, b)
|
|
if b then return a end
|
|
return a and 1 or 0.5
|
|
end,
|
|
}
|
|
love.image = love.image or {}
|
|
love.filesystem = love.filesystem or {
|
|
load = function() return nil end,
|
|
getInfo = function() return nil end,
|
|
read = function() return nil end,
|
|
write = function() return true end,
|
|
remove = function() return true end,
|
|
}
|
|
love.timer = love.timer or { getTime = function() return 0 end }
|
|
|
|
require("src.core.Logger").warn = function() end
|
|
|
|
local CardFlip = require("src.ui.gen2.CardFlip")
|
|
local PrizeMenu = require("src.ui.gen2.PrizeMenu")
|
|
local Save = require("src.core.gen2.Save")
|
|
local SlotMachine = require("src.ui.gen2.SlotMachine")
|
|
|
|
local failures, checks = 0, 0
|
|
local function check(name, got, want)
|
|
checks = checks + 1
|
|
if got ~= want then
|
|
failures = failures + 1
|
|
print(("FAIL %s: got %s, want %s"):format(
|
|
name, tostring(got), tostring(want)))
|
|
end
|
|
end
|
|
|
|
-- A scripted RNG: hand it the bytes the cart's Random would return, in order.
|
|
-- `random(n)` yields the next byte modulo n, which is how every caller here
|
|
-- consumes it (`random(256)` is the raw byte, `random(8)` the `and $7`).
|
|
local function scripted(bytes)
|
|
local i = 0
|
|
return function(n)
|
|
i = i + 1
|
|
local byte = bytes[i] or 0
|
|
return byte % (n or 256)
|
|
end
|
|
end
|
|
|
|
-- A deterministic linear congruential byte source for the distribution passes,
|
|
-- so a failure here is reproducible rather than a flake.
|
|
local function seeded(seed)
|
|
local state = seed
|
|
return function(n)
|
|
state = (state * 1103515245 + 12345) % 2147483648
|
|
return math.floor(state / 65536) % (n or 256)
|
|
end
|
|
end
|
|
|
|
local function newSave(coins, money)
|
|
local save = Save.newGame({ playerName = "GOLD" })
|
|
save.player.coins = coins or 0
|
|
save.player.money = money or 0
|
|
return save
|
|
end
|
|
|
|
-- ==================================================================== reels
|
|
--
|
|
-- Reel1Tilemap / Reel2Tilemap / Reel3Tilemap, including the three repeated
|
|
-- entries that let Slots_GetCurrentReelState read three bytes without wrapping.
|
|
local SEVEN = SlotMachine.SEVEN
|
|
local POKEBALL = SlotMachine.POKEBALL
|
|
local CHERRY, PIKACHU = SlotMachine.CHERRY, SlotMachine.PIKACHU
|
|
local SQUIRTLE, STARYU = SlotMachine.SQUIRTLE, SlotMachine.STARYU
|
|
|
|
for i = 1, 3 do
|
|
check("reel " .. i .. " is 18 entries", #SlotMachine.REELS[i], 18)
|
|
for j = 1, 3 do
|
|
check(("reel %d repeats entry %d"):format(i, j),
|
|
SlotMachine.REELS[i][15 + j], SlotMachine.REELS[i][j])
|
|
end
|
|
end
|
|
|
|
-- Each reel's SEVEN and POKEBALL count is what makes 300 coins so rare: reel 3
|
|
-- carries exactly one of each.
|
|
local function countOf(strip, symbol)
|
|
local n = 0
|
|
for i = 1, SlotMachine.REEL_SIZE do
|
|
if strip[i] == symbol then n = n + 1 end
|
|
end
|
|
return n
|
|
end
|
|
|
|
check("reel 1 has three SEVENs", countOf(SlotMachine.REELS[1], SEVEN), 2)
|
|
check("reel 1 has one POKEBALL", countOf(SlotMachine.REELS[1], POKEBALL), 1)
|
|
check("reel 2 has one SEVEN", countOf(SlotMachine.REELS[2], SEVEN), 1)
|
|
check("reel 2 has two POKEBALLs", countOf(SlotMachine.REELS[2], POKEBALL), 2)
|
|
check("reel 3 has one SEVEN", countOf(SlotMachine.REELS[3], SEVEN), 1)
|
|
check("reel 3 has one POKEBALL", countOf(SlotMachine.REELS[3], POKEBALL), 1)
|
|
|
|
-- Slots_GetCurrentReelState: slot 0 reads as if it were 15, and index 0 of the
|
|
-- window is the BOTTOM row.
|
|
local bottom, middle, top = SlotMachine.window(SlotMachine.REELS[1], 1)
|
|
check("slot 1 shows entry 0 on the bottom", bottom, SEVEN)
|
|
check("slot 1 shows entry 1 in the middle", middle, CHERRY)
|
|
check("slot 1 shows entry 2 on top", top, STARYU)
|
|
|
|
local b0 = SlotMachine.window(SlotMachine.REELS[1], 0)
|
|
local b15 = SlotMachine.window(SlotMachine.REELS[1], 15)
|
|
check("slot 0 reads as slot 15", b0, b15)
|
|
check("slot 0 shows entry 14", b0, SQUIRTLE)
|
|
|
|
-- The `and $f` mask is only harmless because of the three repeats: position 16
|
|
-- reads the same window position 1 does.
|
|
local w1 = { SlotMachine.window(SlotMachine.REELS[2], 1) }
|
|
local w16 = { SlotMachine.window(SlotMachine.REELS[2], 16) }
|
|
check("position 16 reads position 1's window",
|
|
table.concat(w1, ","), table.concat(w16, ","))
|
|
|
|
-- Slots_UpdateReelPositionAndOAM's wrap.
|
|
check("advance wraps 14 to 0", SlotMachine.advance(14), 0)
|
|
check("advance steps 0 to 1", SlotMachine.advance(0), 1)
|
|
|
|
-- ================================================================ pay lines
|
|
--
|
|
-- The jumptable FALLS THROUGH, so a bet of three runs five checks and a bet of
|
|
-- one runs a single middle row.
|
|
check("bet 1 buys one line", #SlotMachine.LINES[1], 1)
|
|
check("bet 2 buys three lines", #SlotMachine.LINES[2], 3)
|
|
check("bet 3 buys five lines", #SlotMachine.LINES[3], 5)
|
|
|
|
-- Windows are { bottom, middle, top }.
|
|
local function win(a, b, c) return { a, b, c } end
|
|
|
|
check("a middle row pays at bet 1",
|
|
SlotMachine.matchAll(1, win(CHERRY, SEVEN, CHERRY), win(CHERRY, SEVEN, CHERRY),
|
|
win(CHERRY, SEVEN, CHERRY)), SEVEN)
|
|
-- Only the bottom row lines up here, so a bet of one sees nothing at all.
|
|
local bottomOnly = {
|
|
win(SEVEN, CHERRY, PIKACHU),
|
|
win(SEVEN, STARYU, SQUIRTLE),
|
|
win(SEVEN, POKEBALL, STARYU),
|
|
}
|
|
check("a bottom row does not pay at bet 1",
|
|
SlotMachine.matchAll(1, bottomOnly[1], bottomOnly[2], bottomOnly[3]),
|
|
SlotMachine.NO_MATCH)
|
|
check("a bottom row pays at bet 2",
|
|
SlotMachine.matchAll(2, bottomOnly[1], bottomOnly[2], bottomOnly[3]), SEVEN)
|
|
check("a top row pays at bet 2",
|
|
SlotMachine.matchAll(2, win(CHERRY, STARYU, STARYU),
|
|
win(PIKACHU, SQUIRTLE, STARYU), win(SEVEN, CHERRY, STARYU)), STARYU)
|
|
-- The upward diagonal is reel 1's bottom, reel 2's middle and reel 3's top.
|
|
check("the upward diagonal only pays at bet 3",
|
|
SlotMachine.matchAll(2, win(CHERRY, SEVEN, STARYU), win(SEVEN, CHERRY, STARYU),
|
|
win(SEVEN, STARYU, CHERRY)), SlotMachine.NO_MATCH)
|
|
check("the upward diagonal pays at bet 3",
|
|
SlotMachine.matchAll(3, win(CHERRY, SEVEN, STARYU), win(SEVEN, CHERRY, STARYU),
|
|
win(SEVEN, STARYU, CHERRY)), CHERRY)
|
|
|
|
-- Every hit overwrites wSlotMatched, so the LAST line checked wins the tie --
|
|
-- and the middle row is checked last. Here the bottom row lines up STARYU and
|
|
-- the middle row lines up CHERRY; only the CHERRY is paid.
|
|
check("the middle row wins a tie",
|
|
SlotMachine.matchAll(2, win(STARYU, CHERRY, SEVEN), win(STARYU, CHERRY, SEVEN),
|
|
win(STARYU, CHERRY, SEVEN)), CHERRY)
|
|
|
|
-- Slots_CheckMatchedFirstTwoReels compares DIFFERENT rows: with two reels down
|
|
-- both diagonals collapse onto reel 2's middle symbol.
|
|
local building, sevens = SlotMachine.matchFirstTwo(3,
|
|
win(SEVEN, CHERRY, PIKACHU), win(STARYU, SEVEN, SQUIRTLE))
|
|
check("two reels build on the upward diagonal", building, SEVEN)
|
|
check("and the sevens flag is set", sevens, true)
|
|
local building2, sevens2 = SlotMachine.matchFirstTwo(1,
|
|
win(SEVEN, CHERRY, PIKACHU), win(STARYU, SEVEN, SQUIRTLE))
|
|
check("but not at bet 1", building2, SlotMachine.NO_MATCH)
|
|
check("and the sevens flag stays clear", sevens2, false)
|
|
|
|
-- ============================================================ payout ladder
|
|
--
|
|
-- Slots_GetPayout .PayoutTable. The bet buys LINES, never a multiplier.
|
|
check("SEVEN pays 300", SlotMachine.payout(SEVEN), 300)
|
|
check("POKEBALL pays 50", SlotMachine.payout(POKEBALL), 50)
|
|
check("CHERRY pays 6", SlotMachine.payout(CHERRY), 6)
|
|
check("PIKACHU pays 8", SlotMachine.payout(PIKACHU), 8)
|
|
check("SQUIRTLE pays 10", SlotMachine.payout(SQUIRTLE), 10)
|
|
check("STARYU pays 15", SlotMachine.payout(STARYU), 15)
|
|
check("no match pays nothing", SlotMachine.payout(SlotMachine.NO_MATCH), 0)
|
|
|
|
-- ================================================================ the bias
|
|
--
|
|
-- Slots_InitBias .Normal / .Lucky. `percent` is "* $ff / 100" with integer
|
|
-- division, so the boundaries are the bytes below and not round percentages.
|
|
-- The scan takes the first row whose threshold is >= the roll, so each byte
|
|
-- below is the LAST one that selects its symbol.
|
|
local NO_BIAS = SlotMachine.NO_BIAS
|
|
local NORMAL_EDGES = {
|
|
{ 0, SEVEN }, { 1, SEVEN }, { 2, POKEBALL }, { 3, POKEBALL },
|
|
{ 4, STARYU }, { 10, STARYU }, { 11, SQUIRTLE }, { 20, SQUIRTLE },
|
|
{ 21, PIKACHU }, { 40, PIKACHU }, { 41, CHERRY }, { 48, CHERRY },
|
|
{ 49, NO_BIAS }, { 255, NO_BIAS },
|
|
}
|
|
for _, row in ipairs(NORMAL_EDGES) do
|
|
check(("normal bias byte %d"):format(row[1]),
|
|
SlotMachine.initBias(NO_BIAS, false, scripted({ row[1] })), row[2])
|
|
end
|
|
|
|
local LUCKY_EDGES = {
|
|
{ 0, SEVEN }, { 2, SEVEN }, { 3, POKEBALL }, { 4, STARYU }, { 8, STARYU },
|
|
{ 9, SQUIRTLE }, { 16, SQUIRTLE }, { 17, PIKACHU }, { 30, PIKACHU },
|
|
{ 31, CHERRY }, { 80, CHERRY }, { 81, NO_BIAS }, { 255, NO_BIAS },
|
|
}
|
|
for _, row in ipairs(LUCKY_EDGES) do
|
|
check(("lucky bias byte %d"):format(row[1]),
|
|
SlotMachine.initBias(NO_BIAS, true, scripted({ row[1] })), row[2])
|
|
end
|
|
|
|
-- `ld a, [wSlotBias] / and a / ret z`: a spin already biased to SEVEN keeps it
|
|
-- without rolling at all. The scripted RNG would say CHERRY if consulted.
|
|
check("a SEVEN bias survives without a roll",
|
|
SlotMachine.initBias(SEVEN, false, scripted({ 48 })), SEVEN)
|
|
check("any other bias is rerolled",
|
|
SlotMachine.initBias(CHERRY, false, scripted({ 48 })), CHERRY)
|
|
|
|
-- The exact shape of the table, sampled: SEVEN is 2 bytes in 256 on the normal
|
|
-- table and CHERRY jumps from 8 bytes to 50 on the lucky one.
|
|
local counts = {}
|
|
local roll = 0
|
|
for byte = 0, 255 do
|
|
local symbol = SlotMachine.initBias(NO_BIAS, false, scripted({ byte }))
|
|
counts[symbol] = (counts[symbol] or 0) + 1
|
|
roll = roll + 1
|
|
end
|
|
check("256 bytes accounted for", roll, 256)
|
|
check("normal SEVEN is 2/256", counts[SEVEN], 2)
|
|
check("normal POKEBALL is 2/256", counts[POKEBALL], 2)
|
|
check("normal STARYU is 7/256", counts[STARYU], 7)
|
|
check("normal SQUIRTLE is 10/256", counts[SQUIRTLE], 10)
|
|
check("normal PIKACHU is 20/256", counts[PIKACHU], 20)
|
|
check("normal CHERRY is 8/256", counts[CHERRY], 8)
|
|
check("normal NO_BIAS is 207/256", counts[NO_BIAS], 207)
|
|
|
|
local lucky = {}
|
|
for byte = 0, 255 do
|
|
local symbol = SlotMachine.initBias(NO_BIAS, true, scripted({ byte }))
|
|
lucky[symbol] = (lucky[symbol] or 0) + 1
|
|
end
|
|
check("lucky SEVEN is 3/256", lucky[SEVEN], 3)
|
|
check("lucky POKEBALL is 1/256", lucky[POKEBALL], 1)
|
|
check("lucky CHERRY is 50/256", lucky[CHERRY], 50)
|
|
check("lucky NO_BIAS is 175/256", lucky[NO_BIAS], 175)
|
|
check("the lucky table is kinder", lucky[NO_BIAS] < counts[NO_BIAS], true)
|
|
|
|
-- .InitGFX's wKeepSevenBiasChance roll: %00101010 clear is 1 byte in 8.
|
|
local keeps = 0
|
|
for byte = 0, 255 do
|
|
if SlotMachine.rollKeepSevenChance(scripted({ byte })) then keeps = keeps + 1 end
|
|
end
|
|
check("the session flag is set 32/256 of the time", keeps, 32)
|
|
|
|
-- .LinedUpSevens: the streak survives 1 roll in 4 normally, and -- the ASM's
|
|
-- own comment calls this out as probably inverted -- only 1 in 8 on the rarer
|
|
-- session flag.
|
|
local kept, keptRare = 0, 0
|
|
for byte = 0, 255 do
|
|
if SlotMachine.keepSevenBias(false, scripted({ byte })) then kept = kept + 1 end
|
|
if SlotMachine.keepSevenBias(true, scripted({ byte })) then
|
|
keptRare = keptRare + 1
|
|
end
|
|
end
|
|
check("a seven streak survives 64/256", kept, 64)
|
|
check("and only 32/256 on the rare flag", keptRare, 32)
|
|
|
|
-- ============================================================== reel stops
|
|
--
|
|
-- ReelAction_StopReel1: with no bias the reel stops where the player pressed.
|
|
check("an unbiased reel 1 stops dead",
|
|
SlotMachine.stopReel1(7, NO_BIAS), 7)
|
|
|
|
-- With a bias it walks up to four slots hunting the symbol. Reel 1 slot 3
|
|
-- shows entries 2,3,4 = STARYU, PIKACHU, SQUIRTLE; the nearest SEVEN in the
|
|
-- window is at slot 4 (entries 3,4,5).
|
|
check("a biased reel 1 walks to its symbol",
|
|
SlotMachine.stopReel1(3, SEVEN), 4)
|
|
-- Four slots is the whole budget: a symbol further away is never reached.
|
|
local far = SlotMachine.stopReel1(7, SEVEN)
|
|
check("and gives up after four slots", far, 11)
|
|
|
|
-- ReelAction_StopReel3's rule with no bias at all: the reel refuses to settle
|
|
-- anywhere a line is lit. Drive it against two reels showing CHERRY straight
|
|
-- across and assert the landing pays nothing.
|
|
local r1 = win(CHERRY, CHERRY, CHERRY)
|
|
local r2 = win(CHERRY, CHERRY, CHERRY)
|
|
local landed = SlotMachine.stopReel3(0, NO_BIAS, 3, r1, r2)
|
|
local r3 = { SlotMachine.window(SlotMachine.REELS[3], landed) }
|
|
check("an unbiased reel 3 lands on nothing",
|
|
SlotMachine.matchAll(3, r1, r2, r3), SlotMachine.NO_MATCH)
|
|
|
|
-- Slots_StopReel2's skip-to-seven gate: it needs a bet of 2 or more, a SEVEN
|
|
-- somewhere in reel one, and a spin that is unbiased or biased to SEVEN.
|
|
local withSeven = win(SEVEN, CHERRY, STARYU)
|
|
local noSeven = win(CHERRY, PIKACHU, STARYU)
|
|
check("skip needs a bet of two",
|
|
SlotMachine.reel2SkipsToSeven(1, NO_BIAS, withSeven, scripted({ 0 })), false)
|
|
check("skip needs a seven in reel one",
|
|
SlotMachine.reel2SkipsToSeven(3, NO_BIAS, noSeven, scripted({ 0 })), false)
|
|
check("skip refuses a non-seven bias",
|
|
SlotMachine.reel2SkipsToSeven(3, CHERRY, withSeven, scripted({ 0 })), false)
|
|
check("skip fires below 80/256",
|
|
SlotMachine.reel2SkipsToSeven(3, NO_BIAS, withSeven, scripted({ 79 })), true)
|
|
check("and not at 80",
|
|
SlotMachine.reel2SkipsToSeven(3, NO_BIAS, withSeven, scripted({ 80 })), false)
|
|
check("a seven bias is allowed",
|
|
SlotMachine.reel2SkipsToSeven(2, SEVEN, withSeven, scripted({ 0 })), true)
|
|
|
|
-- ============================================== reel 3's near-miss theatre
|
|
--
|
|
-- Slots_StopReel3's action roll only happens when the first two reels already
|
|
-- show matching SEVENs.
|
|
check("no sevens means no theatre",
|
|
SlotMachine.reel3Action(false, SEVEN, scripted({ 0 })),
|
|
SlotMachine.REEL3_STOP)
|
|
|
|
-- Biased to SEVEN: stop 76/256, slow 60, golem 60, egg 60.
|
|
local sevenBoundaries = {
|
|
{ 255, SlotMachine.REEL3_STOP }, { 180, SlotMachine.REEL3_STOP },
|
|
{ 179, SlotMachine.REEL3_SLOW }, { 120, SlotMachine.REEL3_SLOW },
|
|
{ 119, SlotMachine.REEL3_GOLEM }, { 60, SlotMachine.REEL3_GOLEM },
|
|
{ 59, SlotMachine.REEL3_EGG }, { 0, SlotMachine.REEL3_EGG },
|
|
}
|
|
for _, row in ipairs(sevenBoundaries) do
|
|
check(("seven-bias theatre at %d"):format(row[1]),
|
|
SlotMachine.reel3Action(true, SEVEN, scripted({ row[1] })), row[2])
|
|
end
|
|
|
|
-- Anything else: stop 96/256, slow 80, golem 80, and the egg is UNREACHABLE.
|
|
local otherBoundaries = {
|
|
{ 255, SlotMachine.REEL3_STOP }, { 160, SlotMachine.REEL3_STOP },
|
|
{ 159, SlotMachine.REEL3_SLOW }, { 80, SlotMachine.REEL3_SLOW },
|
|
{ 79, SlotMachine.REEL3_GOLEM }, { 0, SlotMachine.REEL3_GOLEM },
|
|
}
|
|
for _, row in ipairs(otherBoundaries) do
|
|
check(("unbiased theatre at %d"):format(row[1]),
|
|
SlotMachine.reel3Action(true, NO_BIAS, scripted({ row[1] })), row[2])
|
|
end
|
|
local eggs = 0
|
|
for byte = 0, 255 do
|
|
if SlotMachine.reel3Action(true, NO_BIAS, scripted({ byte }))
|
|
== SlotMachine.REEL3_EGG then
|
|
eggs = eggs + 1
|
|
end
|
|
end
|
|
check("Chansey never appears without a seven bias", eggs, 0)
|
|
|
|
-- Slots_GetNumberOfGolems, biased to SEVEN: the count is exactly the number of
|
|
-- one-slot steps to a SEVEN line, so the reel really does land on one.
|
|
local sevenRow = win(SEVEN, SEVEN, SEVEN)
|
|
local golems = SlotMachine.golemCount(0, SEVEN, 3, sevenRow, sevenRow,
|
|
scripted({}))
|
|
local golemLanding = 0
|
|
for _ = 1, golems do golemLanding = SlotMachine.advance(golemLanding) end
|
|
local golemWindow = { SlotMachine.window(SlotMachine.REELS[3], golemLanding) }
|
|
check("Golem lands a seven when the bias is seven",
|
|
SlotMachine.matchAll(3, sevenRow, sevenRow, golemWindow), SEVEN)
|
|
check("and takes at least one Golem", golems >= 1, true)
|
|
|
|
-- The other branch rerolls `and $7` until it is 4..7, which is the stride the
|
|
-- SEARCH walks; the count returned is the final stride, so the reel lands
|
|
-- somewhere the search never looked. 0, 1, 2 and 3 are all rejected rolls.
|
|
local strideCount = SlotMachine.golemCount(0, NO_BIAS, 3, sevenRow, sevenRow,
|
|
scripted({ 0, 3, 5 }))
|
|
check("an unbiased Golem stride starts at 4 or more", strideCount >= 5, true)
|
|
|
|
-- ================================================================== spins
|
|
--
|
|
-- The whole machine, seeded. Two properties hold over thousands of spins and
|
|
-- they are the ones the tables above exist to produce.
|
|
local paid, spins, unnamed, silentMatch = 0, 0, 0, 0
|
|
local random = seeded(20260807)
|
|
local biasSeen = {}
|
|
for i = 1, 4000 do
|
|
local result = SlotMachine.spin({
|
|
random = random,
|
|
bet = 3,
|
|
bias = NO_BIAS,
|
|
stops = { i % 15, (i * 7) % 15, (i * 11) % 15 },
|
|
})
|
|
spins = spins + 1
|
|
biasSeen[result.bias] = (biasSeen[result.bias] or 0) + 1
|
|
if result.payout > 0 then
|
|
paid = paid + 1
|
|
if SlotMachine.PAYOUTS[result.matched] == nil then unnamed = unnamed + 1 end
|
|
elseif result.matched ~= SlotMachine.NO_MATCH then
|
|
silentMatch = silentMatch + 1
|
|
end
|
|
end
|
|
check("every spin resolved", spins, 4000)
|
|
check("every paid spin names a symbol on the table", unnamed, 0)
|
|
check("and a symbol on the table always pays", silentMatch, 0)
|
|
-- An unbiased spin cannot line anything up, so a paying spin implies a bias was
|
|
-- rolled. With NO_BIAS at 207/256 the paid rate has to be well under half.
|
|
check("most spins pay nothing", paid < spins / 2, true)
|
|
check("some spins pay", paid > 0, true)
|
|
check("the unbiased spins dominate", biasSeen[NO_BIAS] > spins / 2, true)
|
|
|
|
-- ReelAction_StopReel3 keeps spinning past any match that is NOT the bias, so
|
|
-- the reel it stops can only ever land on the biased symbol or on nothing.
|
|
local wrongSymbol = 0
|
|
local twoCherries = win(CHERRY, CHERRY, CHERRY)
|
|
for start = 0, 14 do
|
|
local at = SlotMachine.stopReel3(start, CHERRY, 3, twoCherries, twoCherries)
|
|
local window = { SlotMachine.window(SlotMachine.REELS[3], at) }
|
|
local matched = SlotMachine.matchAll(3, twoCherries, twoCherries, window)
|
|
if matched ~= SlotMachine.NO_MATCH and matched ~= CHERRY then
|
|
wrongSymbol = wrongSymbol + 1
|
|
end
|
|
end
|
|
check("a CHERRY-biased reel 3 never settles on another symbol", wrongSymbol, 0)
|
|
|
|
-- Across whole spins the same holds, with ONE documented exception: the Golem
|
|
-- search for a spin that is not biased to SEVEN walks a growing stride while
|
|
-- the reel only advances one slot per Golem, so it lands somewhere the search
|
|
-- never looked and ReelAction_WaitGolem stops it there anyway.
|
|
local offBias = 0
|
|
local biasRandom = seeded(99)
|
|
for i = 1, 2000 do
|
|
local result = SlotMachine.spin({
|
|
random = biasRandom,
|
|
bet = 3,
|
|
bias = SlotMachine.NO_BIAS,
|
|
stops = { i % 15, (i * 5) % 15, (i * 13) % 15 },
|
|
})
|
|
if result.matched ~= SlotMachine.NO_MATCH
|
|
and result.matched ~= result.bias
|
|
and result.reel3Action ~= SlotMachine.REEL3_GOLEM then
|
|
offBias = offBias + 1
|
|
end
|
|
end
|
|
check("a spin only ever pays its own bias, Golem aside", offBias, 0)
|
|
|
|
-- ============================================================== coin case
|
|
--
|
|
-- engine/events/money.asm GiveCoins / TakeCoins, and Save.MAX_COINS.
|
|
check("the cap is 9999", PrizeMenu.MAX_COINS, 9999)
|
|
check("and the save agrees", Save.MAX_COINS, 9999)
|
|
|
|
local case = newSave(0)
|
|
check("a fresh case is empty", PrizeMenu.coins(case), 0)
|
|
local after, capped = PrizeMenu.giveCoins(case, 50)
|
|
check("giving adds", after, 50)
|
|
check("and does not cap", capped, false)
|
|
|
|
case.player.coins = 9990
|
|
after, capped = PrizeMenu.giveCoins(case, 5)
|
|
check("giving under the cap adds", after, 9995)
|
|
check("still not capped", capped, false)
|
|
after, capped = PrizeMenu.giveCoins(case, 500)
|
|
check("giving over the cap clamps to 9999", after, 9999)
|
|
check("and reports the cap", capped, true)
|
|
after = PrizeMenu.giveCoins(case, 1)
|
|
check("a full case stays at 9999", after, 9999)
|
|
|
|
case.player.coins = 3
|
|
local left, borrowed = PrizeMenu.takeCoins(case, 3)
|
|
check("taking exactly empties", left, 0)
|
|
check("without borrowing", borrowed, false)
|
|
left, borrowed = PrizeMenu.takeCoins(case, 1)
|
|
check("taking more clamps at zero", left, 0)
|
|
check("and reports the borrow", borrowed, true)
|
|
|
|
-- CheckCoins -> CompareMoneyAction.
|
|
case.player.coins = 100
|
|
check("more than", PrizeMenu.checkCoins(case, 50), PrizeMenu.HAVE_MORE)
|
|
check("exactly", PrizeMenu.checkCoins(case, 100), PrizeMenu.HAVE_AMOUNT)
|
|
check("less than", PrizeMenu.checkCoins(case, 101), PrizeMenu.HAVE_LESS)
|
|
check("HAVE_MORE is 0", PrizeMenu.HAVE_MORE, 0)
|
|
check("HAVE_AMOUNT is 1", PrizeMenu.HAVE_AMOUNT, 1)
|
|
check("HAVE_LESS is 2", PrizeMenu.HAVE_LESS, 2)
|
|
|
|
-- Save.normalize re-clamps a file that was edited past the cap.
|
|
local overflowing = newSave(0)
|
|
overflowing.player.coins = 99999
|
|
Save.normalize(overflowing)
|
|
check("normalize clamps a loaded save", overflowing.player.coins, 9999)
|
|
overflowing.player.coins = -5
|
|
Save.normalize(overflowing)
|
|
check("and clamps below zero", overflowing.player.coins, 0)
|
|
|
|
-- ========================================================= prize counters
|
|
--
|
|
-- The prices are the map scripts' EQU blocks.
|
|
local counters = PrizeMenu.COUNTERS
|
|
check("TM32 costs 1500", counters.CELADON_TM.prizes[1].cost, 1500)
|
|
check("TM29 costs 3500", counters.CELADON_TM.prizes[2].cost, 3500)
|
|
check("TM15 costs 7500", counters.CELADON_TM.prizes[3].cost, 7500)
|
|
check("MR.MIME costs 3333", counters.CELADON_MON.prizes[1].cost, 3333)
|
|
check("EEVEE costs 6666", counters.CELADON_MON.prizes[2].cost, 6666)
|
|
check("PORYGON costs 9999", counters.CELADON_MON.prizes[3].cost, 9999)
|
|
check("PORYGON costs the whole case", counters.CELADON_MON.prizes[3].cost,
|
|
PrizeMenu.MAX_COINS)
|
|
check("PORYGON arrives at level 20", counters.CELADON_MON.prizes[3].level, 20)
|
|
check("the Goldenrod TMs are all 5500",
|
|
counters.GOLDENROD_TM.prizes[1].cost + counters.GOLDENROD_TM.prizes[2].cost
|
|
+ counters.GOLDENROD_TM.prizes[3].cost, 16500)
|
|
check("ABRA costs 200", counters.GOLDENROD_MON.prizes[1].cost, 200)
|
|
check("DRATINI costs 2100", counters.GOLDENROD_MON.prizes[3].cost, 2100)
|
|
check("Gold sells EKANS", counters.GOLDENROD_MON.prizes[2].id, "EKANS")
|
|
check("Silver sells SANDSHREW",
|
|
counters.GOLDENROD_MON.prizes[2].silver.id, "SANDSHREW")
|
|
check("the coin vendor sells 50 for 1000",
|
|
counters.COIN_VENDOR.prizes[1].cost, 1000)
|
|
check("and 500 for 10000", counters.COIN_VENDOR.prizes[2].cost, 10000)
|
|
|
|
-- A data double: enough for Bag.add's capacity check and for Mon.new.
|
|
local data = {
|
|
constants = { bagSize = 20 },
|
|
items = { TM_DOUBLE_TEAM = { name = "TM32", pocket = "TM_HM" } },
|
|
moves = {},
|
|
pokemon = {
|
|
growthRates = { MEDIUM_FAST = {} },
|
|
PORYGON = { name = "PORYGON", baseStats = { hp = 65, attack = 60,
|
|
defense = 70, speed = 40, spAttack = 85, spDefense = 75 },
|
|
types = { "NORMAL" }, growthRate = "MEDIUM_FAST", levelMoves = {} },
|
|
},
|
|
}
|
|
|
|
-- The item counter's ladder: coins are checked first, and the PACK only after
|
|
-- the player has said yes -- so a broke player is never told about the bag.
|
|
local tmCounter = counters.CELADON_TM
|
|
local tm32 = tmCounter.prizes[1]
|
|
local broke = newSave(1499)
|
|
check("1499 coins is not enough for TM32",
|
|
PrizeMenu.check(broke, tmCounter, tm32, data), "coins")
|
|
check("and buying refuses without charging",
|
|
PrizeMenu.buy(broke, tmCounter, tm32, data), "coins")
|
|
check("the coins are untouched", broke.player.coins, 1499)
|
|
check("and nothing was added", broke.inventory.TM_DOUBLE_TEAM, nil)
|
|
|
|
local exact = newSave(1500)
|
|
check("exactly 1500 passes the check",
|
|
PrizeMenu.check(exact, tmCounter, tm32, data), "ok")
|
|
check("and buys", PrizeMenu.buy(exact, tmCounter, tm32, data), "ok")
|
|
check("leaving nothing", exact.player.coins, 0)
|
|
check("with the TM in the bag", exact.inventory.TM_DOUBLE_TEAM, 1)
|
|
|
|
-- A full ITEM pocket does NOT block a TM: they live in different pockets
|
|
-- (item_data_constants.asm). The filler ids have no pocket def, so they
|
|
-- resolve to ITEM; the TM goes to TM_HM, which is empty here.
|
|
local fullItems = newSave(5000)
|
|
for i = 1, 20 do fullItems.inventory["FILLER_" .. i] = 1 end
|
|
check("a full ITEM pocket does not block a TM purchase",
|
|
PrizeMenu.buy(fullItems, tmCounter, tm32, data), "ok")
|
|
check("and the TM lands in the TM/HM pocket",
|
|
fullItems.inventory.TM_DOUBLE_TEAM, 1)
|
|
|
|
-- A full TM/HM pocket is what actually refuses a TM. giveitem discovers it,
|
|
-- so the pre-confirm check still says ok.
|
|
local fullTms = newSave(5000)
|
|
for i = 1, 64 do
|
|
local id = "OTHER_TM_" .. i
|
|
data.items[id] = { name = id, pocket = "TM_HM" }
|
|
fullTms.inventory[id] = 1
|
|
end
|
|
check("a full TM/HM pocket passes the pre-confirm check",
|
|
PrizeMenu.check(fullTms, tmCounter, tm32, data), "ok")
|
|
check("but the purchase itself refuses",
|
|
PrizeMenu.buy(fullTms, tmCounter, tm32, data), "room")
|
|
check("and the coins are not taken", fullTms.player.coins, 5000)
|
|
|
|
-- The mon counter checks the PARTY before asking, so a full party never gets
|
|
-- the question at all.
|
|
local monCounter = counters.CELADON_MON
|
|
local porygon = monCounter.prizes[3]
|
|
local fullParty = newSave(9999)
|
|
for i = 1, Save.PARTY_SIZE do fullParty.party[i] = { species = "RATTATA" } end
|
|
check("a full party is refused before the question",
|
|
PrizeMenu.check(fullParty, monCounter, porygon, data), "room")
|
|
check("and the purchase refuses too",
|
|
PrizeMenu.buy(fullParty, monCounter, porygon, data), "room")
|
|
check("with the coins untouched", fullParty.player.coins, 9999)
|
|
|
|
local roomy = newSave(9999)
|
|
check("nine thousand nine hundred and ninety nine is exactly enough",
|
|
PrizeMenu.check(roomy, monCounter, porygon, data), "ok")
|
|
check("and the mon is handed over",
|
|
PrizeMenu.buy(roomy, monCounter, porygon, data), "ok")
|
|
check("the party grew", #roomy.party, 1)
|
|
check("the mon is the prize", roomy.party[1] and roomy.party[1].species,
|
|
"PORYGON")
|
|
check("at the scripted level", roomy.party[1] and roomy.party[1].level, 20)
|
|
check("the case is empty", roomy.player.coins, 0)
|
|
-- `special GameCornerPrizeMonCheckDex` right before the givepoke.
|
|
check("and the dex was told", roomy.pokedex.caught.PORYGON, true)
|
|
check("seen as well", roomy.pokedex.seen.PORYGON, true)
|
|
-- AddPartyMon stamps the buyer's identity onto it (move_mon.asm:44-56, :143-149).
|
|
check("the prize mon carries the player's OT ID", roomy.party[1].otId,
|
|
roomy.player.id)
|
|
check("and the player's OT name", roomy.party[1].ot, roomy.player.name)
|
|
|
|
local shortByOne = newSave(9998)
|
|
check("one coin short is refused",
|
|
PrizeMenu.check(shortByOne, monCounter, porygon, data), "coins")
|
|
|
|
-- The coin vendor asks about ROOM first and money second, which is why a player
|
|
-- with a full case and an empty wallet hears about the case.
|
|
local coinCounter = counters.COIN_VENDOR
|
|
local fifty = coinCounter.prizes[1]
|
|
local fullCase = newSave(9999, 0)
|
|
check("a full case is reported before the wallet",
|
|
PrizeMenu.check(fullCase, coinCounter, fifty, data), "room")
|
|
-- MAX_COINS - 50 is 9949: exactly that still fits, one more does not.
|
|
local atHeadroom = newSave(9949, 1000)
|
|
check("exactly the headroom still fits",
|
|
PrizeMenu.check(atHeadroom, coinCounter, fifty, data), "ok")
|
|
local overHeadroom = newSave(9950, 1000)
|
|
check("one coin past the headroom is full",
|
|
PrizeMenu.check(overHeadroom, coinCounter, fifty, data), "room")
|
|
|
|
local poor = newSave(0, 999)
|
|
check("¥999 does not buy 50 coins",
|
|
PrizeMenu.check(poor, coinCounter, fifty, data), "money")
|
|
check("and buying refuses", PrizeMenu.buy(poor, coinCounter, fifty, data),
|
|
"money")
|
|
check("with the wallet untouched", poor.player.money, 999)
|
|
|
|
local buyer = newSave(0, 1000)
|
|
check("¥1000 buys 50 coins", PrizeMenu.buy(buyer, coinCounter, fifty, data),
|
|
"ok")
|
|
check("the coins arrive", buyer.player.coins, 50)
|
|
check("and the money is gone", buyer.player.money, 0)
|
|
|
|
-- Buying 500 near the cap clamps rather than overflowing.
|
|
local nearCap = newSave(9900, 10000)
|
|
check("500 into a nearly full case is refused",
|
|
PrizeMenu.check(nearCap, coinCounter, coinCounter.prizes[2], data), "room")
|
|
|
|
-- ================================================================ card flip
|
|
--
|
|
-- CARDFLIP_DECK_SIZE, and the card packing every win condition masks against.
|
|
check("the deck is 24 cards", CardFlip.DECK_SIZE, 24)
|
|
check("the bet is three coins", CardFlip.BET, 3)
|
|
check("card 0 is a level 1 Pikachu", CardFlip.level(0) + 1, 1)
|
|
check("and its mon index is 0", CardFlip.mon(0), 0)
|
|
check("card 23 is a level 6 Oddish", CardFlip.level(23) + 1, 6)
|
|
check("with mon index 3", CardFlip.mon(23), 3)
|
|
check("packing round-trips", CardFlip.card(4, 2), 18)
|
|
check("the level pair of card 0", CardFlip.levelPair(0), 0)
|
|
check("the level pair of card 11", CardFlip.levelPair(11), 1)
|
|
check("the level pair of card 23", CardFlip.levelPair(23), 2)
|
|
|
|
-- CardFlip_ShuffleDeck produces a permutation of 0..23 every time, including
|
|
-- the card 0 that is never explicitly placed.
|
|
for seed = 1, 40 do
|
|
local deck = CardFlip.shuffle(seeded(seed))
|
|
local seen = {}
|
|
local ok = #deck == 24
|
|
for _, card in ipairs(deck) do
|
|
if seen[card] or card < 0 or card > 23 then ok = false end
|
|
seen[card] = true
|
|
end
|
|
for card = 0, 23 do
|
|
if not seen[card] then ok = false end
|
|
end
|
|
check("shuffle " .. seed .. " is a permutation", ok, true)
|
|
end
|
|
|
|
-- A degenerate `random` -- one a mod could inject -- must not hang the
|
|
-- rejection loop. The linear-probe fallback still produces a permutation.
|
|
local stuck = CardFlip.shuffle(function() return 31 end)
|
|
local stuckSeen, stuckOk = {}, #stuck == 24
|
|
for _, card in ipairs(stuck) do
|
|
if stuckSeen[card] then stuckOk = false end
|
|
stuckSeen[card] = true
|
|
end
|
|
check("a degenerate RNG still deals 24 distinct cards", stuckOk, true)
|
|
|
|
-- .CheckTheCard's index: two cards a hand, twelve hands to the deck.
|
|
local deck = CardFlip.shuffle(seeded(7))
|
|
check("the first hand deals slots 1 and 2",
|
|
CardFlip.dealt(deck, 0, 0), deck[1])
|
|
check("and the second card", CardFlip.dealt(deck, 0, 1), deck[2])
|
|
check("the last hand deals slots 23 and 24",
|
|
CardFlip.dealt(deck, 11, 1), deck[24])
|
|
check("twelve hands to a deck", CardFlip.HANDS_PER_DECK, 12)
|
|
|
|
-- ------------------------------------------------------------- the board
|
|
--
|
|
-- CardFlip_CheckWinCondition's jumptable, square by square.
|
|
check("the board is six wide", CardFlip.BOARD_W, 6)
|
|
check("and eight tall", CardFlip.BOARD_H, 8)
|
|
|
|
for y = 0, 1 do
|
|
for x = 0, 1 do
|
|
check(("(%d,%d) is impossible"):format(x, y),
|
|
CardFlip.cell(x, y).kind, "impossible")
|
|
-- Impossible squares lose against every card in the deck.
|
|
local anyWin = false
|
|
for card = 0, 23 do
|
|
if CardFlip.payout(x, y, card) > 0 then anyWin = true end
|
|
end
|
|
check(("(%d,%d) never pays"):format(x, y), anyWin, false)
|
|
end
|
|
end
|
|
|
|
check("the ladder pays 6 for a mon pair", CardFlip.PAYOUT_MON_PAIR, 6)
|
|
check("9 for a level pair", CardFlip.PAYOUT_LEVEL_PAIR, 9)
|
|
check("12 for one mon", CardFlip.PAYOUT_MON, 12)
|
|
check("18 for one level", CardFlip.PAYOUT_LEVEL, 18)
|
|
check("72 for the exact card", CardFlip.PAYOUT_CARD, 72)
|
|
|
|
-- A Pikachu/Jigglypuff pair is x = 2 or 3 on row 0, and Poliwag/Oddish x = 4
|
|
-- or 5; both squares of a pair behave identically.
|
|
for _, x in ipairs({ 2, 3 }) do
|
|
check(("(%d,0) pays on Pikachu"):format(x), CardFlip.payout(x, 0, 0), 6)
|
|
check(("(%d,0) pays on Jigglypuff"):format(x), CardFlip.payout(x, 0, 1), 6)
|
|
check(("(%d,0) loses on Poliwag"):format(x), CardFlip.payout(x, 0, 2), 0)
|
|
end
|
|
for _, x in ipairs({ 4, 5 }) do
|
|
check(("(%d,0) pays on Poliwag"):format(x), CardFlip.payout(x, 0, 2), 6)
|
|
check(("(%d,0) pays on Oddish"):format(x), CardFlip.payout(x, 0, 3), 6)
|
|
check(("(%d,0) loses on Pikachu"):format(x), CardFlip.payout(x, 0, 0), 0)
|
|
end
|
|
|
|
-- Row 1 is one square per Pokemon, and the win is level-independent.
|
|
for mon = 0, 3 do
|
|
for level = 0, 5 do
|
|
check(("(%d,1) pays on its mon at level %d"):format(mon + 2, level + 1),
|
|
CardFlip.payout(mon + 2, 1, CardFlip.card(level, mon)), 12)
|
|
end
|
|
check(("(%d,1) loses on another mon"):format(mon + 2),
|
|
CardFlip.payout(mon + 2, 1, CardFlip.card(0, (mon + 1) % 4)), 0)
|
|
end
|
|
|
|
-- Column 0 is a level PAIR reached from two rows apiece: 1-2, 3-4, 5-6.
|
|
local PAIR_ROWS = { { 2, 3, 0 }, { 4, 5, 1 }, { 6, 7, 2 } }
|
|
for _, row in ipairs(PAIR_ROWS) do
|
|
for _, y in ipairs({ row[1], row[2] }) do
|
|
for _, level in ipairs({ row[3] * 2, row[3] * 2 + 1 }) do
|
|
check(("(0,%d) pays on level %d"):format(y, level + 1),
|
|
CardFlip.payout(0, y, CardFlip.card(level, 0)), 9)
|
|
end
|
|
check(("(0,%d) loses outside its pair"):format(y),
|
|
CardFlip.payout(0, y, CardFlip.card((row[3] * 2 + 2) % 6, 0)), 0)
|
|
end
|
|
end
|
|
|
|
-- Column 1 is a single level.
|
|
for level = 0, 5 do
|
|
for mon = 0, 3 do
|
|
check(("(1,%d) pays on level %d"):format(level + 2, level + 1),
|
|
CardFlip.payout(1, level + 2, CardFlip.card(level, mon)), 18)
|
|
end
|
|
check(("(1,%d) loses on another level"):format(level + 2),
|
|
CardFlip.payout(1, level + 2, CardFlip.card((level + 1) % 6, 0)), 0)
|
|
end
|
|
|
|
-- The 24 exact squares pay 72 on their own card and nothing on any other.
|
|
local exactHits = 0
|
|
for level = 0, 5 do
|
|
for mon = 0, 3 do
|
|
local x, y = mon + 2, level + 2
|
|
local card = CardFlip.card(level, mon)
|
|
if CardFlip.payout(x, y, card) == 72 then exactHits = exactHits + 1 end
|
|
local misses = 0
|
|
for other = 0, 23 do
|
|
if other ~= card and CardFlip.payout(x, y, other) ~= 0 then
|
|
misses = misses + 1
|
|
end
|
|
end
|
|
check(("(%d,%d) pays on nothing else"):format(x, y), misses, 0)
|
|
end
|
|
end
|
|
check("all 24 exact squares pay 72", exactHits, 24)
|
|
|
|
-- The house edge, sampled: every square is bet on against every card, and no
|
|
-- square wins more than three cards in twenty-four except the pairs.
|
|
local hitCounts = {}
|
|
for y = 0, 7 do
|
|
for x = 0, 5 do
|
|
local hits = 0
|
|
for card = 0, 23 do
|
|
if CardFlip.payout(x, y, card) > 0 then hits = hits + 1 end
|
|
end
|
|
hitCounts[#hitCounts + 1] = hits
|
|
end
|
|
end
|
|
check("a mon pair hits 12 of 24 cards", hitCounts[0 * 6 + 2 + 1], 12)
|
|
check("one mon hits 6 of 24", hitCounts[1 * 6 + 2 + 1], 6)
|
|
check("a level pair hits 8 of 24", hitCounts[2 * 6 + 0 + 1], 8)
|
|
check("one level hits 4 of 24", hitCounts[2 * 6 + 1 + 1], 4)
|
|
check("an exact card hits 1 of 24", hitCounts[2 * 6 + 2 + 1], 1)
|
|
|
|
-- ------------------------------------------------------- the cursor moves
|
|
--
|
|
-- ChooseCard_HandleJoypad. The two teleports and the `and $e` snaps are the
|
|
-- whole reason the board is not a plain grid.
|
|
local function moved(x, y, dir)
|
|
local nx, ny = CardFlip.moveCursor(x, y, dir)
|
|
return nx .. "," .. ny
|
|
end
|
|
|
|
check("the cursor starts on the level 1 Pikachu card", "2,2", "2,2")
|
|
check("right steps a column", moved(2, 2, "right"), "3,2")
|
|
check("right stops at the last column", moved(5, 2, "right"), "5,2")
|
|
check("left steps back", moved(3, 2, "left"), "2,2")
|
|
check("left stops at column 0 below the mon rows", moved(0, 2, "left"), "0,2")
|
|
check("down steps a row", moved(2, 2, "down"), "2,3")
|
|
check("down stops at the last row", moved(2, 7, "down"), "2,7")
|
|
check("up steps back", moved(2, 3, "up"), "2,2")
|
|
|
|
-- Leaving the level rows upward from a mon column lands on the mon row.
|
|
check("up out of the level rows", moved(3, 2, "up"), "3,1")
|
|
-- Column 1 (the single level column) teleports to the mon row's Pikachu.
|
|
check("up out of column 1 teleports", moved(1, 2, "up"), "2,1")
|
|
-- Column 0 spans two rows, so up snaps to the even row first and then steps
|
|
-- two -- and from the first pair it teleports.
|
|
check("up out of column 0's first pair teleports", moved(0, 3, "up"), "2,1")
|
|
check("up from column 0's second pair steps two", moved(0, 5, "up"), "0,2")
|
|
check("and snaps the odd row even first", moved(0, 4, "up"), "0,2")
|
|
check("down in column 0 steps two", moved(0, 2, "down"), "0,4")
|
|
check("down in column 0 stops at the last pair", moved(0, 6, "down"), "0,6")
|
|
check("and snaps odd rows even", moved(0, 7, "down"), "0,6")
|
|
|
|
-- Row 0 spans two columns the same way.
|
|
check("right on the mon-pair row steps two", moved(2, 0, "right"), "4,0")
|
|
check("and snaps an odd column even first", moved(3, 0, "right"), "4,0")
|
|
check("right stops at the last pair", moved(4, 0, "right"), "4,0")
|
|
check("left from the second pair steps two", moved(4, 0, "left"), "2,0")
|
|
check("left from the first pair teleports", moved(2, 0, "left"), "1,2")
|
|
check("left from the single-mon row teleports", moved(2, 1, "left"), "1,2")
|
|
check("left from a further mon column steps", moved(4, 1, "left"), "3,1")
|
|
check("up from the mon-pair row goes nowhere", moved(2, 0, "up"), "2,0")
|
|
check("down from the mon-pair row reaches the mon row", moved(2, 0, "down"),
|
|
"2,1")
|
|
|
|
-- Every reachable square is reachable: walking the board from the start with
|
|
-- the four directions must cover all 48 squares.
|
|
local reached = { ["2,2"] = true }
|
|
local frontier = { { 2, 2 } }
|
|
while #frontier > 0 do
|
|
local at = table.remove(frontier)
|
|
for _, dir in ipairs({ "left", "right", "up", "down" }) do
|
|
local nx, ny = CardFlip.moveCursor(at[1], at[2], dir)
|
|
local key = nx .. "," .. ny
|
|
if not reached[key] then
|
|
reached[key] = true
|
|
frontier[#frontier + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
local reachedCount = 0
|
|
for _ in pairs(reached) do reachedCount = reachedCount + 1 end
|
|
-- 44 of the 48 squares, and the four missing ones are exactly the .Impossible
|
|
-- corner: both teleports (.left_to_number_gp and .up_to_mon_group) jump PAST
|
|
-- it, so the cursor can never actually rest on a square that always loses. The
|
|
-- jumptable entries and the cursor OAM for them are dead code on the cart.
|
|
check("every square the cursor can occupy is reachable", reachedCount, 44)
|
|
for _, key in ipairs({ "0,0", "1,0", "0,1", "1,1" }) do
|
|
check("the impossible square " .. key .. " is unreachable", reached[key], nil)
|
|
end
|
|
|
|
-- ============================================================ screen flows
|
|
--
|
|
-- A stub input and a stub game, enough to walk each screen's state machine.
|
|
local function newInput()
|
|
local input = { pressed = {} }
|
|
function input:press(...)
|
|
for _, button in ipairs({ ... }) do self.pressed[button] = true end
|
|
end
|
|
function input:wasPressed(button)
|
|
if self.pressed[button] then
|
|
self.pressed[button] = nil
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
function input:isDown() return false end
|
|
return input
|
|
end
|
|
|
|
local function newGame(save)
|
|
local input = newInput()
|
|
return {
|
|
input = input,
|
|
save = save,
|
|
data = nil, -- no audio and no music in this harness
|
|
stack = { _items = {},
|
|
push = function(self, s) self._items[#self._items + 1] = s end,
|
|
pop = function(self) return table.remove(self._items) end,
|
|
},
|
|
}, input
|
|
end
|
|
|
|
-- The slot machine's bet menu: the top row is three coins (`ld a, 4 / sub b`),
|
|
-- and a bet the case cannot cover is refused without deducting.
|
|
local slotSave = newSave(2)
|
|
local slotGame, slotInput = newGame(slotSave)
|
|
local slots = SlotMachine.new(slotGame, { save = slotSave,
|
|
random = seeded(5), onClose = function() slotSave.closed = true end })
|
|
check("the machine opens on the bet menu", slots.phase, "bet")
|
|
check("with the cursor on three coins", slots.betIndex, 1)
|
|
slotInput:press("a")
|
|
slots:update(1 / 60)
|
|
check("two coins cannot cover a bet of three", slots.phase, "bet")
|
|
check("and nothing was deducted", slotSave.player.coins, 2)
|
|
check("the refusal is on screen", slots.message, SlotMachine.TEXTS.notEnough)
|
|
slotInput:press("a")
|
|
slots:update(1 / 60) -- dismiss
|
|
slotInput:press("down")
|
|
slots:update(1 / 60)
|
|
slotInput:press("down")
|
|
slots:update(1 / 60)
|
|
check("the cursor reaches one coin", slots.betIndex, 3)
|
|
slotInput:press("a")
|
|
slots:update(1 / 60)
|
|
check("a bet of one is taken", slots.bet, 1)
|
|
check("and one coin left the case", slotSave.player.coins, 1)
|
|
check("the reels are turning", slots.phase, "spinning")
|
|
|
|
-- A whole round, driven frame by frame: bet three, stop all three reels, and
|
|
-- assert the coins that come back are exactly the payout table's answer for
|
|
-- whatever the reels landed on.
|
|
local roundSave = newSave(50)
|
|
local roundGame, roundInput = newGame(roundSave)
|
|
local round = SlotMachine.new(roundGame, { save = roundSave,
|
|
random = seeded(11) })
|
|
roundInput:press("a")
|
|
round:update(1 / 60)
|
|
check("the top row bets three", round.bet, 3)
|
|
check("and three coins leave the case", roundSave.player.coins, 47)
|
|
-- SlotsAction_WaitStart's 32 frame lockout, so a held A cannot stop reel one.
|
|
for _ = 1, 33 do round:update(1 / 60) end
|
|
for reel = 1, 3 do
|
|
roundInput:press("a")
|
|
round:update(1 / 60)
|
|
for _ = 1, 600 do
|
|
if round.stopped and round.stopped[reel] then break end
|
|
round:update(1 / 60)
|
|
end
|
|
check(("reel %d settles"):format(reel),
|
|
round.stopped and round.stopped[reel] ~= nil, true)
|
|
end
|
|
check("the round resolved into a payout or a Darn!",
|
|
round.phase == "flash" or round.phase == "payoutText", true)
|
|
local owed = SlotMachine.payout(round.matched)
|
|
for _ = 1, 3000 do
|
|
if round.phase == "again" or round.phase == "ranOut" then break end
|
|
if round.phase == "payoutText" and round.matched == SlotMachine.NO_MATCH then
|
|
roundInput:press("a")
|
|
end
|
|
round:update(1 / 60)
|
|
end
|
|
check("the payout counter emptied", round.payoutLeft, 0)
|
|
check("and the case holds the stake back plus the win",
|
|
roundSave.player.coins, 47 + owed)
|
|
check("the machine offers another go",
|
|
round.phase == "again" or round.phase == "ranOut", true)
|
|
|
|
-- Card flip: the first question costs three coins, and a case with two is told
|
|
-- so and shown the door.
|
|
local flipSave = newSave(2)
|
|
local flipGame, flipInput = newGame(flipSave)
|
|
local flip = CardFlip.new(flipGame, { save = flipSave, random = seeded(3),
|
|
onClose = function() flipSave.closed = true end })
|
|
check("card flip opens on the question", flip.phase, "ask")
|
|
flipInput:press("a")
|
|
flip:update(1 / 60)
|
|
check("two coins is not enough", flip.phase, "message")
|
|
check("and the bet was not taken", flipSave.player.coins, 2)
|
|
flipInput:press("a")
|
|
flip:update(1 / 60)
|
|
check("dismissing the refusal leaves", flipSave.closed, true)
|
|
|
|
local flipSave2 = newSave(10)
|
|
local flipGame2, flipInput2 = newGame(flipSave2)
|
|
local flip2 = CardFlip.new(flipGame2, { save = flipSave2, random = seeded(3) })
|
|
flipInput2:press("a")
|
|
flip2:update(1 / 60)
|
|
check("three coins buy a hand", flipSave2.player.coins, 7)
|
|
check("and the cards come out", flip2.phase, "choose")
|
|
flipInput2:press("a")
|
|
flip2:update(1 / 60)
|
|
check("A locks the card and opens the bet", flip2.phase, "bet")
|
|
-- Bet on the square that matches whatever was dealt, and assert it pays 72.
|
|
local dealt = CardFlip.dealt(flip2.deck, 0, flip2.which)
|
|
flip2.cursorX = CardFlip.mon(dealt) + 2
|
|
flip2.cursorY = CardFlip.level(dealt) + 2
|
|
flipInput2:press("a")
|
|
flip2:update(1 / 60)
|
|
check("the exact square pays 72", flip2.payoutLeft, 72)
|
|
check("and the card is discarded", flip2.discarded[dealt], true)
|
|
for _ = 1, 200 do flip2:update(1 / 60) end
|
|
check("the coins are counted out", flipSave2.player.coins, 7 + 72)
|
|
check("and the hand is over", flip2.phase, "result")
|
|
|
|
-- The payout stops at the cap rather than wrapping.
|
|
local capSave = newSave(9990)
|
|
local capGame = newGame(capSave)
|
|
local capFlip = CardFlip.new(capGame, { save = capSave, random = seeded(3) })
|
|
capFlip.payoutLeft = 72
|
|
capFlip.payoutTick = 0
|
|
capFlip.phase = "payout"
|
|
for _ = 1, 400 do capFlip:update(1 / 60) end
|
|
check("a payout past the cap clamps", capSave.player.coins, 9999)
|
|
|
|
-- The prize counter screen: no COIN CASE is the very first refusal.
|
|
local caseless = newSave(5000)
|
|
local caselessGame, caselessInput = newGame(caseless)
|
|
local counter = PrizeMenu.new(caselessGame, { save = caseless,
|
|
counter = "CELADON_TM", texts = "CELADON", data = data,
|
|
onClose = function() caseless.closed = true end })
|
|
check("the intro plays first", counter.message ~= nil, true)
|
|
-- Three pages of Welcome, then the refusal, then the door.
|
|
for _ = 1, 3 do
|
|
caselessInput:press("a")
|
|
counter:update(1 / 60)
|
|
end
|
|
check("the counter never opens its menu", counter.phase, nil)
|
|
caselessInput:press("a")
|
|
counter:update(1 / 60)
|
|
check("a player with no COIN CASE is turned away", caseless.closed, true)
|
|
|
|
local shopper = newSave(5000)
|
|
shopper.inventory.COIN_CASE = 1
|
|
local shopGame, shopInput = newGame(shopper)
|
|
local shop = PrizeMenu.new(shopGame, { save = shopper, counter = "CELADON_TM",
|
|
texts = "CELADON", data = data,
|
|
onClose = function() shopper.closed = true end })
|
|
for _ = 1, 3 do
|
|
shopInput:press("a")
|
|
shop:update(1 / 60)
|
|
end
|
|
check("the menu opens", shop.phase, "menu")
|
|
check("with four rows including CANCEL", #shop.prizes, 4)
|
|
check("and the cursor on the first prize", shop.index, 1)
|
|
shopInput:press("a")
|
|
shop:update(1 / 60)
|
|
check("choosing TM32 asks first", shop.confirm ~= nil, true)
|
|
shopInput:press("a")
|
|
shop:update(1 / 60)
|
|
check("yes buys it", shopper.inventory.TM_DOUBLE_TEAM, 1)
|
|
check("and takes the coins", shopper.player.coins, 3500)
|
|
-- The second TM costs 3500, which is exactly what is left.
|
|
shopInput:press("down")
|
|
shop:update(1 / 60)
|
|
shopInput:press("a")
|
|
shop:update(1 / 60) -- dismiss "Here you go!"
|
|
check("the loop returns to the menu", shop.phase, "menu")
|
|
|
|
-- The Goldenrod mon counter's checkver swap.
|
|
local silverShop = PrizeMenu.new(newGame(newSave(0)),
|
|
{ save = newSave(0), counter = "GOLDENROD_MON", texts = "GOLDENROD",
|
|
version = "silver", data = data, hasCoinCase = true })
|
|
check("Silver's second prize is SANDSHREW", silverShop.prizes[2].id,
|
|
"SANDSHREW")
|
|
local goldShop = PrizeMenu.new(newGame(newSave(0)),
|
|
{ save = newSave(0), counter = "GOLDENROD_MON", texts = "GOLDENROD",
|
|
version = "gold", data = data, hasCoinCase = true })
|
|
check("Gold's second prize is EKANS", goldShop.prizes[2].id, "EKANS")
|
|
|
|
-- ============================================ the counters the GAME reaches
|
|
--
|
|
-- Everything above is a model. None of it says a prize counter can be OPENED,
|
|
-- and none of it is what opens one: on the cart no counter is a screen at all.
|
|
-- Each is a `bg_event ..., BGEVENT_READ, ...Vendor` (CeladonGameCornerPrizeRoom
|
|
-- .asm, GoldenrodGameCorner.asm) and the whole transaction is script bytecode --
|
|
-- `special DisplayCoinCaseBalance`, `loadmenu`, `verticalmenu`, `checkcoins`,
|
|
-- `giveitem` / `givepoke`, `takecoins`. So this drives the REAL extracted
|
|
-- script through the VM off the map's own bg event, which is the only assertion
|
|
-- that says the counter works where a player stands.
|
|
local Events = require("src.world.gen2.Events")
|
|
local Vm = require("src.script.gen2.Vm")
|
|
|
|
local cache = os.getenv("GOLD_CACHE")
|
|
or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold")
|
|
local function loadCache(name)
|
|
local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua")
|
|
return chunk and chunk() or nil
|
|
end
|
|
|
|
local cacheMaps = loadCache("maps")
|
|
local cacheScripts = loadCache("scripts")
|
|
local cacheText = loadCache("text")
|
|
local cacheConsts = loadCache("constants")
|
|
|
|
-- One counter conversation, driven to a stop. `pick` answers every
|
|
-- `verticalmenu`; the log is what the player would have seen.
|
|
local function runCounter(key, opts)
|
|
opts = opts or {}
|
|
local save = opts.save or newSave(opts.coins or 0, opts.money or 0)
|
|
local log, seen = {}, {}
|
|
local vm
|
|
vm = Vm.new(cacheScripts, cacheText, Events.new(), {
|
|
specialOrder = cacheConsts and cacheConsts.specialOrder,
|
|
specials = { save = function() return save end,
|
|
monName = function(index) return "MON" .. tostring(index) end },
|
|
showText = function(body, onDone) log[#log + 1] = body; onDone() end,
|
|
facePlayer = function() end,
|
|
yesorno = function(onChoose) onChoose(opts.yes ~= false) end,
|
|
-- Every counter LOOPS back to its own menu after a sale, so only the first
|
|
-- pass buys and the second cancels out; otherwise the run below empties
|
|
-- the case rather than making one purchase.
|
|
openMenu = function(header, _style, onChoose)
|
|
seen[#seen + 1] = { balance = vm.balanceKind, items = header.items }
|
|
vm.balanceKind = nil
|
|
onChoose(#seen == 1 and (opts.pick or 0) or 0)
|
|
end,
|
|
-- The two seams the balance boxes hang off: engine/menus/menu_2.asm draws
|
|
-- them and returns, and the `loadmenu` that follows is what shows them.
|
|
showCoins = function() vm.balanceKind = "coins" end,
|
|
showMoney = function(kind) vm.balanceKind = kind or "money" end,
|
|
hasItem = function() return opts.coinCase ~= false end,
|
|
giveItem = function(index, qty)
|
|
save.inventory[index] = (save.inventory[index] or 0) + (qty or 1)
|
|
return true
|
|
end,
|
|
givePoke = function(species, level)
|
|
save.party[#save.party + 1] = { species = species, level = level }
|
|
end,
|
|
getCoins = function() return save.player.coins end,
|
|
setCoins = function(value) save.player.coins = value end,
|
|
getMoney = function() return save.player.money end,
|
|
setMoney = function(_account, value) save.player.money = value end,
|
|
getItemName = function(index) return "ITEM" .. tostring(index) end,
|
|
getMonName = function(index) return "MON" .. tostring(index) end,
|
|
readVar = function() return #save.party end,
|
|
playSound = function() end,
|
|
waitSfx = function() return true end,
|
|
})
|
|
vm:start(key)
|
|
for _ = 1, 400 do vm:update() end
|
|
return save, seen, log
|
|
end
|
|
|
|
if not (cacheMaps and cacheScripts and cacheConsts) then
|
|
check("no GOLD_CACHE: the counters are not driven off the map (SKIP)",
|
|
true, true)
|
|
else
|
|
-- The two Celadon counters are bg events 1 and 2 of the prize room, in the
|
|
-- order the .asm lists them: the TM vendor at (2,1), the mon vendor at (4,1).
|
|
local room = cacheMaps.CELADON_GAME_CORNER_PRIZE_ROOM
|
|
local tmEvent = room and room.bgEvents and room.bgEvents[1]
|
|
local monEvent = room and room.bgEvents and room.bgEvents[2]
|
|
check("the Celadon TM counter is a readable bg event",
|
|
tmEvent ~= nil and (tmEvent.kind or 0) == 0, true)
|
|
check("and so is the Celadon mon counter",
|
|
monEvent ~= nil and (monEvent.kind or 0) == 0, true)
|
|
check("the TM counter's script is in the cache",
|
|
cacheScripts[tmEvent.scriptKey] ~= nil, true)
|
|
|
|
local bought, menus = runCounter(tmEvent.scriptKey, { coins = 5000, pick = 1 })
|
|
check("reading the counter opens its prize list",
|
|
menus[1] and menus[1].items[1], "TM32 1500")
|
|
check("with the COIN box up beside it", menus[1] and menus[1].balance,
|
|
"coins")
|
|
check("TM32 is handed over", bought.inventory[224], 1)
|
|
check("and 1500 coins are taken", bought.player.coins, 3500)
|
|
|
|
-- No COIN CASE is the very first refusal: no menu at all.
|
|
local _, noMenus = runCounter(tmEvent.scriptKey,
|
|
{ coins = 5000, coinCase = false, pick = 1 })
|
|
check("without a COIN CASE the counter never opens", #noMenus, 0)
|
|
|
|
-- The mon counter hands over a party member instead, and the dex entry is
|
|
-- `special GameCornerPrizeMonCheckDex` on the way past.
|
|
local won = runCounter(monEvent.scriptKey, { coins = 9999, pick = 1 })
|
|
check("the mon counter gives a prize mon", #won.party, 1)
|
|
check("the coins are taken at 3333", won.player.coins, 9999 - 3333)
|
|
check("and it is registered as caught",
|
|
won.pokedex and won.pokedex.caught["MON122"], true)
|
|
|
|
-- Goldenrod puts its counters behind PEOPLE rather than counter tiles: the
|
|
-- clerk who sells coins and the two receptionists, objects 1 to 3 in the
|
|
-- order GoldenrodGameCorner.asm lists them.
|
|
local goldenrod = cacheMaps.GOLDENROD_GAME_CORNER
|
|
local objects = (goldenrod and goldenrod.objects) or {}
|
|
local _, tmMenus = runCounter(objects[2] and objects[2].scriptKey,
|
|
{ coins = 9999, pick = 0 })
|
|
check("the Goldenrod TM counter opens its list",
|
|
tmMenus[1] and tmMenus[1].items[1], "TM25 5500")
|
|
check("under the COIN box too", tmMenus[1] and tmMenus[1].balance, "coins")
|
|
local _, monMenus = runCounter(objects[3] and objects[3].scriptKey,
|
|
{ coins = 9999, pick = 0 })
|
|
check("and the Goldenrod mon counter sells Gold's EKANS",
|
|
monMenus[1] and monMenus[1].items[2], "EKANS 700")
|
|
|
|
-- The coin vendor beside them is a `jumpstd` to GameCornerCoinVendorScript,
|
|
-- and its box is the MONEY + COIN one (DisplayMoneyAndCoinBalance) rather
|
|
-- than the COIN box the prize counters use.
|
|
local _, coinMenus = runCounter(objects[1] and objects[1].scriptKey,
|
|
{ money = 999999, pick = 1 })
|
|
check("the coin vendor shows MONEY and COIN together",
|
|
coinMenus[1] and coinMenus[1].balance, "moneycoins")
|
|
check("and 50 coins cost ¥1000", coinMenus[1] and coinMenus[1].items[1],
|
|
" 50 : \xc2\xa51000")
|
|
end
|
|
|
|
-- The boxes themselves (engine/menus/menu_2.asm through
|
|
-- src/ui/gen2/Chrome.lua): the yen field is six digits wide with the ¥ against
|
|
-- the first significant digit, and the coin field is four digits with leading
|
|
-- zeroes.
|
|
local Chrome = require("src.ui.gen2.Chrome")
|
|
check("¥1000 is right-aligned in a six-digit field", Chrome.money(1000),
|
|
" \xc2\xa51000")
|
|
check("a full wallet fills it", Chrome.money(999999), "\xc2\xa5999999")
|
|
check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true),
|
|
"0050")
|
|
|
|
print(("gen2 game corner: %d checks, %d failures"):format(checks, failures))
|
|
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
|
|
-- here would take the whole tier down and silently skip every suite after it.
|
|
if failures > 0 then
|
|
error(("%d assertion(s) failed"):format(failures), 0)
|
|
end
|