You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1006 lines
31 KiB
1006 lines
31 KiB
4 years ago
|
//====== Copyright Valve Corporation, All rights reserved. =================
|
||
|
//
|
||
|
//=============================================================================
|
||
|
#include "cbase.h"
|
||
|
#include "maps_workshop.h"
|
||
|
#include "workshop/ugc_utils.h"
|
||
|
|
||
|
#include "tf_gamerules.h"
|
||
|
|
||
|
#include "rtime.h"
|
||
|
#include "tier2/fileutils.h"
|
||
|
#include "filesystem.h"
|
||
|
|
||
|
#include "icommandline.h"
|
||
|
|
||
|
#include "ServerBrowser/IServerBrowser.h"
|
||
|
|
||
|
#if !defined ( _GAMECONSOLE ) && !defined ( NO_STEAM )
|
||
|
|
||
|
CTFMapsWorkshop g_TFMapsWorkshop;
|
||
|
|
||
|
CTFMapsWorkshop *TFMapsWorkshop()
|
||
|
{
|
||
|
// Statically initialized right now, but don't assume infallible
|
||
|
return &g_TFMapsWorkshop;
|
||
|
}
|
||
|
|
||
|
static_assert( sizeof( PublishedFileId_t ) == 8, "Various printfs in this file assuming PublishedFileId_t is a 64bit type (e.g. %llu)" );
|
||
|
|
||
|
static CDllDemandLoader g_ServerBrowser( "ServerBrowser" );
|
||
|
static IServerBrowser *GetServerBrowser()
|
||
|
{
|
||
|
if ( engine->IsDedicatedServer() )
|
||
|
{
|
||
|
return NULL;
|
||
|
}
|
||
|
|
||
|
static IServerBrowser *pServerBrowser = NULL;
|
||
|
if ( pServerBrowser == NULL )
|
||
|
{
|
||
|
int iReturnCode;
|
||
|
pServerBrowser = (IServerBrowser *)g_ServerBrowser.GetFactory()( SERVERBROWSER_INTERFACE_VERSION, &iReturnCode );
|
||
|
Assert( pServerBrowser );
|
||
|
}
|
||
|
return pServerBrowser;
|
||
|
}
|
||
|
|
||
|
bool PublishedFileId_t_Less( const PublishedFileId_t &a, const PublishedFileId_t &b )
|
||
|
{
|
||
|
return a < b;
|
||
|
}
|
||
|
|
||
|
// Get and possibly init UGC
|
||
|
static ISteamUGC *GetWorkshopUGC()
|
||
|
{
|
||
|
static bool bInitUGC = false;
|
||
|
ISteamUGC *pUGC = GetSteamUGC();
|
||
|
|
||
|
// The first time we successfully get a steam context we should call the init
|
||
|
if ( pUGC && !bInitUGC )
|
||
|
{
|
||
|
// For the dedicated server API, honor -ugcpath
|
||
|
int i = CommandLine()->FindParm( "-ugcpath" );
|
||
|
if ( engine->IsDedicatedServer() && i )
|
||
|
{
|
||
|
|
||
|
const char *pUGCPath = CommandLine()->GetParm( i + 1 );
|
||
|
if ( pUGCPath )
|
||
|
{
|
||
|
g_pFullFileSystem->CreateDirHierarchy( pUGCPath, UGC_PATHID );
|
||
|
char szFullPath[MAX_PATH] = { 0 };
|
||
|
g_pFullFileSystem->RelativePathToFullPath( pUGCPath, UGC_PATHID, szFullPath, sizeof( szFullPath ) );
|
||
|
if ( *szFullPath )
|
||
|
{
|
||
|
// NOTE we use our own AppID here as the workshop depot id, but this should match the workshopdepotid in our steam config
|
||
|
pUGC->BInitWorkshopForGameServer( engine->GetAppID(), szFullPath );
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
TFWorkshopWarning( "Could not resolve -ugcpath to absolute path: %s\n", pUGCPath );
|
||
|
}
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
TFWorkshopWarning( "Empty -ugcpath passed, using default\n" );
|
||
|
}
|
||
|
}
|
||
|
else if ( i )
|
||
|
{
|
||
|
TFWorkshopWarning( "-ugcpath is ignored for listen servers\n" );
|
||
|
}
|
||
|
|
||
|
bInitUGC = true;
|
||
|
}
|
||
|
|
||
|
return pUGC;
|
||
|
}
|
||
|
|
||
|
CTFWorkshopMap::CTFWorkshopMap( PublishedFileId_t fileID )
|
||
|
: m_nFileID( fileID ),
|
||
|
m_rtimeUpdated( 0 ),
|
||
|
m_nFileSize( 0 ),
|
||
|
m_eState( eState_Refreshing ),
|
||
|
m_bHighPriority( false )
|
||
|
{
|
||
|
TFWorkshopDebug( "Created TFWorkshopMap for [ %llu ]\n", (uint64)fileID );
|
||
|
|
||
|
Refresh();
|
||
|
}
|
||
|
|
||
|
void CTFWorkshopMap::Refresh( eRefreshType refreshType )
|
||
|
{
|
||
|
ISteamUGC *steamUGC = GetWorkshopUGC();
|
||
|
|
||
|
if ( !steamUGC )
|
||
|
{
|
||
|
TFWorkshopWarning( "Failed to get Steam UGC context, map will not sync [ %llu ]\n", m_nFileID );
|
||
|
m_eState = eState_Error;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
m_eState = eState_Refreshing;
|
||
|
|
||
|
// Cancel in-flight request
|
||
|
if ( m_callbackQueryUGCDetails.IsActive() )
|
||
|
{
|
||
|
m_callbackQueryUGCDetails.Cancel();
|
||
|
}
|
||
|
|
||
|
UGCQueryHandle_t ugcQuery = steamUGC->CreateQueryUGCDetailsRequest( &m_nFileID, 1 );
|
||
|
bool setMeta = steamUGC->SetReturnMetadata( ugcQuery, true );
|
||
|
bool setCache = steamUGC->SetAllowCachedResponse( ugcQuery, 0 );
|
||
|
if ( ugcQuery == k_UGCQueryHandleInvalid || !setMeta || !setCache )
|
||
|
{
|
||
|
TFWorkshopWarning( "Failed to create UGC details request for map [ %llu ]\n", m_nFileID );
|
||
|
return;
|
||
|
}
|
||
|
SteamAPICall_t hSteamAPICall = steamUGC->SendQueryUGCRequest( ugcQuery );
|
||
|
m_callbackQueryUGCDetails.Set( hSteamAPICall, this, &CTFWorkshopMap::Steam_OnQueryUGCDetails );
|
||
|
|
||
|
if ( refreshType == eRefresh_HighPriority )
|
||
|
{
|
||
|
m_bHighPriority = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
void CTFWorkshopMap::Steam_OnQueryUGCDetails( SteamUGCQueryCompleted_t *pResult, bool bError )
|
||
|
{
|
||
|
if ( pResult->m_eResult != k_EResultOK )
|
||
|
{
|
||
|
bError = true;
|
||
|
}
|
||
|
|
||
|
ISteamUGC *steamUGC = GetWorkshopUGC();
|
||
|
|
||
|
SteamUGCDetails_t details = { 0 };
|
||
|
if ( !bError && !( steamUGC->GetQueryUGCResult( pResult->m_handle, 0, &details ) && details.m_eResult == k_EResultOK ) )
|
||
|
{
|
||
|
TFWorkshopWarning( "Error fetching updated information for map id %llu\n", m_nFileID );
|
||
|
bError = true;
|
||
|
}
|
||
|
|
||
|
char szMeta[k_cchDeveloperMetadataMax] = { 0 };
|
||
|
if ( !bError && !steamUGC->GetQueryUGCMetadata( pResult->m_handle, 0, szMeta, sizeof( szMeta ) ) )
|
||
|
{
|
||
|
bError = true;
|
||
|
TFWorkshopWarning( "Failed to get metadata for UGC file %llu\n", m_nFileID );
|
||
|
}
|
||
|
|
||
|
Assert( details.m_nPublishedFileId == m_nFileID );
|
||
|
|
||
|
if ( bError )
|
||
|
{
|
||
|
TFWorkshopWarning( "Info lookup failed for workshop file %llu ( EResult %i )\n", (uint64)m_nFileID, (int)pResult->m_eResult );
|
||
|
m_eState = eState_Error;
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Succeeded, re-evalute
|
||
|
m_eState = eState_Error;
|
||
|
m_nFileSize = details.m_nFileSize;
|
||
|
m_rtimeUpdated = details.m_rtimeUpdated;
|
||
|
|
||
|
// Our workshop maps use the metadata field for the canonical map filename
|
||
|
CUtlString baseName = CUtlString( szMeta );
|
||
|
m_strMapName = baseName;
|
||
|
|
||
|
if ( !baseName.Length() )
|
||
|
{
|
||
|
TFWorkshopWarning( "Tracked map %llu has no filename and will not sync\n", m_nFileID );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ( !g_TFMapsWorkshop.CanonicalNameForMap( m_nFileID, baseName, m_strCanonicalName ) )
|
||
|
{
|
||
|
TFWorkshopWarning( "Failed to make filename for tracked map, map will not be usuable [ baseName: %s ]\n", baseName.Get() );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ( g_TFMapsWorkshop.IsSubscribed( m_nFileID ) )
|
||
|
{
|
||
|
// Tell serverbrowser about new subscription
|
||
|
IServerBrowser *pServerBrowser = GetServerBrowser();
|
||
|
if ( pServerBrowser )
|
||
|
{
|
||
|
TFWorkshopDebug( "Informing server browser of map\n" );
|
||
|
// The server browser lists maps relative to maps/ without extension
|
||
|
if ( baseName.GetExtension() != "bsp" )
|
||
|
{
|
||
|
TFWorkshopWarning( "Map with bogus extension, declining to track [ %s ]\n", m_strCanonicalName.Get() );
|
||
|
return;
|
||
|
}
|
||
|
baseName = baseName.StripExtension();
|
||
|
pServerBrowser->AddWorkshopSubscribedMap( m_strCanonicalName.Get() );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
uint32 state = steamUGC->GetItemState( m_nFileID );
|
||
|
if (( state & k_EItemStateNeedsUpdate ) ||
|
||
|
!( state & ( k_EItemStateDownloading | k_EItemStateDownloadPending | k_EItemStateInstalled ) ) )
|
||
|
{
|
||
|
// Either out of date or not installed, downloading, or queued to download, ask UGC to do so. The latter happens
|
||
|
// for maps added not from subscriptions that have no reason for UGC to initiate downloads on its own.
|
||
|
if ( !steamUGC->DownloadItem( m_nFileID, m_bHighPriority ) )
|
||
|
{
|
||
|
TFWorkshopWarning( "DownloadItem failed for file, map will not be usable [ %s ]\n", m_strCanonicalName.Get() );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
TFWorkshopMsg( "New version available for map, download queued [ %s ]\n", m_strCanonicalName.Get() );
|
||
|
m_eState = eState_Downloading;
|
||
|
}
|
||
|
else if ( engine->IsDedicatedServer() &&
|
||
|
( state & k_EItemStateInstalled ) &&
|
||
|
!( state & k_EItemStateDownloading ) &&
|
||
|
steamUGC->DownloadItem( m_nFileID, m_bHighPriority ) )
|
||
|
{
|
||
|
// TODO This is working around a ISteamUGC bug, wherein it sends us the result of the query for a newer revision
|
||
|
// of the file, but GetItemState() does not see an update available yet. This only seems to occur using the
|
||
|
// gameserver API. Once that is fixed this is only needed if the first DownloadItem() call wasn't high
|
||
|
// priority.
|
||
|
// NOTE There is another bug where calling DownloadItem() on the *non-gameserver* api on a fully up to date item
|
||
|
// sometimes sets it to DownloadPending but never begins the download, causing us to wait
|
||
|
// forever. (Triggered by being subscribed to the file?)
|
||
|
uint32 newState = steamUGC->GetItemState( m_nFileID );
|
||
|
DevMsg( "[TF Workshop] UGC state %u\n", newState );
|
||
|
// It's unclear if DownloadItem() is supposed to be a no-op on downloaded things, or is meant to return
|
||
|
// false, but either way we'll now get a downloaded callback when things are good.
|
||
|
m_eState = eState_Downloading;
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
TFWorkshopMsg( "Got updated information for map [ %s ]\n", m_strCanonicalName.Get() );
|
||
|
m_eState = Downloaded() ? eState_Downloaded : eState_Downloading;
|
||
|
}
|
||
|
|
||
|
// Notify gamerules of the udpate
|
||
|
TFGameRules()->OnWorkshopMapUpdated( m_nFileID );
|
||
|
}
|
||
|
|
||
|
bool CTFWorkshopMap::GetLocalFile( /* out */ CUtlString &strLocalFile )
|
||
|
{
|
||
|
uint64 nUGCSize = 0;
|
||
|
uint32 nTimestamp = 0;
|
||
|
char szFolder[MAX_PATH] = { 0 };
|
||
|
if ( !GetWorkshopUGC()->GetItemInstallInfo( m_nFileID, &nUGCSize, szFolder, sizeof( szFolder ), &nTimestamp ) )
|
||
|
{
|
||
|
TFWorkshopWarning( "GetItemInstallInfo failed for item, map not usable [ %s ]\n", CanonicalName() ? CanonicalName() : "" );
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
char szFullPath[MAX_PATH] = { 0 };
|
||
|
V_MakeAbsolutePath( szFullPath, sizeof( szFullPath ), m_strMapName, szFolder );
|
||
|
strLocalFile = szFullPath;
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
bool CTFWorkshopMap::Downloaded( float *flProgress )
|
||
|
{
|
||
|
uint32 state = GetWorkshopUGC()->GetItemState( m_nFileID );
|
||
|
if (( state & k_EItemStateInstalled ) &&
|
||
|
!( state & ( k_EItemStateNeedsUpdate |
|
||
|
k_EItemStateDownloadPending |
|
||
|
k_EItemStateDownloading )))
|
||
|
{
|
||
|
if ( flProgress )
|
||
|
{
|
||
|
*flProgress = 1.f;
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if ( !flProgress )
|
||
|
{
|
||
|
// No need to calculate
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
uint64 unDownloaded = 0;
|
||
|
uint64 unTotal = 0;
|
||
|
*flProgress = 0.f;
|
||
|
if ( GetWorkshopUGC()->GetItemDownloadInfo( m_nFileID, &unDownloaded, &unTotal ) && unTotal > 0 )
|
||
|
{
|
||
|
*flProgress = (float)unDownloaded / (float)unTotal;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
void CTFWorkshopMap::OnUGCDownload( DownloadItemResult_t *pResult )
|
||
|
{
|
||
|
if ( m_eState == eState_Refreshing )
|
||
|
{
|
||
|
// This can happen if we Refresh while downloading. The info callback will check download state when it arrives,
|
||
|
// so it's safe to drop.
|
||
|
TFWorkshopDebug( "Download callback for map in refresh state [ %llu ]\n", m_nFileID );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ( m_eState != eState_Downloading )
|
||
|
{
|
||
|
TFWorkshopWarning( "Got download callback for item in invalid state [ %llu, state %i ]\n", m_nFileID, (int)m_eState );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ( pResult->m_eResult == k_EResultOK )
|
||
|
{
|
||
|
// TODO Due to the bug workaround in Steam_OnQueryUGCDetails (see TODO there) we trigger no-op downloads for
|
||
|
// even fully prepared maps, so don't spam this.
|
||
|
|
||
|
// TFWorkshopMsg( "Map download completed [ %s ]\n", m_strCanonicalName.Get() );
|
||
|
m_eState = eState_Downloaded;
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
TFWorkshopWarning( "Map download failed with result %u [ %s ]\n", pResult->m_eResult, m_strCanonicalName.Get() );
|
||
|
m_eState = eState_Error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
void CTFWorkshopMap::OnUGCItemInstalled( ItemInstalled_t *pResult )
|
||
|
{
|
||
|
// It's not clear this should ever happen for a map we already requested download of, but if we add a
|
||
|
// have-metadata-but-didnt-download state in the future this would let us short-circuit to already-downloaded,
|
||
|
// triggered by e.g. a user subscribing outside the game.
|
||
|
TFWorkshopMsg( "Installed subscribed map [ %s ]\n", m_strCanonicalName.Get() );
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Main maps workshop constructor
|
||
|
//-----------------------------------------------------------------------------
|
||
|
CTFMapsWorkshop::CTFMapsWorkshop()
|
||
|
: m_callbackDownloadItem( NULL, NULL )
|
||
|
, m_callbackItemInstalled( NULL, NULL )
|
||
|
, m_callbackDownloadItem_GameServer( NULL, NULL )
|
||
|
, m_callbackItemInstalled_GameServer( NULL, NULL )
|
||
|
, m_mapMaps( 0, 0, PublishedFileId_t_Less )
|
||
|
, m_nPreparingMap( k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Initialize workshop and start any background tasks
|
||
|
//-----------------------------------------------------------------------------
|
||
|
bool CTFMapsWorkshop::Init( void )
|
||
|
{
|
||
|
if ( !engine->IsDedicatedServer() )
|
||
|
{
|
||
|
IServerBrowser *pServerBrowser = GetServerBrowser();
|
||
|
if ( pServerBrowser )
|
||
|
{
|
||
|
pServerBrowser->SetWorkshopEnabled( true );
|
||
|
}
|
||
|
|
||
|
// Refresh for dedicated servers will happen in GameServerAPIActivated.
|
||
|
Refresh();
|
||
|
}
|
||
|
|
||
|
if ( engine->IsDedicatedServer() )
|
||
|
{
|
||
|
m_callbackDownloadItem_GameServer.Register( this, &CTFMapsWorkshop::Steam_OnUGCDownload );
|
||
|
m_callbackItemInstalled_GameServer.Register( this, &CTFMapsWorkshop::Steam_OnUGCItemInstalled );
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
m_callbackDownloadItem.Register( this, &CTFMapsWorkshop::Steam_OnUGCDownload );
|
||
|
m_callbackItemInstalled.Register( this, &CTFMapsWorkshop::Steam_OnUGCItemInstalled );
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Stop & cleanup any tasks in progress
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::Shutdown( void )
|
||
|
{
|
||
|
m_mapMaps.PurgeAndDeleteElements();
|
||
|
|
||
|
if ( engine->IsDedicatedServer() )
|
||
|
{
|
||
|
m_callbackDownloadItem_GameServer.Unregister();
|
||
|
m_callbackItemInstalled_GameServer.Unregister();
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
m_callbackDownloadItem.Unregister();
|
||
|
m_callbackItemInstalled.Unregister();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Callback for a DownloadItem() call by us completing, mark map as finished
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::Steam_OnUGCDownload( DownloadItemResult_t *pResult )
|
||
|
{
|
||
|
// This is a generic callback for any downloads happening, we're listening to handle any relevant to us
|
||
|
PublishedFileId_t nFileID = pResult->m_nPublishedFileId;
|
||
|
if ( nFileID == k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
TFWorkshopWarning( "Got UGCDownload notice for invalid item ID\n" );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
unsigned short nInd = m_mapMaps.Find( nFileID );
|
||
|
if ( nInd != m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
// This is a map of ours, notify it
|
||
|
TFWorkshopDebug( "Got DownloadItemResult for %llu\n", nFileID );
|
||
|
m_mapMaps[ nInd ]->OnUGCDownload( pResult );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Handle steam-initiated item installs
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::Steam_OnUGCItemInstalled( ItemInstalled_t *pResult )
|
||
|
{
|
||
|
// This is a generic callback for any downloads happening, we're listening to handle any relevant to us
|
||
|
PublishedFileId_t nFileID = pResult->m_nPublishedFileId;
|
||
|
if ( nFileID == k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
TFWorkshopWarning( "Got ItemInstalled notice for invalid item ID\n" );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
unsigned short nInd = m_mapMaps.Find( nFileID );
|
||
|
if ( nInd != m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
// This is a map of ours, notify it
|
||
|
TFWorkshopDebug( "Got ItemInstalled for %llu\n", nFileID );
|
||
|
m_mapMaps[ nInd ]->OnUGCItemInstalled( pResult );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Rebuild our subscriptions.
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::Refresh()
|
||
|
{
|
||
|
TFWorkshopDebug( "Refresh\n" );
|
||
|
|
||
|
// Ensure directory for maps exists
|
||
|
g_pFullFileSystem->CreateDirHierarchy( "maps/workshop", UGC_PATHID );
|
||
|
|
||
|
ISteamUGC *steamUGC = GetWorkshopUGC();
|
||
|
|
||
|
if ( !steamUGC )
|
||
|
{
|
||
|
TFWorkshopWarning( "Failed to get Steam UGC service, refresh failed\n" );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Check existing maps
|
||
|
FOR_EACH_MAP( m_mapMaps, i )
|
||
|
{
|
||
|
m_mapMaps[i]->Refresh();
|
||
|
}
|
||
|
|
||
|
// Servers are on the steamgameserver API without subscriptions
|
||
|
if ( !engine->IsDedicatedServer() )
|
||
|
{
|
||
|
// Get new subscriptions
|
||
|
m_vecSubscribedMaps.RemoveAll();
|
||
|
|
||
|
uint32 maxResults = steamUGC->GetNumSubscribedItems();
|
||
|
m_vecSubscribedMaps.AddMultipleToTail( maxResults );
|
||
|
uint32 numResults = steamUGC->GetSubscribedItems( m_vecSubscribedMaps.Base(), maxResults );
|
||
|
if ( numResults < maxResults )
|
||
|
{
|
||
|
m_vecSubscribedMaps.RemoveMultipleFromTail( maxResults - numResults );
|
||
|
}
|
||
|
|
||
|
// Check new subscriptions for maps we're not tracking, queue info requests
|
||
|
int newMaps = 0;
|
||
|
FOR_EACH_VEC( m_vecSubscribedMaps, i )
|
||
|
{
|
||
|
// Ignore maps we're already tracking
|
||
|
PublishedFileId_t fileID = m_vecSubscribedMaps[i];
|
||
|
|
||
|
if ( m_mapMaps.Find( fileID ) == m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
CTFWorkshopMap *newMap = new CTFWorkshopMap( fileID );
|
||
|
m_mapMaps.Insert( fileID, newMap );
|
||
|
|
||
|
TFWorkshopDebug( "Created workshop map %llu\n", fileID );
|
||
|
newMaps++;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
TFWorkshopMsg( "Got %u subscribed maps, %u new\n", m_vecSubscribedMaps.Count(), newMaps );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Check if a map is in our subscribed list
|
||
|
//-----------------------------------------------------------------------------
|
||
|
bool CTFMapsWorkshop::IsSubscribed( PublishedFileId_t nFileID )
|
||
|
{
|
||
|
return ( m_vecSubscribedMaps.Find( nFileID ) != m_vecSubscribedMaps.InvalidIndex() );
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Hook from BaseClientDLL to allow us to catch and prepare a workshop map load
|
||
|
//-----------------------------------------------------------------------------
|
||
|
IServerGameDLL::ePrepareLevelResourcesResult
|
||
|
CTFMapsWorkshop::AsyncPrepareLevelResources( /* in/out */ char *pMapName, size_t nMaxMapNameLen,
|
||
|
/* in/out */ char *pMapFileToUse, size_t nMaxMapFileLen,
|
||
|
float *flProgress /* = NULL */ )
|
||
|
{
|
||
|
// Files from this hook start with maps/
|
||
|
PublishedFileId_t nMapID = k_PublishedFileIdInvalid;
|
||
|
CUtlString localName( pMapName );
|
||
|
localName.ToLower();
|
||
|
nMapID = MapIDFromName( localName );
|
||
|
|
||
|
// Doesn't look like a workshop map load
|
||
|
if ( nMapID == k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
if ( flProgress )
|
||
|
{
|
||
|
*flProgress = 1.f;
|
||
|
}
|
||
|
m_nPreparingMap = k_PublishedFileIdInvalid;
|
||
|
return IServerGameDLL::ePrepareLevelResources_Prepared;
|
||
|
}
|
||
|
|
||
|
bool bNewPrepare = ( m_nPreparingMap != nMapID );
|
||
|
m_nPreparingMap = nMapID;
|
||
|
|
||
|
TFWorkshopDebug( "OnClientPrepareLevelResources for [ %s ]\n", pMapName );
|
||
|
|
||
|
unsigned int nIndex = m_mapMaps.Find( nMapID );
|
||
|
CTFWorkshopMap *pMap = NULL;
|
||
|
if ( nIndex == m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
TFWorkshopMsg( "Map ID %llu isn't tracked, adding\n", nMapID );
|
||
|
pMap = new CTFWorkshopMap( nMapID );
|
||
|
m_mapMaps.Insert( nMapID, pMap );
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
pMap = m_mapMaps[ nIndex ];
|
||
|
}
|
||
|
|
||
|
if ( bNewPrepare )
|
||
|
{
|
||
|
// Even if map is up to date, it could be stale, so always start a new prepare with a re-check
|
||
|
pMap->Refresh( CTFWorkshopMap::eRefresh_HighPriority );
|
||
|
}
|
||
|
|
||
|
if ( pMap->State() == CTFWorkshopMap::eState_Refreshing )
|
||
|
{
|
||
|
if ( flProgress )
|
||
|
{
|
||
|
*flProgress = 0.f;
|
||
|
}
|
||
|
return IServerGameDLL::ePrepareLevelResources_InProgress;
|
||
|
}
|
||
|
|
||
|
if ( pMap->State() == CTFWorkshopMap::eState_Downloading )
|
||
|
{
|
||
|
// Get download %
|
||
|
if ( flProgress )
|
||
|
{
|
||
|
pMap->Downloaded( flProgress );
|
||
|
}
|
||
|
|
||
|
return IServerGameDLL::ePrepareLevelResources_InProgress;
|
||
|
}
|
||
|
|
||
|
if ( pMap->State() == CTFWorkshopMap::eState_Downloaded )
|
||
|
{
|
||
|
// Get file name & canonical name
|
||
|
CUtlString fileName;
|
||
|
if ( pMap->GetLocalFile( fileName ) )
|
||
|
{
|
||
|
TFWorkshopMsg( "Successfully prepared client map from workshop [ %s ]\n", pMap->CanonicalName() );
|
||
|
V_strncpy( pMapFileToUse, fileName.Get(), nMaxMapFileLen );
|
||
|
V_strncpy( pMapName, pMap->CanonicalName(), nMaxMapNameLen );
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
// Tell engine we're done so it can go on and fail. It should be using maps/workshop/foo.ugc1234.bsp as a fallback...
|
||
|
TFWorkshopWarning( "Map synced, but failed to resolve local file [ %s ]\n", pMap->CanonicalName() ? pMap->CanonicalName() : "" );
|
||
|
}
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
Assert ( pMap->State() == CTFWorkshopMap::eState_Error );
|
||
|
TFWorkshopWarning( "Map failed to sync, load will not go well :(\n" );
|
||
|
// Tell engine we're done so it can go on and fail
|
||
|
}
|
||
|
|
||
|
if ( flProgress )
|
||
|
{
|
||
|
*flProgress = 1.f;
|
||
|
}
|
||
|
|
||
|
// New calls to this map ID are new loads
|
||
|
m_nPreparingMap = k_PublishedFileIdInvalid;
|
||
|
|
||
|
return IServerGameDLL::ePrepareLevelResources_Prepared;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Hook from ServerGameDLL to allow us to catch and prepare a workshop map load
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::PrepareLevelResources( /* in/out */ char *pszMapName, size_t nMapNameSize,
|
||
|
/* in/out */ char *pszMapFile, size_t nMapFileSize )
|
||
|
{
|
||
|
// Prepare the map if necessary
|
||
|
PublishedFileId_t nWorkshopID = MapIDFromName( pszMapName );
|
||
|
if ( nWorkshopID == k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// If we are a dedicated server, we're using the special steam gameserver UGC context, and need to make sure
|
||
|
// we're logged in first.
|
||
|
if ( engine->IsDedicatedServer() )
|
||
|
{
|
||
|
if ( !steamgameserverapicontext || !steamgameserverapicontext->SteamGameServer() )
|
||
|
{
|
||
|
TFWorkshopWarning( "No steam connection in PrepareLevelResources, workshop map loads will fail\n" );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Wait for login to finish, which is async and may not be done yet on initial map load
|
||
|
if ( !steamgameserverapicontext->SteamGameServer()->BLoggedOn() )
|
||
|
{
|
||
|
TFWorkshopMsg( "Waiting for steam connection\n" );
|
||
|
while ( !steamgameserverapicontext->SteamGameServer()->BLoggedOn() )
|
||
|
{
|
||
|
ThreadSleep( 10 );
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
TFWorkshopMsg( "Preparing map ID %llu\n", nWorkshopID );
|
||
|
|
||
|
while ( AsyncPrepareLevelResources( pszMapName, nMapNameSize, pszMapFile, nMapFileSize ) == \
|
||
|
IServerGameDLL::ePrepareLevelResources_InProgress )
|
||
|
{
|
||
|
ThreadSleep( 10 );
|
||
|
if ( engine->IsDedicatedServer() )
|
||
|
{
|
||
|
SteamGameServer_RunCallbacks();
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
SteamAPI_RunCallbacks();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Hook from ServerGameDLL to ask if we can provide a level as a workshop map
|
||
|
//-----------------------------------------------------------------------------
|
||
|
IServerGameDLL::eCanProvideLevelResult
|
||
|
CTFMapsWorkshop::OnCanProvideLevel( /* in/out */ char *pMapName, int nMapNameMax )
|
||
|
{
|
||
|
// Prepare the map if necessary
|
||
|
PublishedFileId_t nWorkshopID = MapIDFromName( pMapName );
|
||
|
if ( nWorkshopID != k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
auto index = m_mapMaps.Find( nWorkshopID );
|
||
|
if ( index == m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
// Looks like a workshop map, but it's not currently available
|
||
|
return IServerGameDLL::eCanProvideLevel_Possibly;
|
||
|
}
|
||
|
|
||
|
const char *szCanonicalName = m_mapMaps[ index ]->CanonicalName();
|
||
|
// A workshop map that we know about
|
||
|
// Provide canonical map name if known.
|
||
|
if ( szCanonicalName && szCanonicalName[0] )
|
||
|
{
|
||
|
V_strncpy( pMapName, szCanonicalName, nMapNameMax );
|
||
|
}
|
||
|
|
||
|
if ( m_mapMaps[ index ]->State() != CTFWorkshopMap::eState_Downloaded )
|
||
|
{
|
||
|
return IServerGameDLL::eCanProvideLevel_Possibly;
|
||
|
}
|
||
|
|
||
|
AssertMsg( !GetWorkshopUGC() || m_mapMaps[ index ]->Downloaded(), "Map in state Downloaded isn't" );
|
||
|
|
||
|
// Should have canonical name if it is downloaded
|
||
|
if ( !szCanonicalName || !szCanonicalName[0] )
|
||
|
{
|
||
|
TFWorkshopWarning( "Map is marked available but has no proper name configured [ %llu ]\n", nWorkshopID );
|
||
|
return IServerGameDLL::eCanProvideLevel_Possibly;
|
||
|
}
|
||
|
return IServerGameDLL::eCanProvideLevel_CanProvide;
|
||
|
}
|
||
|
|
||
|
return IServerGameDLL::eCanProvideLevel_CannotProvide;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Hook from ServerGameDLL to tell us the steam API is alive
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::GameServerSteamAPIActivated()
|
||
|
{
|
||
|
if ( engine->IsDedicatedServer() )
|
||
|
{
|
||
|
Refresh();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Backend for tf_workshop_map_status
|
||
|
//-----------------------------------------------------------------------------
|
||
|
void CTFMapsWorkshop::PrintStatusToConsole()
|
||
|
{
|
||
|
// Find longest map name
|
||
|
unsigned int nMapLen = 12; // minimum for column header and padding
|
||
|
FOR_EACH_MAP_FAST( m_mapMaps, idx )
|
||
|
{
|
||
|
CUtlString mapName;
|
||
|
GetMapName( m_mapMaps[idx]->FileID(), mapName );
|
||
|
nMapLen = Max( nMapLen, (unsigned int)mapName.Length() );
|
||
|
}
|
||
|
|
||
|
char szHeaderFmt[128] = { 0 };
|
||
|
char szLineFmt[128] = { 0 };
|
||
|
V_snprintf( szHeaderFmt, sizeof( szHeaderFmt ), "%%20s %%%us %%12s\n", nMapLen );
|
||
|
V_snprintf( szLineFmt, sizeof( szLineFmt ), "%%20llu %%%us %%12s\n", nMapLen );
|
||
|
|
||
|
Msg( szHeaderFmt, "FileID", "Map Name", "Status" );
|
||
|
Msg( szHeaderFmt, "---", "---", "---" );
|
||
|
|
||
|
FOR_EACH_MAP_FAST( m_mapMaps, idx )
|
||
|
{
|
||
|
const CTFWorkshopMap &map = *m_mapMaps[idx];
|
||
|
const char *pState = "unknown";
|
||
|
switch ( map.State() )
|
||
|
{
|
||
|
case CTFWorkshopMap::eState_Refreshing:
|
||
|
pState = "refreshing";
|
||
|
break;
|
||
|
case CTFWorkshopMap::eState_Error:
|
||
|
pState = "error";
|
||
|
break;
|
||
|
case CTFWorkshopMap::eState_Downloading:
|
||
|
pState = "downloading";
|
||
|
break;
|
||
|
case CTFWorkshopMap::eState_Downloaded:
|
||
|
pState = "ready";
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
CUtlString mapName;
|
||
|
GetMapName( map.FileID(), mapName );
|
||
|
Msg( szLineFmt, map.FileID(), mapName.Get(), pState );
|
||
|
}
|
||
|
|
||
|
Msg( "%u tracked maps\n", m_mapMaps.Count() );
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
bool CTFMapsWorkshop::CanonicalNameForMap( PublishedFileId_t fileID, const CUtlString &originalFileName, /* out */ CUtlString &strCanonName )
|
||
|
{
|
||
|
if ( !IsValidOriginalFileNameForMap( originalFileName ) )
|
||
|
{
|
||
|
TFWorkshopWarning( "Invalid workshop map name %llu [ %s ]\n", fileID, originalFileName.Get() );
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// cp_mymap.bsp -> workshop/cp_mymap.ugc12345
|
||
|
char szBase[MAX_PATH];
|
||
|
V_FileBase( originalFileName.Get(), szBase, sizeof( szBase ) );
|
||
|
|
||
|
int len = strCanonName.Format( "workshop/%s.ugc%llu", szBase, fileID );
|
||
|
if ( len >= MAX_PATH )
|
||
|
{
|
||
|
Assert( len < MAX_PATH );
|
||
|
// This should be caught by the name validator but
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
CTFMapsWorkshop::eNameType CTFMapsWorkshop::GetMapName( PublishedFileId_t nMapID, /* out */ CUtlString &mapName )
|
||
|
{
|
||
|
auto index = m_mapMaps.Find( nMapID );
|
||
|
if ( index != m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
const char *pCanonName = m_mapMaps[ index ]->CanonicalName();
|
||
|
if ( pCanonName )
|
||
|
{
|
||
|
mapName = pCanonName;
|
||
|
return CTFMapsWorkshop::eName_Canon;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Default stub name
|
||
|
mapName.Format( "workshop/%llu", nMapID );
|
||
|
return CTFMapsWorkshop::eName_Incomplete;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
CTFWorkshopMap *CTFMapsWorkshop::FindMapByName( const char *pMapName )
|
||
|
{
|
||
|
PublishedFileId_t nWorkshopID = MapIDFromName( pMapName );
|
||
|
if ( nWorkshopID != k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
auto index = m_mapMaps.Find( nWorkshopID );
|
||
|
if ( index != m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
return m_mapMaps[ index ];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return NULL;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
CTFWorkshopMap *CTFMapsWorkshop::FindOrCreateMapByName( const char *pMapName )
|
||
|
{
|
||
|
PublishedFileId_t nWorkshopID = MapIDFromName( pMapName );
|
||
|
if ( nWorkshopID != k_PublishedFileIdInvalid )
|
||
|
{
|
||
|
auto index = m_mapMaps.Find( nWorkshopID );
|
||
|
if ( index != m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
return m_mapMaps[ index ];
|
||
|
}
|
||
|
|
||
|
// Not found, but valid-looking workshop name, create
|
||
|
CTFWorkshopMap *pMap = new CTFWorkshopMap( nWorkshopID );
|
||
|
m_mapMaps.Insert( nWorkshopID, pMap );
|
||
|
return pMap;
|
||
|
}
|
||
|
|
||
|
return NULL;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Synchronously prepare a map for use, assuming it has been subscribed to
|
||
|
//-----------------------------------------------------------------------------
|
||
|
PublishedFileId_t CTFMapsWorkshop::MapIDFromName( CUtlString localMapName )
|
||
|
{
|
||
|
localMapName.ToLower();
|
||
|
const char szWorkshopPrefix[] = "workshop/";
|
||
|
|
||
|
if ( localMapName.Slice( 0, sizeof( szWorkshopPrefix ) - 1 ) != szWorkshopPrefix )
|
||
|
{
|
||
|
TFWorkshopDebug( "Map '%s' does not appear to be a workshop map -- no workshop/ prefix\n", localMapName.Get() );
|
||
|
return k_PublishedFileIdInvalid;
|
||
|
}
|
||
|
|
||
|
// Check canonical format: workshop/cp_anyname.ugc1234
|
||
|
// Find .ugc, ensure its followed by a number
|
||
|
const char szUGCSuffix[] = ".ugc";
|
||
|
const size_t nSuffixLen = sizeof( szUGCSuffix ) - 1;
|
||
|
|
||
|
CUtlString strID;
|
||
|
|
||
|
char *pszUGCSuffix = V_strstr( localMapName.Get(), szUGCSuffix );
|
||
|
if ( pszUGCSuffix && strlen( pszUGCSuffix ) >= nSuffixLen + 1 )
|
||
|
{
|
||
|
// Need at least five for ".ugc1"
|
||
|
strID = pszUGCSuffix + nSuffixLen;
|
||
|
|
||
|
// Check that the name string is at least a valid workshop map name. It doesn't have to match the real name,
|
||
|
// since IDs can update their display name at arbitrary points, but "workshop/\n\n\x1.ugc5" should not parse as
|
||
|
// a valid alias for workshop/5
|
||
|
CUtlString baseMapName = localMapName.Slice( sizeof( szWorkshopPrefix ) - 1,
|
||
|
(int32)((intptr_t)pszUGCSuffix - (intptr_t)localMapName.Get()) );
|
||
|
if ( !IsValidDisplayNameForMap( baseMapName ) )
|
||
|
{
|
||
|
TFWorkshopDebug( "Map '%s' looks like a workshop map, but '%s' is not a legal workshop map name\n",
|
||
|
localMapName.Get(), baseMapName.Get() );
|
||
|
return k_PublishedFileIdInvalid;
|
||
|
}
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
// Assume workshop/12345 shorthand, we'll fail if we hit a non-number parsing it
|
||
|
strID = localMapName.Slice( sizeof( szWorkshopPrefix ) - 1 );
|
||
|
}
|
||
|
|
||
|
int i;
|
||
|
for ( i = 0; i < strID.Length(); i ++ )
|
||
|
{
|
||
|
if ( strID[i] < '0' || strID[i] > '9' )
|
||
|
{
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ( i != strID.Length() )
|
||
|
{
|
||
|
return k_PublishedFileIdInvalid;
|
||
|
}
|
||
|
|
||
|
// Found ID and it was all numbers, sscanf it
|
||
|
PublishedFileId_t nMapID = k_PublishedFileIdInvalid;
|
||
|
sscanf( strID.Get(), "%llu", &nMapID );
|
||
|
return nMapID;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Add this map to our list for this session, triggering download/etc as if it were subscribed
|
||
|
//-----------------------------------------------------------------------------
|
||
|
bool CTFMapsWorkshop::AddMap( PublishedFileId_t nMapID )
|
||
|
{
|
||
|
unsigned int nIndex = m_mapMaps.Find( nMapID );
|
||
|
if ( nIndex == m_mapMaps.InvalidIndex() )
|
||
|
{
|
||
|
m_mapMaps.Insert( nMapID, new CTFWorkshopMap( nMapID ) );
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Command to trigger refresh
|
||
|
//-----------------------------------------------------------------------------
|
||
|
CON_COMMAND( tf_workshop_refresh, "tf_workshop_refresh" )
|
||
|
{
|
||
|
#ifdef GAME_DLL
|
||
|
if ( !UTIL_IsCommandIssuedByServerAdmin() )
|
||
|
return;
|
||
|
#endif
|
||
|
|
||
|
if ( args.ArgC() != 1 )
|
||
|
{
|
||
|
TFWorkshopMsg( "Usage: tf_workshop_refresh - Trigger a recheck subscriptions and tracked maps\n" );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
TFWorkshopMsg( "Requesting maps refresh\n" );
|
||
|
g_TFMapsWorkshop.Refresh();
|
||
|
}
|
||
|
|
||
|
//-----------------------------------------------------------------------------
|
||
|
// Purpose: Command to sync prepare map
|
||
|
//-----------------------------------------------------------------------------
|
||
|
CON_COMMAND( tf_workshop_map_sync, "Add a map to the workshop auto-sync list" )
|
||
|
{
|
||
|
#ifdef GAME_DLL
|
||
|
if ( !UTIL_IsCommandIssuedByServerAdmin() )
|
||
|
return;
|
||
|
#endif
|
||
|
|
||
|
PublishedFileId_t nTargetID = 0;
|
||
|
if ( args.ArgC() == 2 )
|
||
|
{
|
||
|
sscanf( args[1], "%llu", &nTargetID );
|
||
|
}
|
||
|
|
||
|
if ( !nTargetID )
|
||
|
{
|
||
|
TFWorkshopMsg( "Usage: tf_workshop_map_sync <map ugc id> - Add a map to the workshop auto-sync list\n" );
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ( g_TFMapsWorkshop.AddMap( nTargetID ) )
|
||
|
{
|
||
|
TFWorkshopMsg( "Added %llu to tracked maps\n", nTargetID );
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
TFWorkshopMsg( "Map %llu is already tracked\n", nTargetID );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
CON_COMMAND( tf_workshop_map_status, "Print information about workshop maps and their status" )
|
||
|
{
|
||
|
#ifdef GAME_DLL
|
||
|
if ( !UTIL_IsCommandIssuedByServerAdmin() )
|
||
|
return;
|
||
|
#endif
|
||
|
|
||
|
g_TFMapsWorkshop.PrintStatusToConsole();
|
||
|
}
|
||
|
|
||
|
#endif // !_GAMECONSOLE
|