2023-03-03 16:23:46 +01:00
import { inject , injectable } from "tsyringe" ;
import { ContainerHelper } from "../helpers/ContainerHelper" ;
import { ItemHelper } from "../helpers/ItemHelper" ;
import { PresetHelper } from "../helpers/PresetHelper" ;
import { RagfairServerHelper } from "../helpers/RagfairServerHelper" ;
import {
2023-07-02 16:05:32 +02:00
ILooseLoot , Spawnpoint , SpawnpointTemplate , SpawnpointsForced
2023-03-03 16:23:46 +01:00
} from "../models/eft/common/ILooseLoot" ;
import { Item } from "../models/eft/common/tables/IItem" ;
import {
IStaticAmmoDetails , IStaticContainerProps , IStaticForcedProps , IStaticLootDetails
} from "../models/eft/common/tables/ILootBase" ;
import { BaseClasses } from "../models/enums/BaseClasses" ;
import { ConfigTypes } from "../models/enums/ConfigTypes" ;
import { Money } from "../models/enums/Money" ;
import { ILocationConfig } from "../models/spt/config/ILocationConfig" ;
import { ILogger } from "../models/spt/utils/ILogger" ;
import { ConfigServer } from "../servers/ConfigServer" ;
import { LocalisationService } from "../services/LocalisationService" ;
import { SeasonalEventService } from "../services/SeasonalEventService" ;
import { JsonUtil } from "../utils/JsonUtil" ;
import { MathUtil } from "../utils/MathUtil" ;
import { ObjectId } from "../utils/ObjectId" ;
import { ProbabilityObject , ProbabilityObjectArray , RandomUtil } from "../utils/RandomUtil" ;
export interface IContainerItem
{
items : Item [ ]
width : number
height : number
}
@injectable ( )
export class LocationGenerator
{
protected locationConfig : ILocationConfig ;
constructor (
@inject ( "WinstonLogger" ) protected logger : ILogger ,
@inject ( "JsonUtil" ) protected jsonUtil : JsonUtil ,
@inject ( "ObjectId" ) protected objectId : ObjectId ,
@inject ( "RandomUtil" ) protected randomUtil : RandomUtil ,
@inject ( "RagfairServerHelper" ) protected ragfairServerHelper : RagfairServerHelper ,
@inject ( "ItemHelper" ) protected itemHelper : ItemHelper ,
@inject ( "MathUtil" ) protected mathUtil : MathUtil ,
@inject ( "SeasonalEventService" ) protected seasonalEventService : SeasonalEventService ,
@inject ( "ContainerHelper" ) protected containerHelper : ContainerHelper ,
@inject ( "PresetHelper" ) protected presetHelper : PresetHelper ,
@inject ( "LocalisationService" ) protected localisationService : LocalisationService ,
@inject ( "ConfigServer" ) protected configServer : ConfigServer
)
{
this . locationConfig = this . configServer . getConfig ( ConfigTypes . LOCATION ) ;
}
2023-03-03 18:53:28 +01:00
/ * *
2023-07-02 16:05:32 +02:00
* Choose loot to put into a static container based on weighting
* Handle forced items + seasonal item removal when not in season
* @param staticContainer The container itself we will add loot to
* @param staticForced Loot we need to force into the container
* @param staticLootDist staticLoot . json
* @param staticAmmoDist staticAmmo . json
2023-03-03 18:53:28 +01:00
* @param locationName Name of the map to generate static loot for
* @returns IStaticContainerProps
* /
2023-03-03 16:23:46 +01:00
public generateContainerLoot (
2023-07-02 16:05:32 +02:00
staticContainer : IStaticContainerProps ,
2023-03-03 16:23:46 +01:00
staticForced : IStaticForcedProps [ ] ,
staticLootDist : Record < string , IStaticLootDetails > ,
staticAmmoDist : Record < string , IStaticAmmoDetails [ ] > ,
locationName : string ) : IStaticContainerProps
{
2023-07-02 16:05:32 +02:00
const container = this . jsonUtil . clone ( staticContainer ) ;
const containerTpl = container . Items [ 0 ] . _tpl ;
// Create new unique parent id to prevent any collisions
2023-03-03 16:23:46 +01:00
const parentId = this . objectId . generate ( ) ;
container . Root = parentId ;
container . Items [ 0 ] . _id = parentId ;
2023-07-02 16:05:32 +02:00
let containerMap = this . getContainerMapping ( containerTpl ) ;
2023-03-03 16:23:46 +01:00
2023-07-02 16:05:32 +02:00
// Choose count of items to add to container
const itemCountToAdd = this . getWeightedCountOfContainerItems ( containerTpl , staticLootDist , locationName ) ;
2023-03-03 16:23:46 +01:00
2023-07-02 16:05:32 +02:00
// Get all possible loot items for container
const containerLootPool = this . getPossibleLootItemsForContainer ( containerTpl , staticLootDist ) ;
2023-03-03 16:23:46 +01:00
2023-07-02 16:05:32 +02:00
// Some containers need to have items forced into it (quest keys etc)
2023-03-03 16:23:46 +01:00
const tplsForced = staticForced . filter ( x = > x . containerId === container . Id ) . map ( x = > x . itemTpl ) ;
// Draw random loot
2023-07-02 16:05:32 +02:00
// Money spawn more than once in container
2023-03-03 18:53:28 +01:00
let failedToFitCount = 0 ;
2023-03-03 16:23:46 +01:00
const locklist = [ Money . ROUBLES , Money . DOLLARS , Money . EUROS ] ;
2023-07-02 16:05:32 +02:00
// Choose items to add to container, factor in weighting + lock money down
const chosenTpls = containerLootPool . draw ( itemCountToAdd , false , locklist ) ;
// Add forced loot to chosen item pool
const tplsToAddToContainer = tplsForced . concat ( chosenTpls ) ;
for ( const tplToAdd of tplsToAddToContainer )
2023-03-03 16:23:46 +01:00
{
2023-07-02 16:05:32 +02:00
const chosenItemWithChildren = this . createStaticLootItem ( tplToAdd , staticAmmoDist , parentId ) ;
2023-03-06 10:57:04 +01:00
const items = chosenItemWithChildren . items ;
const width = chosenItemWithChildren . width ;
const height = chosenItemWithChildren . height ;
2023-03-03 16:23:46 +01:00
2023-03-03 18:53:28 +01:00
// look for open slot to put chosen item into
2023-07-02 16:05:32 +02:00
const result = this . containerHelper . findSlotForItem ( containerMap , width , height ) ;
2023-03-03 16:23:46 +01:00
if ( ! result . success )
{
2023-03-03 18:53:28 +01:00
// 2 attempts to fit an item, container is probably full, stop trying to add more
if ( failedToFitCount >= this . locationConfig . fitLootIntoContainerAttempts )
{
break ;
}
// Can't fit item, skip
failedToFitCount ++ ;
2023-07-02 16:05:32 +02:00
2023-03-03 18:53:28 +01:00
continue ;
2023-03-03 16:23:46 +01:00
}
2023-07-02 16:05:32 +02:00
containerMap = this . containerHelper . fillContainerMapWithItem ( containerMap , result . x , result . y , width , height , result . rotation ) ;
const rotation = result . rotation ? 1 : 0 ;
2023-03-03 16:23:46 +01:00
items [ 0 ] . slotId = "main" ;
2023-07-02 16:05:32 +02:00
items [ 0 ] . location = { "x" : result . x , "y" : result . y , "r" : rotation } ;
2023-03-03 16:23:46 +01:00
2023-07-02 16:05:32 +02:00
// Add loot to container before returning
2023-03-03 16:23:46 +01:00
for ( const item of items )
{
container . Items . push ( item ) ;
}
}
2023-03-03 18:53:28 +01:00
2023-03-03 16:23:46 +01:00
return container ;
}
2023-07-02 16:05:32 +02:00
/ * *
* Get a 2 d grid of a containers item slots
* @param containerTpl Tpl id of the container
* @returns number [ ] [ ]
* /
protected getContainerMapping ( containerTpl : string ) : number [ ] [ ]
{
// Get template from db
const containerTemplate = this . itemHelper . getItem ( containerTpl ) [ 1 ] ;
// Get height/width
const height = containerTemplate . _props . Grids [ 0 ] . _props . cellsV ;
const width = containerTemplate . _props . Grids [ 0 ] . _props . cellsH ;
// Calcualte 2d array and return
return Array ( height ) . fill ( 0 ) . map ( ( ) = > Array ( width ) . fill ( 0 ) ) ;
}
/ * *
* Look up a containers itemcountDistribution data and choose an item count based on the found weights
* @param containerTypeId Container to get item count for
* @param staticLootDist staticLoot . json
* @param locationName Map name ( to get per - map multiplier for from config )
* @returns item count
* /
protected getWeightedCountOfContainerItems ( containerTypeId : string , staticLootDist : Record < string , IStaticLootDetails > , locationName : string ) : number
{
// Create probability array to calcualte the total count of lootable items inside container
const itemCountArray = new ProbabilityObjectArray < number > ( this . mathUtil ) ;
for ( const itemCountDistribution of staticLootDist [ containerTypeId ] . itemcountDistribution )
{
// Add each count of items into array
itemCountArray . push (
new ProbabilityObject ( itemCountDistribution . count , itemCountDistribution . relativeProbability )
) ;
}
return Math . round ( this . getStaticLootMultiplerForLocation ( locationName ) * itemCountArray . draw ( ) [ 0 ] ) ;
}
/ * *
* Get all possible loot items that can be placed into a container
* Do not add seasonal items if found + current date is inside seasonal event
* @param containerTypeId Contianer to get possible loot for
* @param staticLootDist staticLoot . json
* @returns ProbabilityObjectArray of item tpls + probabilty
* /
protected getPossibleLootItemsForContainer ( containerTypeId : string , staticLootDist : Record < string , IStaticLootDetails > ) : ProbabilityObjectArray < string , number >
{
const seasonalEventActive = this . seasonalEventService . seasonalEventEnabled ( ) ;
const seasonalItemTplBlacklist = this . seasonalEventService . getSeasonalEventItemsToBlock ( ) ;
const itemDistribution = new ProbabilityObjectArray < string > ( this . mathUtil ) ;
for ( const icd of staticLootDist [ containerTypeId ] . itemDistribution )
{
if ( ! seasonalEventActive && seasonalItemTplBlacklist . includes ( icd . tpl ) )
{
// Skip seasonal event items if they're not enabled
continue ;
}
itemDistribution . push (
new ProbabilityObject ( icd . tpl , icd . relativeProbability )
) ;
}
return itemDistribution ;
}
2023-03-03 16:23:46 +01:00
protected getLooseLootMultiplerForLocation ( location : string ) : number
{
return this . locationConfig . looseLootMultiplier [ location ] ;
}
protected getStaticLootMultiplerForLocation ( location : string ) : number
{
return this . locationConfig . staticLootMultiplier [ location ] ;
}
/ * *
* Create array of loose + forced loot using probability system
* @param dynamicLootDist
* @param staticAmmoDist
* @param locationName Location to generate loot for
* @returns Array of spawn points with loot in them
* /
public generateDynamicLoot ( dynamicLootDist : ILooseLoot , staticAmmoDist : Record < string , IStaticAmmoDetails [ ] > , locationName : string ) : SpawnpointTemplate [ ]
{
const loot : SpawnpointTemplate [ ] = [ ] ;
this . addForcedLoot ( loot , this . jsonUtil . clone ( dynamicLootDist . spawnpointsForced ) , locationName ) ;
const dynamicSpawnPoints = this . jsonUtil . clone ( dynamicLootDist . spawnpoints ) ;
//draw from random distribution
const numSpawnpoints = Math . round (
this . getLooseLootMultiplerForLocation ( locationName ) *
this . randomUtil . randn (
dynamicLootDist . spawnpointCount . mean ,
dynamicLootDist . spawnpointCount . std
)
) ;
const spawnpointArray = new ProbabilityObjectArray < string , Spawnpoint > ( this . mathUtil ) ;
for ( const si of dynamicSpawnPoints )
{
spawnpointArray . push (
new ProbabilityObject ( si . template . Id , si . probability , si )
) ;
}
// Select a number of spawn points to add loot to
let spawnPoints : Spawnpoint [ ] = [ ] ;
for ( const si of spawnpointArray . draw ( numSpawnpoints , false ) )
{
spawnPoints . push ( spawnpointArray . data ( si ) ) ;
}
// Filter out duplicate locationIds
spawnPoints = [ . . . new Map ( spawnPoints . map ( x = > [ x . locationId , x ] ) ) . values ( ) ] ;
const numberTooManyRequested = numSpawnpoints - spawnPoints . length ;
if ( numberTooManyRequested > 0 )
{
this . logger . info ( this . localisationService . getText ( "location-spawn_point_count_requested_vs_found" , { requested : numSpawnpoints , found : spawnPoints.length , mapName : locationName } ) ) ;
}
// iterate over spawnpoints
const seasonalEventActive = this . seasonalEventService . seasonalEventEnabled ( ) ;
const seasonalItemTplBlacklist = this . seasonalEventService . getSeasonalEventItemsToBlock ( ) ;
for ( const spawnPoint of spawnPoints )
{
const itemArray = new ProbabilityObjectArray < string > ( this . mathUtil ) ;
for ( const itemDist of spawnPoint . itemDistribution )
{
if ( ! seasonalEventActive && seasonalItemTplBlacklist . includes ( spawnPoint . template . Items . find ( x = > x . _id === itemDist . composedKey . key ) . _tpl ) )
{
// Skip seasonal event items if they're not enabled
continue ;
}
itemArray . push (
new ProbabilityObject ( itemDist . composedKey . key , itemDist . relativeProbability )
) ;
}
// Draw a random item from spawn points possible items
const chosenComposedKey = itemArray . draw ( 1 ) [ 0 ] ;
const createItemResult = this . createDynamicLootItem ( chosenComposedKey , spawnPoint ) ;
// Root id can change when generating a weapon
spawnPoint . template . Root = createItemResult . items [ 0 ] . _id ;
spawnPoint . template . Items = createItemResult . items ;
loot . push ( spawnPoint . template ) ;
}
return loot ;
}
/ * *
* Add forced spawn point loot into loot parameter array
* @param loot array to add forced loot to
* @param forcedSpawnPoints forced loot to add
* @param name of map currently generating forced loot for
* /
protected addForcedLoot ( loot : SpawnpointTemplate [ ] , forcedSpawnPoints : SpawnpointsForced [ ] , locationName : string ) : void
{
const lootToForceSingleAmountOnMap = this . locationConfig . forcedLootSingleSpawnById [ locationName ] ;
if ( lootToForceSingleAmountOnMap )
{
// Process loot items defined as requiring only 1 spawn position as they appear in multiple positions on the map
for ( const itemTpl of lootToForceSingleAmountOnMap )
{
// Get all spawn positions for item tpl in forced loot array
const items = forcedSpawnPoints . filter ( x = > x . template . Items [ 0 ] . _tpl === itemTpl ) ;
if ( ! items || items . length === 0 )
{
this . logger . debug ( ` Unable to adjust loot item ${ itemTpl } as it does not exist inside ${ locationName } forced loot. ` ) ;
continue ;
}
// Create probability array of all spawn positions for this spawn id
const spawnpointArray = new ProbabilityObjectArray < string , SpawnpointsForced > ( this . mathUtil ) ;
for ( const si of items )
{
// use locationId as template.Id is the same across all items
spawnpointArray . push (
new ProbabilityObject ( si . locationId , si . probability , si )
) ;
}
// Choose 1 out of all found spawn positions for spawn id and add to loot array
for ( const spawnPointLocationId of spawnpointArray . draw ( 1 , false ) )
{
const itemToAdd = items . find ( x = > x . locationId === spawnPointLocationId ) ;
const lootItem = itemToAdd . template ;
lootItem . Root = this . objectId . generate ( ) ;
lootItem . Items [ 0 ] . _id = lootItem . Root ;
loot . push ( lootItem ) ;
}
}
}
const seasonalEventActive = this . seasonalEventService . seasonalEventEnabled ( ) ;
const seasonalItemTplBlacklist = this . seasonalEventService . getSeasonalEventItemsToBlock ( ) ;
// Add remaining forced loot to array
for ( const forcedLootItem of forcedSpawnPoints )
{
// Skip spawn positions processed above
if ( lootToForceSingleAmountOnMap ? . includes ( forcedLootItem . template . Items [ 0 ] . _tpl ) )
{
continue ;
}
// Skip seasonal items when seasonal event is active
if ( ! seasonalEventActive && seasonalItemTplBlacklist . includes ( forcedLootItem . template . Items [ 0 ] . _tpl ) )
{
continue ;
}
const li = forcedLootItem . template ;
li . Root = this . objectId . generate ( ) ;
li . Items [ 0 ] . _id = li . Root ;
loot . push ( li ) ;
}
}
/ * *
* Create array of item ( with child items ) and return
* @param chosenComposedKey Key we want to look up items for
* @param spawnPoint Dynamic spawn point item we want will be placed in
* @returns IContainerItem
* /
protected createDynamicLootItem ( chosenComposedKey : string , spawnPoint : Spawnpoint ) : IContainerItem
{
const chosenItem = spawnPoint . template . Items . find ( x = > x . _id === chosenComposedKey ) ;
const chosenTpl = chosenItem . _tpl ;
// Item array to return
const itemWithMods : Item [ ] = [ ] ;
// Money/Ammo - don't rely on items in spawnPoint.template.Items so we can randomise it ourselves
if ( this . itemHelper . isOfBaseclass ( chosenTpl , BaseClasses . MONEY ) || this . itemHelper . isOfBaseclass ( chosenTpl , BaseClasses . AMMO ) )
{
const itemTemplate = this . itemHelper . getItem ( chosenTpl ) [ 1 ] ;
const stackCount = this . randomUtil . getInt ( itemTemplate . _props . StackMinRandom , itemTemplate . _props . StackMaxRandom ) ;
itemWithMods . push (
{
_id : this.objectId.generate ( ) ,
_tpl : chosenTpl ,
upd : { "StackObjectsCount" : stackCount }
}
) ;
}
else
{
// Get item + children and add into array we return
const itemWithChildren = this . itemHelper . findAndReturnChildrenAsItems ( spawnPoint . template . Items , chosenItem . _id ) ;
// We need to reparent to ensure ids are unique
this . reparentItemAndChildren ( itemWithChildren ) ;
itemWithMods . push ( . . . itemWithChildren ) ;
}
// Get inventory size of item
const size = this . itemHelper . getItemSize ( itemWithMods , itemWithMods [ 0 ] . _id ) ;
return {
items : itemWithMods ,
width : size.width ,
height : size.height
} ;
}
/ * *
* Replace the _id value for base item + all children items parentid value
* @param itemWithChildren Item with mods to update
* @param newId new id to add on chidren of base item
* /
protected reparentItemAndChildren ( itemWithChildren : Item [ ] , newId = this . objectId . generate ( ) ) : void
{
// original id on base item
const oldId = itemWithChildren [ 0 ] . _id ;
// Update base item to use new id
itemWithChildren [ 0 ] . _id = newId ;
// Update all parentIds of items attached to base item to use new id
for ( const item of itemWithChildren )
{
if ( item . parentId === oldId )
{
item . parentId = newId ;
}
}
}
/ * *
* Find an item in array by its _tpl , handle differently if chosenTpl is a weapon
* @param items Items array to search
* @param chosenTpl Tpl we want to get item with
* @returns Item object
* /
protected getItemInArray ( items : Item [ ] , chosenTpl : string ) : Item
{
if ( this . itemHelper . isOfBaseclass ( chosenTpl , BaseClasses . WEAPON ) )
{
return items . find ( v = > v . _tpl === chosenTpl && v . parentId === undefined ) ;
}
return items . find ( x = > x . _tpl === chosenTpl ) ;
}
// TODO: rewrite, BIG yikes
protected createStaticLootItem ( tpl : string , staticAmmoDist : Record < string , IStaticAmmoDetails [ ] > , parentId : string = undefined ) : IContainerItem
{
const itemTemplate = this . itemHelper . getItem ( tpl ) [ 1 ] ;
let width = itemTemplate . _props . Width ;
let height = itemTemplate . _props . Height ;
let items : Item [ ] = [
{
_id : this.objectId.generate ( ) ,
_tpl : tpl
}
] ;
// Use passed in parentId as override for new item
if ( parentId )
{
items [ 0 ] . parentId = parentId ;
}
if ( this . itemHelper . isOfBaseclass ( tpl , BaseClasses . MONEY ) || this . itemHelper . isOfBaseclass ( tpl , BaseClasses . AMMO ) )
{
const stackCount = this . randomUtil . getInt ( itemTemplate . _props . StackMinRandom , itemTemplate . _props . StackMaxRandom ) ;
items [ 0 ] . upd = { "StackObjectsCount" : stackCount } ;
}
// No spawn point, use default template
else if ( this . itemHelper . isOfBaseclass ( tpl , BaseClasses . WEAPON ) )
{
let children : Item [ ] = [ ] ;
const defaultPreset = this . jsonUtil . clone ( this . presetHelper . getDefaultPreset ( tpl ) ) ;
if ( defaultPreset )
{
try
{
children = this . ragfairServerHelper . reparentPresets ( defaultPreset . _items [ 0 ] , defaultPreset . _items ) ;
}
catch ( error )
{
// this item already broke it once without being reproducible tpl = "5839a40f24597726f856b511"; AKS-74UB Default
// 5ea03f7400685063ec28bfa8 // ppsh default
// 5ba26383d4351e00334c93d9 //mp7_devgru
this . logger . warning ( this . localisationService . getText ( "location-preset_not_found" , { tpl : tpl , defaultId : defaultPreset._id , defaultName : defaultPreset._name , parentId : parentId } ) ) ;
throw error ;
}
}
else
{
// RSP30 (62178be9d0050232da3485d9/624c0b3340357b5f566e8766) doesnt have any default presets and kills this code below as it has no chidren to reparent
this . logger . debug ( ` createItem() No preset found for weapon: ${ tpl } ` ) ;
}
const rootItem = items [ 0 ] ;
if ( ! rootItem )
{
this . logger . error ( this . localisationService . getText ( "location-missing_root_item" , { tpl : tpl , parentId : parentId } ) ) ;
throw new Error ( this . localisationService . getText ( "location-critical_error_see_log" ) ) ;
}
try
{
if ( children ? . length > 0 )
{
items = this . ragfairServerHelper . reparentPresets ( rootItem , children ) ;
}
}
catch ( error )
{
this . logger . error ( this . localisationService . getText ( "location-unable_to_reparent_item" , { tpl : tpl , parentId : parentId } ) ) ;
throw error ;
}
// Here we should use generalized BotGenerators functions e.g. fillExistingMagazines in the future since
// it can handle revolver ammo (it's not restructured to be used here yet.)
// General: Make a WeaponController for Ragfair preset stuff and the generating weapons and ammo stuff from
// BotGenerator
2023-03-03 18:53:28 +01:00
const magazine = items . filter ( x = > x . slotId === "mod_magazine" ) [ 0 ] ;
2023-03-03 16:23:46 +01:00
// some weapon presets come without magazine; only fill the mag if it exists
2023-03-03 18:53:28 +01:00
if ( magazine )
2023-03-03 16:23:46 +01:00
{
2023-03-03 18:53:28 +01:00
const magTemplate = this . itemHelper . getItem ( magazine . _tpl ) [ 1 ] ;
2023-03-06 10:57:04 +01:00
const weaponTemplate = this . itemHelper . getItem ( tpl ) [ 1 ] ;
2023-03-03 18:53:28 +01:00
// Create array with just magazine
const magazineWithCartridges = [ magazine ] ;
2023-03-06 11:08:36 +01:00
this . itemHelper . fillMagazineWithRandomCartridge ( magazineWithCartridges , magTemplate , staticAmmoDist , weaponTemplate . _props . ammoCaliber ) ;
2023-03-03 18:53:28 +01:00
// Replace existing magazine with above array
items . splice ( items . indexOf ( magazine ) , 1 , . . . magazineWithCartridges ) ;
2023-03-03 16:23:46 +01:00
}
const size = this . itemHelper . getItemSize ( items , rootItem . _id ) ;
width = size . width ;
height = size . height ;
}
// No spawnpoint to fall back on, generate manually
else if ( this . itemHelper . isOfBaseclass ( tpl , BaseClasses . AMMO_BOX ) )
{
this . itemHelper . addCartridgesToAmmoBox ( items , itemTemplate ) ;
}
else if ( this . itemHelper . isOfBaseclass ( tpl , BaseClasses . MAGAZINE ) )
{
2023-03-03 18:53:28 +01:00
// Create array with just magazine
const magazineWithCartridges = [ items [ 0 ] ] ;
this . itemHelper . fillMagazineWithRandomCartridge ( magazineWithCartridges , itemTemplate , staticAmmoDist ) ;
// Replace existing magazine with above array
items . splice ( items . indexOf ( items [ 0 ] ) , 1 , . . . magazineWithCartridges ) ;
2023-03-03 16:23:46 +01:00
}
return {
items : items ,
width : width ,
height : height
} ;
}
}