feat(scene): acts.html 接入房间级惰性刷怪与休眠/唤醒流式加载
把已合入 main 但从未被生产代码调用的两块能力接到 acts.html 的 act-scene 上,按 D2Game.dll 的做法组织怪物生命周期: - act-scene:新增 buildStreamingRooms(),把关卡切成房间并把怪物群 分配给房间,交给 GameEngine 的 roomPacks。房间按「场景像素空间」 切分而不是格子空间——一块矩形格子投影出来是菱形,相邻矩形的 AABB 几乎完全重叠,那样切会让全图房间同时「靠近玩家」,流式加载 就没有意义了。落在菱形外、没有任何可走地面的房间直接丢弃,否则 分到它头上的怪物群会被静默吞掉。 - monster-streaming:populateRoom 改用 monster-rooms 的 spawnPacksInRoom,怪物在房间内抱团成营地,而不是沿用旧的全局 螺旋撒点。这把 feat/monster-streaming 和 feat/monster-room-placement 两条分支真正合到了一起。落进安全区(传送点/关卡入口)的个体会被 推到安全区边缘。 - 重新打开怪物仇恨。它当初被关掉是因为全图怪物同时醒着,会被拖成 一条长队;现在玩家没走近的怪物要么还没实例化、要么在睡眠中。 - swapLevel 过图时重建房间表,而不是在门口一次性刷完整张图。 tests/wilderness-roads:角落断言改为按槽位号统计。这条断言早于 6b9c3d3(Act 1 北/西/三个角改用岩壁边界),岩壁族还带字母后缀 (Wild Cliff Border 6A),按全名查表就落空了。 Verified: typecheck + 923 tests pass;另用真实烘焙数据回放, 冰冷之原 45 群 57 只怪 → 45 个房间,出生点只实例化 18 房 26 只, 走遍全图后恰好 45 房 57 只,无遗漏、无重复。
This commit is contained in:
parent
374bf2fff6
commit
4ece5aa3c1
|
|
@ -165,7 +165,14 @@ export class GameEngine {
|
|||
selectedSkill = 0
|
||||
npcEntities: NpcEntity[] = []
|
||||
dialog: string[] = []
|
||||
streamingManager?: MonsterStreamingManager
|
||||
/**
|
||||
* Lazy room population driver, when the level supplied a room plan.
|
||||
*
|
||||
* Explicitly assignable to `undefined`: walking into a level the baker gave
|
||||
* no packs has to clear the previous level's manager, or its rooms would keep
|
||||
* waking monsters that belong to a map nobody is standing on.
|
||||
*/
|
||||
streamingManager?: MonsterStreamingManager | undefined
|
||||
/**
|
||||
* Whether the save input was held on the previous tick.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -13,14 +13,20 @@
|
|||
* When the player returns, monsters wake up (`monster.sleeping = false`) with their
|
||||
* exact positions, health, and cooldowns preserved. Dead monsters remain dead.
|
||||
*/
|
||||
import { spawnMonsterPacks } from './combat.ts'
|
||||
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
|
||||
import { spawnPacksInRoom } from './monster-rooms.ts'
|
||||
import type { MapRoom } from './monster-rooms.ts'
|
||||
|
||||
/** Definition of a room holding unspawned monster pack blueprints. */
|
||||
export interface StreamingRoomDef {
|
||||
readonly id: string | number
|
||||
readonly bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
|
||||
readonly packs: readonly MonsterPack[]
|
||||
/**
|
||||
* Optional source `MapRoom` (carrying cell coordinates / safe-zone flags) used when
|
||||
* the room is lazily populated. When omitted one is synthesised from `bounds`.
|
||||
*/
|
||||
readonly room?: MapRoom | undefined
|
||||
}
|
||||
|
||||
/** Runtime state of a streaming room. */
|
||||
|
|
@ -91,6 +97,7 @@ export class MonsterStreamingManager {
|
|||
id: r.id,
|
||||
bounds: r.bounds,
|
||||
packs: r.packs,
|
||||
room: r.room,
|
||||
bPopulated: false,
|
||||
active: false,
|
||||
monsterIndices: [],
|
||||
|
|
@ -139,33 +146,73 @@ export class MonsterStreamingManager {
|
|||
|
||||
/**
|
||||
* Lazily instantiates a room's monster packs into `world.monsters`.
|
||||
* Sets `bPopulated = true` to guarantee idempotency.
|
||||
*
|
||||
* Packs are placed as tight camps inside the room boundary (see
|
||||
* `spawnPacksInRoom`), mirroring D2's per-room monster groups, rather than
|
||||
* being scattered across the whole level. Sets `bPopulated = true` to
|
||||
* guarantee idempotency.
|
||||
*/
|
||||
private populateRoom(room: StreamingRoomState): void {
|
||||
if (room.bPopulated) return
|
||||
|
||||
const startIndex = this.world.monsters.length
|
||||
const centerX = (room.bounds.minX + room.bounds.maxX) / 2
|
||||
const centerY = (room.bounds.minY + room.bounds.maxY) / 2
|
||||
const width = Math.abs(room.bounds.maxX - room.bounds.minX)
|
||||
const height = Math.abs(room.bounds.maxY - room.bounds.minY)
|
||||
const spread = Math.max(width, height) / 2 || 100
|
||||
const mapRoom = room.room ?? boundsToMapRoom(room.id, room.bounds)
|
||||
|
||||
spawnMonsterPacks(
|
||||
this.world,
|
||||
room.packs,
|
||||
{ x: centerX, y: centerY },
|
||||
spread,
|
||||
this.terrain,
|
||||
this.options?.safeZones,
|
||||
)
|
||||
spawnPacksInRoom(this.world, room.packs, mapRoom, this.terrain)
|
||||
|
||||
const endIndex = this.world.monsters.length
|
||||
for (let i = startIndex; i < endIndex; i += 1) {
|
||||
room.monsterIndices.push(i)
|
||||
}
|
||||
|
||||
// Push any monster that landed inside a safe zone (waypoint / level entrance)
|
||||
// out to the safe zone perimeter so towns and warps stay clear.
|
||||
const safeZones = this.options?.safeZones
|
||||
if (safeZones !== undefined && safeZones.length > 0) {
|
||||
for (let i = startIndex; i < endIndex; i += 1) {
|
||||
const monster = this.world.monsters[i]
|
||||
if (monster === undefined) continue
|
||||
for (const zone of safeZones) {
|
||||
const dx = monster.x - zone.x
|
||||
const dy = monster.y - zone.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist >= zone.radius) continue
|
||||
const angle = dist > 1e-6 ? Math.atan2(dy, dx) : (i * 2.399963)
|
||||
monster.x = zone.x + Math.cos(angle) * (zone.radius + 8)
|
||||
monster.y = zone.y + Math.sin(angle) * (zone.radius + 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
room.bPopulated = true
|
||||
this.options?.onSpawnRoom?.(room)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal `MapRoom` from pixel bounds when the caller did not supply one.
|
||||
*
|
||||
* @param id - room identifier (also seeds the camp ring angle).
|
||||
* @param bounds - axis-aligned pixel bounds of the room.
|
||||
* @returns a `MapRoom` usable by `spawnPacksInRoom`.
|
||||
*/
|
||||
function boundsToMapRoom(
|
||||
id: string | number,
|
||||
bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number },
|
||||
): MapRoom {
|
||||
const minX = Math.min(bounds.minX, bounds.maxX)
|
||||
const maxX = Math.max(bounds.minX, bounds.maxX)
|
||||
const minY = Math.min(bounds.minY, bounds.maxY)
|
||||
const maxY = Math.max(bounds.minY, bounds.maxY)
|
||||
return {
|
||||
id,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: Math.max(1, maxX - minX),
|
||||
height: Math.max(1, maxY - minY),
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
cellsX: 0,
|
||||
cellsY: 0,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import type { Palette } from '../formats/pal.ts'
|
|||
import type { SpriteSheet } from '../formats/sprite.ts'
|
||||
import {
|
||||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
||||
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||
buildIsoMapScene, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||
} from '../game/d2map.ts'
|
||||
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
||||
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
|
||||
|
|
@ -37,6 +37,10 @@ import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
|
|||
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
||||
import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts'
|
||||
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
|
||||
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
|
||||
import type { StreamingRoomDef } from '../game/monster-streaming.ts'
|
||||
import { allocatePacksToRooms } from '../game/monster-rooms.ts'
|
||||
import type { MapRoom } from '../game/monster-rooms.ts'
|
||||
import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
|
||||
import { GameEngine, syncEngineState } from "../game/engine.ts"
|
||||
import type { NpcEntity } from "../game/engine.ts"
|
||||
|
|
@ -108,6 +112,32 @@ const MARKER_HEIGHT = 26
|
|||
* works at all.
|
||||
*/
|
||||
const PLACEHOLDER_MONSTER_COUNT = 12
|
||||
/**
|
||||
* Side of a streaming room, in cells.
|
||||
*
|
||||
* `D2Common`'s DRLG cuts a wilderness level into rooms of a handful of tiles
|
||||
* each; a generated level here is 8 cells per generator block, so 24 cells is
|
||||
* three blocks — about a screen and a half, which is the granularity at which
|
||||
* populating a room is cheap enough to do inside one frame.
|
||||
*/
|
||||
const ROOM_CELL_SIZE = 24
|
||||
/**
|
||||
* Distance at which a room is populated and woken, in scene pixels.
|
||||
*
|
||||
* The viewport is ~1280×720, and the isometric projection makes a screen
|
||||
* roughly 1300px wide in scene space, so 1600 is "just past the edge of the
|
||||
* screen": monsters exist before they can be seen, never in front of the
|
||||
* player's eyes.
|
||||
*/
|
||||
const ROOM_ACTIVATION_RADIUS = 1600
|
||||
/**
|
||||
* Distance at which a room goes back to sleep, in scene pixels.
|
||||
*
|
||||
* Deliberately larger than the activation radius. The gap is hysteresis: a
|
||||
* player pacing across a room boundary would otherwise toggle a room's AI on
|
||||
* and off every few frames.
|
||||
*/
|
||||
const ROOM_DEACTIVATION_RADIUS = 2400
|
||||
/**
|
||||
* `localStorage` key holding the single save slot.
|
||||
*
|
||||
|
|
@ -1347,6 +1377,125 @@ async function loadCharacterArt(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any walkable sub-tile lies inside a scene-pixel rectangle.
|
||||
*
|
||||
* A level's pixel extent is the bounding box of an isometric diamond, so
|
||||
* roughly half of it is off-map. Rooms carved out of that half can never hold a
|
||||
* monster — `spawnPacksInRoom` would search their whole area, find nothing and
|
||||
* silently drop the pack — so they are discarded before packs are handed out.
|
||||
*
|
||||
* @param grid - the level's collision grid.
|
||||
* @param minX - rectangle left edge, scene pixels.
|
||||
* @param minY - rectangle top edge, scene pixels.
|
||||
* @param maxX - rectangle right edge, scene pixels.
|
||||
* @param maxY - rectangle bottom edge, scene pixels.
|
||||
* @returns true when at least one sampled point is walkable.
|
||||
*/
|
||||
function roomHasWalkableGround(
|
||||
grid: CollisionGrid,
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
): boolean {
|
||||
const samples = 9
|
||||
for (let iy = 1; iy < samples; iy += 1) {
|
||||
const y = minY + (maxY - minY) * (iy / samples)
|
||||
for (let ix = 1; ix < samples; ix += 1) {
|
||||
const x = minX + (maxX - minX) * (ix / samples)
|
||||
if (!isBlockedAt(grid, x, y)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts a level into streaming rooms and allocates its monster packs to them.
|
||||
*
|
||||
* This is the level-generation half of `D2Game.dll`'s lazy population: the DRLG
|
||||
* hands the game a room list, each room remembers *what* should live in it, and
|
||||
* nothing is instantiated until the player walks close enough. `GameEngine`
|
||||
* owns the other half (see `MonsterStreamingManager`).
|
||||
*
|
||||
* Rooms are laid out over **scene-pixel space**, not cell space. A rectangle of
|
||||
* cells projects to a diamond, and the axis-aligned boxes of neighbouring cell
|
||||
* rectangles overlap almost completely — which would make every room in the
|
||||
* level "near" the player at once and defeat the streaming entirely.
|
||||
*
|
||||
* @param runtime - the level being entered.
|
||||
* @param packs - the level's packs, with landmark tiles already resolved to
|
||||
* scene positions.
|
||||
* @param safeZones - waypoints, entrances and the landing spot; rooms touching
|
||||
* one are left empty so a player never arrives inside a camp.
|
||||
* @returns the rooms that hold at least one pack, in load order.
|
||||
*/
|
||||
function buildStreamingRooms(
|
||||
runtime: MapRuntime,
|
||||
packs: readonly MonsterPack[],
|
||||
safeZones: readonly SafeZone[],
|
||||
): StreamingRoomDef[] {
|
||||
if (packs.length === 0) return []
|
||||
|
||||
const grid = runtime.grid
|
||||
const roomWidth = ROOM_CELL_SIZE * ORTHO_CELL_WIDTH / 2
|
||||
const roomHeight = ROOM_CELL_SIZE * ORTHO_CELL_HEIGHT / 2
|
||||
const cellsPerRoomX = Math.max(1, Math.round(roomWidth / ORTHO_CELL_WIDTH))
|
||||
const cellsPerRoomY = Math.max(1, Math.round(roomHeight / ORTHO_CELL_HEIGHT))
|
||||
|
||||
const rooms: MapRoom[] = []
|
||||
let id = 0
|
||||
for (let y = 0; y < runtime.heightPx; y += roomHeight) {
|
||||
for (let x = 0; x < runtime.widthPx; x += roomWidth) {
|
||||
const maxX = Math.min(x + roomWidth, runtime.widthPx)
|
||||
const maxY = Math.min(y + roomHeight, runtime.heightPx)
|
||||
if (maxX - x < 32 || maxY - y < 32) continue
|
||||
if (!roomHasWalkableGround(grid, x, y, maxX, maxY)) continue
|
||||
|
||||
// A safe zone is a circle; the cheap box-vs-box test is deliberate. An
|
||||
// over-eager match only costs one empty room, while a missed one puts a
|
||||
// pack on top of a waypoint.
|
||||
const isSafeZone = safeZones.some(zone =>
|
||||
zone.x + zone.radius > x && zone.x - zone.radius < maxX
|
||||
&& zone.y + zone.radius > y && zone.y - zone.radius < maxY)
|
||||
|
||||
// Cell coordinates are bookkeeping for `allocatePacksToRooms`' area
|
||||
// weighting; every room is the same size, so the value only has to be
|
||||
// consistent, not exact.
|
||||
const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2)
|
||||
rooms.push({
|
||||
id: id,
|
||||
x,
|
||||
y,
|
||||
width: maxX - x,
|
||||
height: maxY - y,
|
||||
cellX: Math.floor(cell.x - cellsPerRoomX / 2),
|
||||
cellY: Math.floor(cell.y - cellsPerRoomY / 2),
|
||||
cellsX: cellsPerRoomX,
|
||||
cellsY: cellsPerRoomY,
|
||||
isSafeZone,
|
||||
})
|
||||
id += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (rooms.length === 0) return []
|
||||
|
||||
const allocation = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level))
|
||||
const defs: StreamingRoomDef[] = []
|
||||
for (const room of rooms) {
|
||||
const roomPacks = allocation.get(room)
|
||||
if (roomPacks === undefined || roomPacks.length === 0) continue
|
||||
defs.push({
|
||||
id: room.id,
|
||||
bounds: { minX: room.x, minY: room.y, maxX: room.x + room.width, maxY: room.y + room.height },
|
||||
packs: roomPacks,
|
||||
room,
|
||||
})
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the loop for a runtime.
|
||||
*
|
||||
|
|
@ -1419,6 +1568,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
}
|
||||
return pack
|
||||
})
|
||||
const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
|
||||
const engine = new GameEngine(
|
||||
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
||||
width: FEET_WIDTH,
|
||||
|
|
@ -1441,16 +1591,29 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
playerDamage: 7,
|
||||
playerManaPerAttack: 2,
|
||||
respawnTicks: 75,
|
||||
disableMonsterAggro: true,
|
||||
// Aggro is safe again now that rooms stream: a monster the player has
|
||||
// not walked up to is either unspawned or asleep, so "chase the player"
|
||||
// can no longer drag the whole level into a single conga line. It was
|
||||
// only ever disabled because every pack on the map was awake at once.
|
||||
disableMonsterAggro: false,
|
||||
},
|
||||
lootSeed: 0x5eed,
|
||||
talkRadius: 80,
|
||||
pickupRadius: 64,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
// `monsterCount` / `monsterPacks` are the fallback for levels the baker
|
||||
// gave no pack plan: with rooms present the engine skips frame-0 spawning
|
||||
// entirely and lets the streaming manager do it room by room.
|
||||
monsterCount: runtime.monsterCount,
|
||||
monsterPacks: resolvedPacks,
|
||||
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
||||
roomPacks: streamingRooms,
|
||||
streamingOptions: {
|
||||
activationRadius: ROOM_ACTIVATION_RADIUS,
|
||||
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
|
||||
safeZones,
|
||||
},
|
||||
safeZones,
|
||||
// This scene knows where its NPCs stand — the DS1 carries their
|
||||
// coordinates — so the engine must not build a ring we would discard.
|
||||
|
|
@ -1585,10 +1748,31 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
return pack
|
||||
})
|
||||
const spread = Math.max(next.widthPx, next.heightPx) / 4
|
||||
if (nextResolvedPacks.length > 0) {
|
||||
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
|
||||
} else if (next.monsterCount > 0) {
|
||||
spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain)
|
||||
// A level change means a new room list. `engine.terrain` was already
|
||||
// swapped above, which matters: the manager captures it and uses it for
|
||||
// every later collision test, so building it any earlier would place the
|
||||
// destination's monsters against the departed level's walls.
|
||||
const nextRooms = buildStreamingRooms(next, nextResolvedPacks, nextSafeZones)
|
||||
if (nextRooms.length > 0) {
|
||||
engine.streamingManager = new MonsterStreamingManager(
|
||||
engine.world,
|
||||
engine.terrain,
|
||||
nextRooms,
|
||||
{
|
||||
activationRadius: ROOM_ACTIVATION_RADIUS,
|
||||
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
|
||||
safeZones: nextSafeZones,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Levels the baker gave no pack plan keep the old behaviour: there is
|
||||
// nothing to stream, so the handful of placeholders go down at once.
|
||||
engine.streamingManager = undefined
|
||||
if (nextResolvedPacks.length > 0) {
|
||||
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
|
||||
} else if (next.monsterCount > 0) {
|
||||
spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain)
|
||||
}
|
||||
}
|
||||
|
||||
if (next.palette !== null) {
|
||||
|
|
|
|||
|
|
@ -129,16 +129,31 @@ describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1
|
|||
substitutions: [],
|
||||
})
|
||||
const usage = res.stats.borderUsage as Record<string, number>
|
||||
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR)
|
||||
expect(usage['Act 1 - Wild Border 5']).toBe(1)
|
||||
expect(usage['Act 1 - Wild Border 6']).toBe(1)
|
||||
expect(usage['Act 1 - Wild Border 7']).toBe(1)
|
||||
expect(usage['Act 1 - Wild Border 8']).toBe(1)
|
||||
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR).
|
||||
//
|
||||
// Counted by slot number rather than by exact name. Act 1's north, west and
|
||||
// three of its corners prefer rock cliff pieces over wooden fence ones
|
||||
// (`Act 1 - Wild Cliff Border 6A`), and the cliff family also carries
|
||||
// lettered variants — all of which are still "the NW corner piece". What
|
||||
// must hold is that each corner block is stamped exactly once; stamping
|
||||
// twice is what leaves a corner with two overlapping silhouettes.
|
||||
const slotUsage = (slot: number): number => {
|
||||
const pattern = new RegExp(`\\bBorder\\s*0?${String(slot)}[A-Z]?$`, 'i')
|
||||
let total = 0
|
||||
for (const [name, count] of Object.entries(usage)) {
|
||||
if (pattern.test(name)) total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
expect(slotUsage(5)).toBe(1)
|
||||
expect(slotUsage(6)).toBe(1)
|
||||
expect(slotUsage(7)).toBe(1)
|
||||
expect(slotUsage(8)).toBe(1)
|
||||
// Edges: Border 1 (Bottom), Border 2 (Left), Border 3 (Top), Border 4 (Right)
|
||||
expect(usage['Act 1 - Wild Border 1']).toBeGreaterThan(0)
|
||||
expect(usage['Act 1 - Wild Border 2']).toBeGreaterThan(0)
|
||||
expect(usage['Act 1 - Wild Border 3']).toBeGreaterThan(0)
|
||||
expect(usage['Act 1 - Wild Border 4']).toBeGreaterThan(0)
|
||||
expect(slotUsage(1)).toBeGreaterThan(0)
|
||||
expect(slotUsage(2)).toBeGreaterThan(0)
|
||||
expect(slotUsage(3)).toBeGreaterThan(0)
|
||||
expect(slotUsage(4)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
|
|
|||
Loading…
Reference in New Issue