Refactor Insurance Processing for Gear Lost in Raids

Notable coding Changes:
- Added `getRootItemParentID` method in `InsuranceService` to standardize the determination of the root insurance container.
- Added `IInsuranceEquipmentPkg` model for structuring insurance packages, a type used to store insurance item data before it's saved in the profile.
- Added `HashUtil` in `InsuranceController` and `InsuranceService` for generating an ID for the root insurance container in the case that the root ID cannot be found.
- Updated and normalized item map generation and usage across `InsuranceService` and `InsuranceController`.
- Updated `ItemHelper` with new methods `adoptOrphanedItems` and `generateItemsMap`, facilitating better management of item relationships and efficient item look-ups.
- Updated `InsuranceController.findItemsToDelete` and related methods to use the new `rootItemParentID` parameter to ensure that all root level items share the same parent ID.
- Updated logic in `InsuranceService` for creating insurance packages and handling orphaned items.

Uh-huh, but what would you say you do here?
- Resolves an issue that arose when `lostondeath.json` equipment configuration options were set to `false`. On death, the equipment's children items would be sent back to the player through insurance, duplicating them.
- Resolves an issue that prevented items from appearing in an insurance return even though they passed an insurance roll.
- Improved debug logging.

Remaining Oopses:
- We do not have data on items that were dropped in a raid. This means we have to pull item data from the profile at the start of the raid to return to the player in insurance. Because of this, the item positioning may differ from the position the item was in when the player died. Apart from removing all positioning, this is the best we can do.

Resolves #425
This commit is contained in:
Refringe 2024-02-08 15:56:45 -05:00
parent 2d27aaf545
commit 115f217c02
No known key found for this signature in database
GPG Key ID: 64E03E5F892C6F9E
5 changed files with 188 additions and 124 deletions

View File

@ -165,7 +165,7 @@ export class InraidController
if (gearToStore.length > 0)
{
mapHasInsuranceEnabled
? this.insuranceService.storeGearLostInRaidToSendLater(gearToStore)
? this.insuranceService.storeGearLostInRaidToSendLater(sessionID, gearToStore)
: this.insuranceService.sendLostInsuranceMessage(sessionID, locationName);
}

View File

