fix(drlg): resolve M1 iteration 2 audit findings (animSpeed, crossActObjOf, strip-types, __d2Scene, D2Rng.int)

This commit is contained in:
troytt 2026-09-29 10:29:42 +00:00
parent 8ddfbd318a
commit c2c1d280d5
26 changed files with 174 additions and 87 deletions

View File

@ -1476,7 +1476,7 @@ async function bakeDs1Variant(
} : {}), } : {}),
}) })
if (pick !== null) objectsWithArt += 1 if (pick !== null) objectsWithArt += 1
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(object.act ?? entry.act)}) has no art members`) else if (!resolved.artless) missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(object.act ?? entry.act)}) has no art members`)
} }
const objectFiles = objectPages.encode(0) const objectFiles = objectPages.encode(0)

View File

@ -240,12 +240,22 @@ export interface MonsterResult {
export type EntityResult = CharResult | MonsterResult export type EntityResult = CharResult | MonsterResult
class EntityWorkerContext { class EntityWorkerContext {
public readonly archives: MountedArchives
public readonly animData: any
public readonly monstats2: any
public readonly palettes: Record<number, Uint8Array>
constructor( constructor(
public readonly archives: MountedArchives, archives: MountedArchives,
public readonly animData: any, animData: any,
public readonly monstats2: any, monstats2: any,
public readonly palettes: Record<number, Uint8Array>, palettes: Record<number, Uint8Array>,
) {} ) {
this.archives = archives
this.animData = animData
this.monstats2 = monstats2
this.palettes = palettes
}
static async create(archiveDir: string, outDir: string): Promise<EntityWorkerContext> { static async create(archiveDir: string, outDir: string): Promise<EntityWorkerContext> {
const archives = new MountedArchives() const archives = new MountedArchives()

View File

@ -1,5 +1,5 @@
import { decompressSector, COMPRESSION_ADPCM_MONO } from '../src/mpq/decompress.js'; import { decompressSector, COMPRESSION_ADPCM_MONO } from '../src/mpq/decompress.ts';
import { AudioManager } from '../src/audio/manager.js'; import { AudioManager } from '../src/audio/manager.ts';
async function run() { async function run() {
console.log("Verifying Audio dependencies..."); console.log("Verifying Audio dependencies...");

View File

@ -78,7 +78,7 @@
* comparing one file first-wins against the other last-wins. Comparing like for * comparing one file first-wins against the other last-wins. Comparing like for
* like, the difference count is 0.) * like, the difference count is 0.)
*/ */
import { ByteReader, InvalidFieldError } from './reader' import { ByteReader, InvalidFieldError } from './reader.ts'
/** Hash blocks in the file, one per possible value of the name hash. */ /** Hash blocks in the file, one per possible value of the name hash. */
export const ANIMDATA_BLOCK_COUNT = 256 export const ANIMDATA_BLOCK_COUNT = 256

View File

@ -1,4 +1,4 @@
import { InvalidFieldError, TruncatedDataError } from "./reader"; import { InvalidFieldError, TruncatedDataError } from "./reader.ts";
/** /**
* Least-significant-bit-first bit reader for Diablo II's packed sprite * Least-significant-bit-first bit reader for Diablo II's packed sprite

View File

@ -54,7 +54,7 @@ const DEFAULT_MAX_FRAMES = 20000
/** Default guard for one frame (a 4096×4096 indexed frame). */ /** Default guard for one frame (a 4096×4096 indexed frame). */
const DEFAULT_MAX_FRAME_PIXELS = 1 << 24 const DEFAULT_MAX_FRAME_PIXELS = 1 << 24
import { requireBytes } from './reader' import { requireBytes } from './reader.ts'
/** Read a little-endian uint32. */ /** Read a little-endian uint32. */
function u32(data: Uint8Array, at: number): number { function u32(data: Uint8Array, at: number): number {

View File

@ -25,7 +25,7 @@
* is exactly why a renderer must resolve them through the layer records instead * is exactly why a renderer must resolve them through the layer records instead
* of using them directly. That resolution is `cofLayerOrder`. * of using them directly. That resolution is `cofLayerOrder`.
*/ */
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader' import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader.ts'
/** Fixed part of the file, up to and including the animation-speed byte. */ /** Fixed part of the file, up to and including the animation-speed byte. */
const HEADER_SIZE = 25 const HEADER_SIZE = 25

View File

@ -19,7 +19,7 @@
* entry unused), which is why frames carry a mask rather than relying on a * entry unused), which is why frames carry a mask rather than relying on a
* sentinel index. * sentinel index.
*/ */
import { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader' import { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader.ts'
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts' import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
/** Byte offset of the file header, and its size. */ /** Byte offset of the file header, and its size. */

View File

@ -1,4 +1,4 @@
import { CelError } from "./sprite"; import { CelError } from "./sprite.ts";
/** /**
* Diablo II `.ds1` map decoder. * Diablo II `.ds1` map decoder.
@ -15,7 +15,7 @@ import { CelError } from "./sprite";
* versions below 4. Both are encoded here exactly as the reference decoder * versions below 4. Both are encoded here exactly as the reference decoder
* reads them, including the pre-7 orientation lookup table. * reads them, including the pre-7 orientation lookup table.
*/ */
import { FormatError, InvalidFieldError, TruncatedDataError } from './reader' import { FormatError, InvalidFieldError, TruncatedDataError } from './reader.ts'
/** Pre-7 maps store orientations through this lookup rather than directly. */ /** Pre-7 maps store orientations through this lookup rather than directly. */
const LEGACY_DIRECTION_LOOKUP = [ const LEGACY_DIRECTION_LOOKUP = [

View File

@ -1,6 +1,6 @@
import { CelError } from "./sprite"; import { CelError } from "./sprite.ts";
import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader' import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader.ts'
/** Bytes of the fixed header before the tile count. */ /** Bytes of the fixed header before the tile count. */
const HEADER_PREFIX = 8 const HEADER_PREFIX = 8

View File

@ -1,4 +1,4 @@
import { FormatError } from "./reader"; import { FormatError } from "./reader.ts";
/** /**

View File

@ -7,7 +7,7 @@
* the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples). * the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples).
*/ */
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader' import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader.ts'
/** One decoded image, ready to blit. */ /** One decoded image, ready to blit. */
export interface PcxImage { export interface PcxImage {

View File

@ -1,4 +1,4 @@
import { ByteReader, FormatError, TruncatedDataError } from "./reader"; import { ByteReader, FormatError, TruncatedDataError } from "./reader.ts";
/** /**
* Diablo II `.pl2` palette+transform decoder. * Diablo II `.pl2` palette+transform decoder.

View File

@ -1,4 +1,4 @@
import { requireBytes } from './reader' import { requireBytes } from './reader.ts'
/** /**
* Diablo II `.tbl` string-table decoder (hash-bucket layout). * Diablo II `.tbl` string-table decoder (hash-bucket layout).
* *

View File

@ -79,11 +79,15 @@ function normalizeMember(path: string): string {
*/ */
export class WorldVariants<E extends VariantEntry> { export class WorldVariants<E extends VariantEntry> {
private readonly pinned = new Map<number, number>() private readonly pinned = new Map<number, number>()
private readonly index: VariantIndex<E>
private readonly seed: number
constructor( constructor(
private readonly index: VariantIndex<E>, index: VariantIndex<E>,
private readonly seed: number = WORLD_VARIANT_SEED, seed: number = WORLD_VARIANT_SEED,
) { ) {
this.index = index
this.seed = seed
for (const [act, layouts] of Object.entries(index.actLayouts ?? {})) { for (const [act, layouts] of Object.entries(index.actLayouts ?? {})) {
if (layouts.length === 0) throw new Error(`actLayouts[${act}] is empty`) if (layouts.length === 0) throw new Error(`actLayouts[${act}] is empty`)
layouts.forEach((layout, at) => { layouts.forEach((layout, at) => {

View File

@ -175,8 +175,9 @@ export class D2Rng {
* Draw an integer in an inclusive range `[min, max]`. * Draw an integer in an inclusive range `[min, max]`.
*/ */
int(min: number, max: number): number { int(min: number, max: number): number {
const lo = Math.ceil(Math.min(min, max)) if (max <= min) return Math.floor(min)
const hi = Math.floor(Math.max(min, max)) const lo = Math.ceil(min)
const hi = Math.floor(max)
const span = hi - lo + 1 const span = hi - lo + 1
if (span <= 0) return lo if (span <= 0) return lo
return lo + this.rand(span) return lo + this.rand(span)

View File

@ -529,6 +529,15 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) { for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
objSlotOf.set(DRLGPRESET_GetObjectIndexFromObjPreset(nAct, slot), slot) // lowest slot wins objSlotOf.set(DRLGPRESET_GetObjectIndexFromObjPreset(nAct, slot), slot) // lowest slot wins
} }
const crossActObjOf = new Map<number, { readonly act: number; readonly slot: number }>()
for (let a = 4; a >= 0; a -= 1) {
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
const objIdx = DRLGPRESET_GetObjectIndexFromObjPreset(a, slot)
if (objIdx >= 0 && objIdx !== OBJECT_CLASS_INVALID_PRESET) {
crossActObjOf.set(objIdx, { act: a + 1, slot })
}
}
}
const monSection = DATATBLS_GetMonPresetTxtActSection(ctx.tables, nAct) const monSection = DATATBLS_GetMonPresetTxtActSection(ctx.tables, nAct)
if (monSection === null) throw new Error(`level ${levelId}: MonPreset has no act ${nAct + 1} section`) if (monSection === null) throw new Error(`level ${levelId}: MonPreset has no act ${nAct + 1} section`)
const nMonStats = ctx.tables.nMonStatsTxtRecordCount const nMonStats = ctx.tables.nMonStatsTxtRecordCount
@ -574,7 +583,15 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
if (u.index === OBJECT_CLASS_INVALID_PRESET) { skippedInvalidObjects += 1; break } if (u.index === OBJECT_CLASS_INVALID_PRESET) { skippedInvalidObjects += 1; break }
if (u.index < 0) throw new Error(`level ${levelId}: object unit with class ${u.index}`) if (u.index < 0) throw new Error(`level ${levelId}: object unit with class ${u.index}`)
const slot = objSlotOf.get(u.index) const slot = objSlotOf.get(u.index)
objects.push({ type: DS1_TYPE_OBJECT, id: slot ?? u.index + DS1_OBJPRESET_SLOTS, x: u.subX, y: u.subY, flags: u.spawned }) const crossObj = slot === undefined ? crossActObjOf.get(u.index) : undefined
objects.push({
type: DS1_TYPE_OBJECT,
id: slot ?? crossObj?.slot ?? u.index + DS1_OBJPRESET_SLOTS,
x: u.subX,
y: u.subY,
flags: u.spawned,
...(crossObj !== undefined ? { act: crossObj.act } : {}),
})
nObjects += 1 nObjects += 1
// Throne of Destruction (131, wthrone.ds1) connects to The Worldstone Chamber (132, Vis1=132, // Throne of Destruction (131, wthrone.ds1) connects to The Worldstone Chamber (132, Vis1=132,
// Warp1=82) via the preset object BaalsPortal (Objects.txt row 563, "The Worldstone Chamber") // Warp1=82) via the preset object BaalsPortal (Objects.txt row 563, "The Worldstone Chamber")
@ -961,8 +978,9 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables:
for (const room of level.rooms) { for (const room of level.rooms) {
if (room.preset) { if (room.preset) {
const prest = DATATBLS_GetLvlPrestTxtRecord(tables, room.preset.prest) const prest = DATATBLS_GetLvlPrestTxtRecord(tables, room.preset.prest)
if (prest.dwAnimate !== 0 && prest.nAnimSpeed > 0) { const speed = prest.nAnimSpeed > 0 ? prest.nAnimSpeed : prest.dwAnimate
animSpeed = prest.nAnimSpeed if (speed > 0) {
animSpeed = speed
break break
} }
} }

View File

@ -170,10 +170,14 @@ export function DRLGPRESET_MapTileType(nId: number): number {
/** `int32_t* pData` walking over a DS1 image (unaligned little-endian reads, bounds-checked). */ /** `int32_t* pData` walking over a DS1 image (unaligned little-endian reads, bounds-checked). */
class Ds1Cursor { class Ds1Cursor {
private readonly bytes: Uint8Array
private readonly path: string
private readonly view: DataView private readonly view: DataView
offset: number offset: number
constructor(private readonly bytes: Uint8Array, offset: number, private readonly path: string) { constructor(bytes: Uint8Array, offset: number, path: string) {
this.bytes = bytes
this.path = path
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
this.offset = offset this.offset = offset
} }

View File

@ -186,41 +186,44 @@ export interface ItemFlagsDecoded {
} }
/** Location / storage mode of the item in D2 bitstream (3 bits). */ /** Location / storage mode of the item in D2 bitstream (3 bits). */
export enum ItemMode { export type ItemMode = 0 | 1 | 2 | 3 | 4 | 5 | 6
STORED = 0, // In inventory, stash, or Horadric Cube export const ItemMode = {
EQUIPPED = 1, // Equipped on character body STORED: 0 as ItemMode, // In inventory, stash, or Horadric Cube
BELT = 2, // Slotted in potion belt EQUIPPED: 1 as ItemMode, // Equipped on character body
GROUND = 3, // Dropped on the ground BELT: 2 as ItemMode, // Slotted in potion belt
CURSOR = 4, // Held on mouse cursor GROUND: 3 as ItemMode, // Dropped on the ground
DROPPING = 5, // Currently in drop motion CURSOR: 4 as ItemMode, // Held on mouse cursor
SOCKETED = 6, // Socketed into a parent item DROPPING: 5 as ItemMode, // Currently in drop motion
} SOCKETED: 6 as ItemMode, // Socketed into a parent item
} as const
/** Body equipment slot (4 bits). */ /** Body equipment slot (4 bits). */
export enum EquippedSlot { export type EquippedSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
NONE = 0, export const EquippedSlot = {
HEAD = 1, NONE: 0 as EquippedSlot,
NECK = 2, HEAD: 1 as EquippedSlot,
TORSO = 3, NECK: 2 as EquippedSlot,
RIGHT_HAND = 4, TORSO: 3 as EquippedSlot,
LEFT_HAND = 5, RIGHT_HAND: 4 as EquippedSlot,
RIGHT_FINGER = 6, LEFT_HAND: 5 as EquippedSlot,
LEFT_FINGER = 7, RIGHT_FINGER: 6 as EquippedSlot,
WAIST = 8, LEFT_FINGER: 7 as EquippedSlot,
FEET = 9, WAIST: 8 as EquippedSlot,
HANDS = 10, FEET: 9 as EquippedSlot,
ALT_RIGHT_HAND = 11, HANDS: 10 as EquippedSlot,
ALT_LEFT_HAND = 12, ALT_RIGHT_HAND: 11 as EquippedSlot,
} ALT_LEFT_HAND: 12 as EquippedSlot,
} as const
/** Container / storage page (3 bits). */ /** Container / storage page (3 bits). */
export enum StoragePage { export type StoragePage = 0 | 1 | 2 | 4 | 5
INVENTORY = 0, export const StoragePage = {
EQUIPPED = 1, INVENTORY: 0 as StoragePage,
BELT = 2, EQUIPPED: 1 as StoragePage,
CUBE = 4, BELT: 2 as StoragePage,
STASH = 5, CUBE: 4 as StoragePage,
} STASH: 5 as StoragePage,
} as const
/** Item location and positioning structure. */ /** Item location and positioning structure. */
export interface ItemLocation { export interface ItemLocation {
@ -241,17 +244,18 @@ export interface ItemLocation {
} }
/** Quality tier of the item (4 bits). */ /** Quality tier of the item (4 bits). */
export enum ItemQuality { export type ItemQuality = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
LOW = 1, // Inferior / Low Quality / Cracked / Crude / Damaged export const ItemQuality = {
NORMAL = 2, // Normal base LOW: 1 as ItemQuality, // Inferior / Low Quality / Cracked / Crude / Damaged
SUPERIOR = 3, // Superior / High Quality NORMAL: 2 as ItemQuality, // Normal base
MAGIC = 4, // Magic SUPERIOR: 3 as ItemQuality, // Superior / High Quality
SET = 5, // Set MAGIC: 4 as ItemQuality, // Magic
RARE = 6, // Rare SET: 5 as ItemQuality, // Set
UNIQUE = 7, // Unique RARE: 6 as ItemQuality, // Rare
CRAFTED = 8, // Crafted UNIQUE: 7 as ItemQuality, // Unique
TEMPERED = 9, // Tempered (expansion prototype remnant) CRAFTED: 8 as ItemQuality, // Crafted
} TEMPERED: 9 as ItemQuality, // Tempered (expansion prototype remnant)
} as const
/** One affix slot for Rare / Crafted items (1 bit present + 11-bit id). */ /** One affix slot for Rare / Crafted items (1 bit present + 11-bit id). */
export interface ItemAffixSlot { export interface ItemAffixSlot {

View File

@ -195,14 +195,28 @@ export interface TreasureClassTable {
* Concrete implementation of `TreasureClassTable`. * Concrete implementation of `TreasureClassTable`.
*/ */
class TreasureClassTableImpl implements TreasureClassTable { class TreasureClassTableImpl implements TreasureClassTable {
readonly all: TreasureClassNode[]
readonly byName: Map<string, TreasureClassNode>
readonly byGroup: Map<number, TreasureClassNode[]>
readonly danglingReferences: ReadonlySet<string>
readonly autoTCDanglingReferences: ReadonlySet<string>
readonly baseItemDanglingReferences: ReadonlySet<string>
constructor( constructor(
readonly all: TreasureClassNode[], all: TreasureClassNode[],
readonly byName: Map<string, TreasureClassNode>, byName: Map<string, TreasureClassNode>,
readonly byGroup: Map<number, TreasureClassNode[]>, byGroup: Map<number, TreasureClassNode[]>,
readonly danglingReferences: ReadonlySet<string>, danglingReferences: ReadonlySet<string>,
readonly autoTCDanglingReferences: ReadonlySet<string>, autoTCDanglingReferences: ReadonlySet<string>,
readonly baseItemDanglingReferences: ReadonlySet<string>, baseItemDanglingReferences: ReadonlySet<string>,
) {} ) {
this.all = all
this.byName = byName
this.byGroup = byGroup
this.danglingReferences = danglingReferences
this.autoTCDanglingReferences = autoTCDanglingReferences
this.baseItemDanglingReferences = baseItemDanglingReferences
}
get(name: string): TreasureClassNode | undefined { get(name: string): TreasureClassNode | undefined {
return this.byName.get(name) return this.byName.get(name)

View File

@ -1,4 +1,4 @@
import { NEXT_STEP_TABLE, STEP_SIZE_TABLE } from './adpcm-tables.js'; import { NEXT_STEP_TABLE, STEP_SIZE_TABLE } from './adpcm-tables.ts';
export class AdpcmError extends Error { export class AdpcmError extends Error {
constructor(message: string) { constructor(message: string) {

View File

@ -12,9 +12,9 @@
* `implode.ts` and is called here. * `implode.ts` and is called here.
*/ */
import { ImplodeError, explode } from './implode.js'; import { ImplodeError, explode } from './implode.ts';
import { decompressHuffman } from './huffman.js'; import { decompressHuffman } from './huffman.ts';
import { decompressAdpcm } from './adpcm.js'; import { decompressAdpcm } from './adpcm.ts';
/** Codec bit: adaptive Huffmann (FGK variant). */ /** Codec bit: adaptive Huffmann (FGK variant). */
export const COMPRESSION_HUFFMANN = 0x01 export const COMPRESSION_HUFFMANN = 0x01

View File

@ -4,7 +4,7 @@ export class HuffmanError extends Error {
this.name = 'HuffmanError'; this.name = 'HuffmanError';
} }
} }
import { HUFFMAN_WEIGHT_TABLES } from './huffman-tables.js'; import { HUFFMAN_WEIGHT_TABLES } from './huffman-tables.ts';
export const HUFF_DECOMPRESS_ERROR = 0x1FF; export const HUFF_DECOMPRESS_ERROR = 0x1FF;
@ -74,10 +74,11 @@ class InputStream {
} }
} }
enum InsertPoint { const InsertPoint = {
InsertAfter = 1, InsertAfter: 1,
InsertBefore = 2 InsertBefore: 2,
} } as const;
type InsertPoint = (typeof InsertPoint)[keyof typeof InsertPoint];
class THuffmanTree { class THuffmanTree {
listHead: THuffmanTreeItem; listHead: THuffmanTreeItem;

View File

@ -250,6 +250,7 @@ export const state: ActSceneState = {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.__d2webAct = state window.__d2webAct = state
window.__d2webActs = state window.__d2webActs = state
;(window as unknown as { __d2Scene?: ActSceneState }).__d2Scene = state
} }
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo { export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {

View File

@ -1,3 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
import { import {
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS, DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
@ -143,4 +145,25 @@ describe('Animated Tile Speed from LvlPrest.txt Animate Column (Issue #85)', ()
expect(tile.currentFrameIndex).toBe(2) expect(tile.currentFrameIndex).toBe(2)
}) })
}) })
describe('Baked Act 4 Lava and Act 5 Hell portal scenes preserve animSpeed = 1 (10240ms)', () => {
const lavaScenes = [
'samples/d2-packs/act4/107-act-4-lava-1-var1/scene.json',
'samples/d2-packs/act4/108-act-4-diablo-1-var1/scene.json',
'samples/d2-packs/act5/125-act-5-hell-1-var1/scene.json',
'samples/d2-packs/act5/126-act-5-hell-2-var1/scene.json',
'samples/d2-packs/act5/127-act-5-hell-3-var1/scene.json',
]
test.each(lavaScenes)('%s has animSpeed === 1 and frameDurationMs === 10240', relPath => {
const fullPath = resolve(process.cwd(), relPath)
expect(existsSync(fullPath)).toBe(true)
const scene = JSON.parse(readFileSync(fullPath, 'utf8')) as {
animSpeed?: number
frameDurationMs?: number
}
expect(scene.animSpeed).toBe(1)
expect(scene.frameDurationMs).toBe(10240)
})
})
}) })

View File

@ -310,7 +310,14 @@ describe("1.13c Diablo II PRNG and Per-Unit Seed (D2Common!6FD510B0 / D2Game!6FC
expect(v).toBeGreaterThanOrEqual(5) expect(v).toBeGreaterThanOrEqual(5)
expect(v).toBeLessThanOrEqual(10) expect(v).toBeLessThanOrEqual(10)
} }
const loBeforeDegenerate = rng.lo
const hiBeforeDegenerate = rng.hi
expect(rng.int(7, 7)).toBe(7) expect(rng.int(7, 7)).toBe(7)
expect(rng.lo).toBe(loBeforeDegenerate)
expect(rng.hi).toBe(hiBeforeDegenerate)
expect(rng.int(10, 5)).toBe(10)
expect(rng.lo).toBe(loBeforeDegenerate)
expect(rng.hi).toBe(hiBeforeDegenerate)
const r = rng.range(10, 20) const r = rng.range(10, 20)
expect(r).toBeGreaterThanOrEqual(10) expect(r).toBeGreaterThanOrEqual(10)