/**
* Copyright (c) 2005-2017, KoLmafia development team
* http://kolmafia.sourceforge.net/
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* [1] Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* [2] Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the
* distribution.
* [3] Neither the name "KoLmafia" nor the names of its contributors may
* be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package net.sourceforge.kolmafia.request;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.sourceforge.kolmafia.AdventureResult;
import net.sourceforge.kolmafia.KoLCharacter;
import net.sourceforge.kolmafia.KoLConstants;
import net.sourceforge.kolmafia.KoLConstants.MafiaState;
import net.sourceforge.kolmafia.KoLmafia;
import net.sourceforge.kolmafia.RequestLogger;
import net.sourceforge.kolmafia.objectpool.ItemPool;
import net.sourceforge.kolmafia.persistence.ItemDatabase;
import net.sourceforge.kolmafia.session.Limitmode;
import net.sourceforge.kolmafia.session.StoreManager;
import net.sourceforge.kolmafia.session.ResultProcessor;
import net.sourceforge.kolmafia.utilities.StringUtilities;
public class MallPurchaseRequest
extends PurchaseRequest
{
private static final Pattern YIELD_PATTERN =
Pattern.compile( "You may only buy ([\\d,]+) of this item per day from this store\\.You have already purchased ([\\d,]+)" );
public static final Set<Integer> disabledStores = new HashSet<Integer>();
public static final Set<Integer> ignoringStores = new HashSet<Integer>();
private final int shopId;
private static Pattern STOREID_PATTERN = Pattern.compile( "whichstore\\d?=(\\d+)" );
public static final int getStoreId( final String urlString )
{
return GenericRequest.getNumericField( urlString, MallPurchaseRequest.STOREID_PATTERN );
}
public int getShopId()
{
return this.shopId;
}
public static void reset()
{
// Stores which are ignoring one character may not be ignoring
// another player
MallPurchaseRequest.ignoringStores.clear();
}
/**
* Constructs a new <code>MallPurchaseRequest</code> with the given
* values. Note that the only value which can be modified at a later
* time is the quantity of items being purchases; all others are
* consistent through the time when the purchase is actually executed.
*
* @param itemName The name of the item to be purchased
* @param itemId The database Id for the item to be purchased
* @param quantity The quantity of items to be purchased
* @param shopId The integer identifier for the shop from which the item will be purchased
* @param shopName The name of the shop
* @param price The price at which the item will be purchased
* @param limit The maximum number of items that can be purchased per day
* @param canPurchase Whether or not this purchase request is possible
*/
public MallPurchaseRequest( final int itemId, final int quantity, final int shopId,
final String shopName, final int price, final int limit,
final boolean canPurchase )
{
this( ItemPool.get( itemId ), quantity, shopId, shopName, price, limit, canPurchase );
}
public MallPurchaseRequest( final int itemId, final int quantity, final int shopId,
final String shopName, final int price, final int limit )
{
this( ItemPool.get( itemId ), quantity, shopId, shopName, price, limit, true );
}
public MallPurchaseRequest( final AdventureResult item, final int quantity, final int shopId,
final String shopName, final int price, final int limit, final boolean canPurchase )
{
super( "mallstore.php" );
this.isMallStore = true;
this.hashField = "pwd";
this.item = item;
this.shopName = shopName;
this.shopId = shopId;
this.quantity = quantity;
this.price = price;
this.limit = Math.min( quantity, limit );
this.canPurchase = canPurchase;
this.addFormField( "whichstore", String.valueOf( shopId ) );
this.addFormField( "buying", "1" );
this.addFormField( "ajax", "1" );
this.addFormField( "whichitem", MallPurchaseRequest.getStoreString( item.getItemId(), price ) );
this.timestamp = System.currentTimeMillis();
}
public static final String getStoreString( final int itemId, final int price )
{
// whichitem=2272000000246
StringBuilder whichItem = new StringBuilder();
whichItem.append( itemId );
int originalLength = whichItem.length();
whichItem.append( price );
while ( whichItem.length() < originalLength + 9 )
{
whichItem.insert( originalLength, '0' );
}
return whichItem.toString();
}
@Override
public int getAvailableMeat()
{
return KoLCharacter.canInteract() ? KoLCharacter.getAvailableMeat() : KoLCharacter.getStorageMeat();
}
@Override
public String color()
{
return !this.canPurchase ?
"gray" :
KoLCharacter.canInteract() ?
( KoLCharacter.getAvailableMeat() >= this.price ? null : "gray" ) :
( KoLCharacter.getStorageMeat() >= this.price ? "blue" : "gray" );
}
@Override
public void run()
{
if ( this.shopId == KoLCharacter.getUserId() )
{
return;
}
if ( MallPurchaseRequest.disabledStores.contains( this.shopId ) )
{
KoLmafia.updateDisplay( "This shop (#" + this.shopId + ") is disabled. Skipping..." );
return;
}
if ( MallPurchaseRequest.ignoringStores.contains( this.shopId ) )
{
KoLmafia.updateDisplay( "This shop (#" + this.shopId + ") is ignoring you. Skipping..." );
return;
}
if ( Limitmode.limitMall() )
{
return;
}
this.addFormField( "quantity", String.valueOf( this.limit ) );
super.run();
}
@Override
public int getCurrentCount()
{
List list = KoLCharacter.canInteract() ? KoLConstants.inventory : KoLConstants.storage;
return this.item.getCount( list );
}
private static int extractItemId( final String urlString )
{
Matcher itemMatcher = TransferItemRequest.ITEMID_PATTERN.matcher( urlString );
if ( !itemMatcher.find() )
{
return -1;
}
// whichitem=2272000000246
// the last 9 characters of idString are the price, with leading zeros
String idString = itemMatcher.group( 1 );
return StringUtilities.parseInt( idString.substring( 0, idString.length() - 9 ) );
}
@Override
public void processResults()
{
MallPurchaseRequest.parseResponse( this.getURLString(), this.responseText );
int quantityAcquired = this.getCurrentCount() - this.initialCount;
if ( quantityAcquired > 0 )
{
return;
}
int startIndex = this.responseText.indexOf( "<center>" );
int stopIndex = this.responseText.indexOf( "</table>" );
if ( startIndex == -1 || stopIndex == -1 )
{
KoLmafia.updateDisplay( "Store unavailable. Skipping..." );
return;
}
String result = this.responseText.substring( startIndex, stopIndex );
// One error is that the item price changed, or the item
// is no longer available because someone was faster at
// purchasing the item. If that's the case, just return
// without doing anything; nothing left to do.
if ( this.responseText.contains( "You can't afford" ) )
{
KoLmafia.updateDisplay( MafiaState.ERROR, "Not enough funds." );
return;
}
// If you are on a player's ignore list, you can't buy from his store
if ( this.responseText.contains( "That player will not sell to you" ) )
{
KoLmafia.updateDisplay( "You are on this shop's ignore list (#" + this.shopId + "). Skipping..." );
RequestLogger.updateSessionLog( "You are on this shop's ignore list (#" + this.shopId + "). Skipping...");
MallPurchaseRequest.ignoringStores.add( shopId );
StoreManager.flushCache( -1, this.shopId );
return;
}
// This store belongs to a player whose account has been disabled for policy violation. Its inventory is frozen.
if ( this.responseText.contains( "Its inventory is frozen" ) )
{
KoLmafia.updateDisplay( "This shop's inventory is frozen (#" + this.shopId + "). Skipping..." );
RequestLogger.updateSessionLog( "This shop's inventory is frozen (#" + this.shopId + "). Skipping...");
MallPurchaseRequest.disabledStores.add( shopId );
StoreManager.flushCache( -1, this.shopId );
return;
}
// Another thing to search for is to see if the person
// swapped the price on the item, or you got a "failed
// to yield" message. In that case, you may wish to
// re-attempt the purchase.
if ( this.responseText.contains( "This store doesn't" ) || this.responseText.contains( "failed to yield" ) )
{
Matcher itemChangedMatcher =
Pattern.compile(
"<td valign=center><b>" + this.item.getName() + "</b> \\(([\\d,]+)\\) </td><td>([\\d,]+) Meat" ).matcher(
result );
if ( itemChangedMatcher.find() )
{
int limit = StringUtilities.parseInt( itemChangedMatcher.group( 1 ) );
int newPrice = StringUtilities.parseInt( itemChangedMatcher.group( 2 ) );
// If the item exists at a lower or equivalent
// price, then you should re-attempt the purchase
// of the item.
if ( this.price >= newPrice )
{
KoLmafia.updateDisplay( "Failed to yield. Attempting repurchase..." );
( new MallPurchaseRequest(
this.item,
Math.min( limit, this.quantity ),
this.shopId, this.shopName,
newPrice, Math.min( limit, this.quantity ),
true ) ).run();
}
else
{
KoLmafia.updateDisplay( "Price switch detected (#" + this.shopId + "). Skipping..." );
}
}
else
{
KoLmafia.updateDisplay( "Failed to yield. Skipping..." );
}
return;
}
// One error that might be encountered is that the user
// already purchased the item; if that's the case, and
// the user hasn't exhausted their limit, then make a
// second request to the server containing the correct
// number of items to buy.
Matcher quantityMatcher = MallPurchaseRequest.YIELD_PATTERN.matcher( result );
if ( quantityMatcher.find() )
{
int limit = StringUtilities.parseInt( quantityMatcher.group( 1 ) );
int alreadyPurchased = StringUtilities.parseInt( quantityMatcher.group( 2 ) );
if ( limit != alreadyPurchased )
{
( new MallPurchaseRequest(
this.item,
limit - alreadyPurchased,
this.shopId, this.shopName,
this.price, limit, true ) ).run();
}
this.canPurchase = false;
return;
}
}
private static Pattern TABLE_PATTERN = Pattern.compile( "<table>.*?</table>", Pattern.DOTALL );
// (You spent 1,900 meat from Hagnk's.<br />You have XXX meat left.)
private static Pattern MEAT_PATTERN = Pattern.compile( "You spent ([\\d,]+) [Mm]eat( from Hagnk's.*?You have ([\\d,]+) [Mm]eat left)?", Pattern.DOTALL );
public static final void parseResponse( final String urlString, final String responseText )
{
if ( !urlString.startsWith( "mallstore.php" ) || !urlString.contains( "whichitem" ) )
{
return;
}
if ( responseText.contains( "That player will not sell to you" ) )
{
// This store is unavailable to you.
int shopId = MallPurchaseRequest.getStoreId( urlString );
if ( shopId == -1 )
{
return;
}
// Ignore it for the rest of the session.
MallPurchaseRequest.ignoringStores.add( shopId );
StoreManager.flushCache( -1, shopId );
return;
}
if ( responseText.contains( "Its inventory is frozen" ) )
{
// This store is unavailable to you.
int shopId = MallPurchaseRequest.getStoreId( urlString );
if ( shopId == -1 )
{
return;
}
// Ignore it for the rest of the session.
MallPurchaseRequest.disabledStores.add( shopId );
StoreManager.flushCache( -1, shopId );
return;
}
// Mall stores themselves can only contain processable results
// when actually buying an item, and then only at the very top
// of the page.
Matcher tableMatcher = MallPurchaseRequest.TABLE_PATTERN.matcher( responseText );
if ( !tableMatcher.find() )
{
return;
}
AdventureResult result = MallPurchaseRequest.processItemFromMall( tableMatcher.group( 0 ) );
Matcher meatMatcher = MallPurchaseRequest.MEAT_PATTERN.matcher( responseText );
if ( !meatMatcher.find() )
{
return;
}
int cost = StringUtilities.parseInt( meatMatcher.group( 1 ) );
if ( meatMatcher.group( 2 ) != null )
{
int balance = StringUtilities.parseInt( meatMatcher.group( 3 ) );
KoLCharacter.setStorageMeat( balance );
}
else
{
ResultProcessor.processMeat( -cost );
KoLCharacter.updateStatus();
}
}
// You acquire an item: <b>tiny plastic Charity the Zombie Hunter</b> (stored in Hagnk's Ancestral Mini-Storage)
// You acquire <b>2 tiny plastic Charities the Zombie Hunters</b> (stored in Hagnk's Ancestral Mini-Storage)
// You acquire <b>11 limes</b><br>(That's ridiculous. It's not even funny.) (stored in Hagnk's Ancestral Mini-Storage)
// You acquire <b>23 limes</b><font color=white>FNORD</font> (stored in Hagnk's Ancestral Mini-Storage)
// You acquire <b>37 limes</b><br>(In a row?!) (stored in Hagnk's Ancestral Mini-Storage)
public static Pattern ITEM_PATTERN = Pattern.compile( "<table class=\"item\".*?rel=\".*?\".*?( \\(stored in Hagnk's Ancestral Mini-Storage\\))?</td></tr></table>", Pattern.DOTALL );
public static final AdventureResult processItemFromMall( final String text )
{
// Items are now wrapped in KoL's standard "relstring" table"
Matcher itemMatcher = MallPurchaseRequest.ITEM_PATTERN.matcher( text );
if ( !itemMatcher.find() )
{
return null;
}
String result = itemMatcher.group( 0 );
boolean storage = itemMatcher.group( 1 ) != null;
if ( storage )
{
result = result.replaceFirst( "\\(stored in Hagnk's Ancestral Mini-Storage\\)", "" );
}
ArrayList<AdventureResult> results = new ArrayList<AdventureResult>();
ResultProcessor.processResults( false, result, results );
if ( results.isEmpty() )
{
// Shouldn't happen
return null;
}
AdventureResult item = results.get( 0 );
if ( storage )
{
// Add the item to storage
AdventureResult.addResultToList( KoLConstants.storage, item );
}
else
{
// Add the item to inventory
ResultProcessor.processResult( item );
}
return item;
}
public static final boolean registerRequest( final String urlString )
{
// mallstore.php?whichstore=294980&buying=1&ajax=1&whichitem=2272000000246&quantity=9
if ( !urlString.startsWith( "mallstore.php" ) )
{
return false;
}
Matcher itemMatcher = TransferItemRequest.ITEMID_PATTERN.matcher( urlString );
if ( !itemMatcher.find() )
{
return true;
}
Matcher quantityMatcher = TransferItemRequest.QUANTITY_PATTERN.matcher( urlString );
if ( !quantityMatcher.find() )
{
return true;
}
int quantity = StringUtilities.parseInt( quantityMatcher.group( 1 ) );
// whichitem=2272000000246
// the last 9 characters of idString are the price, with leading zeros
String idString = itemMatcher.group( 1 );
int idStringLength = idString.length();
String priceString = idString.substring(idStringLength - 9, idStringLength);
idString = idString.substring( 0, idStringLength - 9 );
// In a perfect world where I was not so lazy, I'd verify that
// the price string was really an int and might find another
// way to effectively strip leading zeros from the display
int priceVal = StringUtilities.parseInt( priceString );
int itemId = StringUtilities.parseInt( idString );
String itemName = ItemDatabase.getItemName( itemId );
// store ID is embedded in the URL. Extract it and get
// the store name for logging
int shopId = MallPurchaseRequest.getStoreId( urlString );
String storeName = shopId != -1 ? ( "shop #" + shopId ) : "a PC store";
RequestLogger.updateSessionLog();
RequestLogger.updateSessionLog( "buy " + quantity + " " + itemName + " for " + priceVal +
" each from " + storeName + " on " + KoLConstants.DAILY_FORMAT.format( new Date() ) );
return true;
}
}