@ -23,6 +23,7 @@ import { SaveServer } from "@spt-aki/servers/SaveServer";
import { InsuranceService } from "@spt-aki/services/InsuranceService";
import { MailSendService } from "@spt-aki/services/MailSendService";
import { PaymentService } from "@spt-aki/services/PaymentService";
import { HashUtil } from "@spt-aki/utils/HashUtil";
import { MathUtil } from "@spt-aki/utils/MathUtil";
import { RandomUtil } from "@spt-aki/utils/RandomUtil";
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
@ -37,6 +38,7 @@ export class InsuranceController
@inject("WinstonLogger") protected logger: ILogger,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("MathUtil") protected mathUtil: MathUtil,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("SaveServer") protected saveServer: SaveServer,
@ -120,17 +122,20 @@ export class InsuranceController
} items, in profile ${sessionID}`,
);
// Fetch the root Item parentId property value that should be used for insurance packages.
const rootItemParentID = this.insuranceService.getRootItemParentID(sessionID);
// Iterate over each of the insurance packages.
for (const insured of insuranceDetails)
{
// Find items that should be deleted from the insured items.
const itemsToDelete = this.findItemsToDelete(insured);
const itemsToDelete = this.findItemsToDelete(rootItemParentID, insured);
// Actually remove them.
this.removeItemsFromInsurance(insured, itemsToDelete);
// Fix any orphaned items.
this.adoptOrphanedItems(insured);
// Ensure that all items have a valid parent.
insured.items = this.itemHelper.adoptOrphanedItems(rootItemParentID, insured.items);
// Send the mail to the player.
this.sendMail(sessionID, insured);
@ -173,17 +178,18 @@ export class InsuranceController
/**
* Finds the items that should be deleted based on the given Insurance object.
*
* @param insured The insurance object containing the items to evaluate for deletion.
* @param rootItemParentID - The ID that should be assigned to all "hideout"/root items.
* @param insured - The insurance object containing the items to evaluate for deletion.
* @returns A Set containing the IDs of items that should be deleted.
*/
protected findItemsToDelete(insured: Insurance): Set<string>
protected findItemsToDelete(rootItemParentID: string, insured: Insurance): Set<string>
{
const toDelete = new Set<string>();
// Populate a Map object of items for quick lookup by their ID and use it to populate a Map of main-parent items
// and each of their attachments. For example, a gun mapped to each of its attachments.
const itemsMap = this.populateItemsMap(insured);
let parentAttachmentsMap = this.populateParentAttachmentsMap(insured, itemsMap);
const itemsMap = this.itemHelper.generateItemsMap(insured.items);
let parentAttachmentsMap = this.populateParentAttachmentsMap(rootItemParentID, insured, itemsMap);
// Check to see if any regular items are present.
const hasRegularItems = Array.from(itemsMap.values()).some((item) =>
@ -215,32 +221,21 @@ export class InsuranceController
return toDelete;
}
/**
* Populate a Map object of items for quick lookup by their ID.
*
* @param insured The insurance object containing the items to populate the map with.
* @returns A Map where the keys are the item IDs and the values are the corresponding Item objects.
*/
protected populateItemsMap(insured: Insurance): Map<string, Item>
{
const itemsMap = new Map<string, Item>();
for (const item of insured.items)
{
itemsMap.set(item._id, item);
}
return itemsMap;
}
/**
* Initialize a Map object that holds main-parents to all of their attachments. Note that "main-parent" in this
* context refers to the parent item that an attachment is attached to. For example, a suppressor attached to a gun,
* not the backpack that the gun is located in (the gun's parent).
*
* @param rootItemParentID - The ID that should be assigned to all "hideout"/root items.
* @param insured - The insurance object containing the items to evaluate.
* @param itemsMap - A Map object for quick item look-up by item ID.
* @returns A Map object containing parent item IDs to arrays of their attachment items.
*/
protected populateParentAttachmentsMap(insured: Insurance, itemsMap: Map<string, Item>): Map<string, Item[]>
protected populateParentAttachmentsMap(
rootItemParentID: string,
insured: Insurance,
itemsMap: Map<string, Item>,
): Map<string, Item[]>
{
const mainParentToAttachmentsMap = new Map<string, Item[]>();
for (const insuredItem of insured.items)
@ -249,7 +244,7 @@ export class InsuranceController
const parentItem = insured.items.find((item) => item._id === insuredItem.parentId);
// The parent (not the hideout) could not be found. Skip and warn.
if (!parentItem && insuredItem.parentId !== this.fetchHideoutItemParent(insured.items))
if (!parentItem && insuredItem.parentId !== rootItemParentID)
{
this.logger.warning(
`Could not find parent for insured item - ID: ${insuredItem._id}, Template: ${insuredItem._tpl}, Parent ID: ${insuredItem.parentId}`,
@ -405,7 +400,7 @@ export class InsuranceController
// Log the parent item's name.
const parentItem = itemsMap.get(parentId);
const parentName = this.itemHelper.getItemName(parentItem._tpl);
this.logger.debug(`Processing attachments for parent item: ${parentName}`);
this.logger.debug(`Processing attachments of parent "${parentName}":`);
// Process the attachments for this individual parent item.
this.processAttachmentByParent(attachmentItems, traderId, toDelete);
@ -429,7 +424,7 @@ export class InsuranceController
this.logAttachmentsDetails(sortedAttachments);
const successfulRolls = this.countSuccessfulRolls(sortedAttachments, traderId);
this.logger.debug(`Number of deletion rolls: ${successfulRolls}`);
this.logger.debug(`Number of attachments to be deleted: ${successfulRolls}`);
this.attachmentDeletionByValue(sortedAttachments, successfulRolls, toDelete);
}
@ -456,9 +451,11 @@ export class InsuranceController
*/
protected logAttachmentsDetails(attachments: EnrichedItem[]): void
{
let index = 1;
for (const attachment of attachments)
{
this.logger.debug(`Child Item - Name: ${attachment.name}, Max Price: ${attachment.maxPrice}`);
this.logger.debug(`Attachment ${index}: "${attachment.name}" - Price: ${attachment.maxPrice}`);
index++;
}
}
@ -496,7 +493,7 @@ export class InsuranceController
if (valuableChild)
{
const { name, maxPrice } = valuableChild;
this.logger.debug(`Marked for removal - Child Item: ${name}, Max Price: ${maxPrice}`);
this.logger.debug(`Marked attachment "${name}" for removal - Max Price: ${maxPrice}`);
toDelete.add(attachmentsId);
}
}
@ -514,52 +511,6 @@ export class InsuranceController
insured.items = insured.items.filter((item) => !toDelete.has(item._id));
}
/**
* Adopts orphaned items by resetting them as base-level items. Helpful in situations where a parent has been
* deleted from insurance, but any insured items within the parent should remain. This method will remove the
* reference from the children to the parent and set item properties to main-level values.
*
* @param insured Insurance object containing items.
*/
protected adoptOrphanedItems(insured: Insurance): void
{
const hideoutParentId = this.fetchHideoutItemParent(insured.items);
for (const item of insured.items)
{
// Check if the item's parent exists in the insured items list.
const parentExists = insured.items.some((parentItem) => parentItem._id === item.parentId);
// If the parent does not exist and the item is not already a 'hideout' item, adopt the orphaned item.
if (!parentExists && item.parentId !== hideoutParentId && item.slotId !== "hideout")
{
item.parentId = hideoutParentId;
item.slotId = "hideout";
delete item.location;
}
}
}
/**
* Fetches the parentId property of an item with a slotId "hideout". Not sure if this is actually dynamic, but this
* method should be a reliable way to fetch it, if it ever does change.
*
* @param items Array of items to search through.
* @returns The parentId of an item with slotId 'hideout'. Empty string if not found.
*/
protected fetchHideoutItemParent(items: Item[]): string
{
const hideoutItem = items.find((item) => item.slotId === "hideout");
const hideoutParentId = hideoutItem ? hideoutItem?.parentId : "";
if (hideoutParentId === "")
{
this.logger.warning("Unable to find an item with slotId 'hideout' in the insured item package.");
}
return hideoutParentId;
}
/**
* Handle sending the insurance message to the user that potentially contains the valid insurance items.
*
@ -614,10 +565,10 @@ export class InsuranceController
const roll = returnChance >= traderReturnChance;
// Log the roll with as much detail as possible.
const itemName = insuredItem ? ` for "${this.itemHelper.getItemName(insuredItem._tpl)}"` : "";
const itemName = insuredItem ? ` "${this.itemHelper.getItemName(insuredItem._tpl)}"` : "";
const status = roll ? "Delete" : "Keep";
this.logger.debug(
`Rolling deletion${itemName} with ${trader} - Return ${traderReturnChance}% - Roll: ${returnChance} - Status: ${status}`,
`Rolling${itemName} with ${trader} - Return ${traderReturnChance}% - Roll: ${returnChance} - Status: ${status}`,
);
return roll;

View File

@ -7,6 +7,7 @@ import { Item, Location, Repairable } from "@spt-aki/models/eft/common/tables/II
import { IStaticAmmoDetails } from "@spt-aki/models/eft/common/tables/ILootBase";
import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem";
import { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
import { EquipmentSlots } from "@spt-aki/models/enums/EquipmentSlots";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
import { ItemBaseClassService } from "@spt-aki/services/ItemBaseClassService";
@ -891,12 +892,6 @@ export class ItemHelper
* to traverse, where the keys are the item IDs and the values are the corresponding Item objects. This alleviates
* some of the performance concerns, as it allows for quick lookups of items by ID.
*
* To generate the map:
* ```
* const itemsMap = new Map<string, Item>();
* items.forEach(item => itemsMap.set(item._id, item));
* ```
*
* @param itemId - The unique identifier of the item for which to find the main parent.
* @param itemsMap - A Map containing item IDs mapped to their corresponding Item objects for quick lookup.
* @returns The Item object representing the top-most parent of the given item, or `null` if no such parent exists.
@ -923,7 +918,42 @@ export class ItemHelper
*/
public isAttachmentAttached(item: Item): boolean
{
return item.slotId !== "hideout" && item.slotId !== "main" && Number.isNaN(Number(item.slotId));
const equipmentSlots = Object.values(EquipmentSlots).map((value) => value as string);
return !(["hideout", "main"].includes(item.slotId)
|| equipmentSlots.includes(item.slotId)
|| !Number.isNaN(Number(item.slotId)));
}
/**
* Retrieves the equipment parent item for a given item.
*
* This method traverses up the hierarchy of items starting from a given `itemId`, until it finds the equipment
* parent item. In other words, if you pass it an item id of a suppressor, it will traverse up the muzzle brake,
* barrel, upper receiver, gun, nested backpack, and finally return the backpack Item that is equipped.
*
* It's important to note that traversal is expensive, so this method requires that you pass it a Map of the items
* to traverse, where the keys are the item IDs and the values are the corresponding Item objects. This alleviates
* some of the performance concerns, as it allows for quick lookups of items by ID.
*
* @param itemId - The unique identifier of the item for which to find the equipment parent.
* @param itemsMap - A Map containing item IDs mapped to their corresponding Item objects for quick lookup.
* @returns The Item object representing the equipment parent of the given item, or `null` if no such parent exists.
*/
public getEquipmentParent(itemId: string, itemsMap: Map<string, Item>): Item | null
{
let currentItem = itemsMap.get(itemId);
const equipmentSlots = Object.values(EquipmentSlots).map((value) => value as string);
while (currentItem && !equipmentSlots.includes(currentItem.slotId))
{
currentItem = itemsMap.get(currentItem.parentId);
if (!currentItem)
{
return null;
}
}
return currentItem;
}
/**
@ -1503,6 +1533,51 @@ export class ItemHelper
return newId;
}
/**
* Adopts orphaned items by resetting them as root "hideout" items. Helpful in situations where a parent has been
* deleted from a group of items and there are children still referencing the missing parent. This method will
* remove the reference from the children to the parent and set item properties to root values.
*
* @param rootId The ID of the "root" of the container.
* @param items Array of Items that should be adjusted.
* @returns Array of Items that have been adopted.
*/
public adoptOrphanedItems(rootId: string, items: Item[]): Item[]
{
for (const item of items)
{
// Check if the item's parent exists.
const parentExists = items.some((parentItem) => parentItem._id === item.parentId);
// If the parent does not exist and the item is not already a 'hideout' item, adopt the orphaned item by
// setting the parent ID to the PMCs inventory equipment ID, the slot ID to 'hideout', and remove the location.
if (!parentExists && item.parentId !== rootId && item.slotId !== "hideout")
{
item.parentId = rootId;
item.slotId = "hideout";
delete item.location;
}
}
return items;
}
/**
* Populate a Map object of items for quick lookup using their ID.
*
* @param items An array of Items that should be added to a Map.
* @returns A Map where the keys are the item IDs and the values are the corresponding Item objects.
*/
public generateItemsMap(items: Item[]): Map<string, Item>
{
const itemsMap = new Map<string, Item>();
for (const item of items)
{
itemsMap.set(item._id, item);
}
return itemsMap;
}
}
namespace ItemHelper

View File

@ -0,0 +1,10 @@
import { IPmcData } from "@spt-aki/models/eft/common/IPmcData";
import { Item } from "@spt-aki/models/eft/common/tables/IItem";
export interface IInsuranceEquipmentPkg
{
sessionID: string;
pmcData: IPmcData;
itemToReturnToPlayer: Item;
traderId: string;
}

View File

@ -10,13 +10,13 @@ import { Item } from "@spt-aki/models/eft/common/tables/IItem";
import { ITraderBase } from "@spt-aki/models/eft/common/tables/ITrader";
import { IInsuredItemsData } from "@spt-aki/models/eft/inRaid/IInsuredItemsData";
import { ISaveProgressRequestData } from "@spt-aki/models/eft/inRaid/ISaveProgressRequestData";
import { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
import { BonusType } from "@spt-aki/models/enums/BonusType";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { MessageType } from "@spt-aki/models/enums/MessageType";
import { Traders } from "@spt-aki/models/enums/Traders";
import { IInsuranceConfig } from "@spt-aki/models/spt/config/IInsuranceConfig";
import { ILostOnDeathConfig } from "@spt-aki/models/spt/config/ILostOnDeathConfig";
import { IInsuranceEquipmentPkg } from "@spt-aki/models/spt/services/IInsuranceEquipmentPkg";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
@ -24,6 +24,7 @@ import { SaveServer } from "@spt-aki/servers/SaveServer";
import { LocaleService } from "@spt-aki/services/LocaleService";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { MailSendService } from "@spt-aki/services/MailSendService";
import { HashUtil } from "@spt-aki/utils/HashUtil";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { RandomUtil } from "@spt-aki/utils/RandomUtil";
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
@ -41,6 +42,7 @@ export class InsuranceService
@inject("SecureContainerHelper") protected secureContainerHelper: SecureContainerHelper,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("SaveServer") protected saveServer: SaveServer,
@ -215,7 +217,14 @@ export class InsuranceService
}
/**
* Create an array of insured items lost in a raid player has just exited
* Create insurance equipment packages that should be sent to the user. The packages should contain items that have
* been lost in a raid and should be returned to the player through the insurance system.
*
* NOTE: We do not have data on items that were dropped in a raid. This means we have to pull item data from the
* profile at the start of the raid to return to the player in insurance. Because of this, the item
* positioning may differ from the position the item was in when the player died. Apart from removing all
* positioning, this is the best we can do. >:{}
*
* @param pmcData Player profile
* @param offraidData Post-raid data
* @param preRaidGear Pre-raid data
@ -229,38 +238,45 @@ export class InsuranceService
preRaidGear: Item[],
sessionID: string,
playerDied: boolean,
): any[]
): IInsuranceEquipmentPkg[]
{
const preRaidGearHash = this.createItemHashTable(preRaidGear);
const offRaidGearHash = this.createItemHashTable(offraidData.profile.Inventory.items);
const equipmentPkg: IInsuranceEquipmentPkg[] = [];
const preRaidGearMap = this.itemHelper.generateItemsMap(preRaidGear);
const offRaidGearMap = this.itemHelper.generateItemsMap(offraidData.profile.Inventory.items);
const equipmentToSendToPlayer = [];
for (const insuredItem of pmcData.InsuredItems)
{
// Skip insured items not on player when they started raid
const preRaidItem = preRaidGearHash[insuredItem.itemId];
if (!preRaidItem)
// Skip insured items not on player when they started the raid.
if (!preRaidGearMap.has(insuredItem.itemId))
{
continue;
}
const preRaidItem = preRaidGearMap.get(insuredItem.itemId);
// Skip slots we should never return as they're never lost on death
if (this.insuranceConfig.blacklistedEquipment.includes(preRaidItem.slotId))
{
continue;
}
// Slots can be flagged as never lost on death and shouldn't be sent to player as insurance
const itemShouldBeLostOnDeath = this.lostOnDeathConfig.equipment[preRaidItem.slotId] ?? true;
// Equipment slots can be flagged as never lost on death and shouldn't be saved in an insurance package.
// We need to check if the item is directly equipped to an equipment slot, or if it is a child Item of an
// equipment slot.
const equipmentParentItem = this.itemHelper.getEquipmentParent(preRaidItem._id, preRaidGearMap);
// Was item found on player inventory post-raid
const itemOnPlayerPostRaid = offRaidGearHash[insuredItem.itemId];
// Now that we have the equipment parent item, we can check to see if that item is located in an equipment
// slot that is flagged as lost on death. If it is, then the itemShouldBeLostOnDeath.
const itemShouldBeLostOnDeath = this.lostOnDeathConfig.equipment[equipmentParentItem?.slotId] ?? true;
// Was the item found in the player inventory post-raid?
const itemOnPlayerPostRaid = offRaidGearMap.has(insuredItem.itemId);
// Check if item missing in post-raid gear OR player died + item slot flagged as lost on death
// Catches both events: player died with item on + player survived but dropped item in raid
if (!itemOnPlayerPostRaid || (playerDied && itemShouldBeLostOnDeath))
{
equipmentToSendToPlayer.push({
equipmentPkg.push({
pmcData: pmcData,
itemToReturnToPlayer: this.getInsuredItemDetails(
pmcData,
@ -285,7 +301,7 @@ export class InsuranceService
// Add all items found above to return data
for (const softInsertChildModId of softInsertChildIds)
{
equipmentToSendToPlayer.push({
equipmentPkg.push({
pmcData: pmcData,
itemToReturnToPlayer: this.getInsuredItemDetails(
pmcData,
@ -303,21 +319,40 @@ export class InsuranceService
}
}
return equipmentToSendToPlayer;
return equipmentPkg;
}
/**
* Take the insurance item packages within a profile session and ensure that each of the items in that package are
* not orphaned from their parent ID.
*
* @param sessionID The session ID to update insurance equipment packages in.
* @returns void
*/
protected adoptOrphanedInsEquipment(sessionID: string): void
{
const rootID = this.getRootItemParentID(sessionID);
const insuranceData = this.getInsurance(sessionID);
for (const [traderId, items] of Object.entries(insuranceData))
{
this.insured[sessionID][traderId] = this.itemHelper.adoptOrphanedItems(rootID, items);
}
}
/**
* Store lost gear post-raid inside profile, ready for later code to pick it up and mail it
* @param equipmentToSendToPlayer Gear to store - generated by getGearLostInRaid()
* TODO: add type to equipmentToSendToPlayer
* @param equipmentPkg Gear to store - generated by getGearLostInRaid()
*/
public storeGearLostInRaidToSendLater(equipmentToSendToPlayer: any[]): void
public storeGearLostInRaidToSendLater(sessionID: string, equipmentPkg: IInsuranceEquipmentPkg[]): void
{
// Process all insured items lost in-raid
for (const gear of equipmentToSendToPlayer)
for (const gear of equipmentPkg)
{
this.addGearToSend(gear);
}
// Items are separated into their individual trader packages, now we can ensure that they all have valid parents
this.adoptOrphanedInsEquipment(sessionID);
}
/**
@ -414,22 +449,6 @@ export class InsuranceService
}
}
/**
* Create a hash table for an array of items, keyed by items _id
* @param items Items to hash
* @returns Hashtable
*/
protected createItemHashTable(items: Item[]): Record<string, Item>
{
const hashTable: Record<string, Item> = {};
for (const item of items)
{
hashTable[item._id] = item;
}
return hashTable;
}
/**
* Add gear item to InsuredItems array in player profile
* @param sessionID Session id
@ -437,9 +456,7 @@ export class InsuranceService
* @param itemToReturnToPlayer item to store
* @param traderId Id of trader item was insured with
*/
protected addGearToSend(
gear: { sessionID: string; pmcData: IPmcData; itemToReturnToPlayer: Item; traderId: string; },
): void
protected addGearToSend(gear: IInsuranceEquipmentPkg): void
{
const sessionId = gear.sessionID;
const pmcData = gear.pmcData;
@ -528,4 +545,15 @@ export class InsuranceService
return Math.round(pricePremium);
}
/**
* Returns the ID that should be used for a root-level Item's parentId property value within in the context of insurance.
*
* @returns The ID.
*/
public getRootItemParentID(sessionID: string): string
{
// Try to use the equipment id from the profile. I'm not sure this is strictly required, but it feels neat.
return this.saveServer.getProfile(sessionID)?.characters?.pmc?.Inventory?.equipment ?? this.hashUtil.generate();
}
}