diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index ae3aeaf..2f26c19 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -33,6 +33,11 @@ static char const * const DefaultSearchFolders = "INI,MIX,Maps"; static char const * const ConfigName = "OPENTS.INI"; +/* + * The folder saved games are kept in, under whichever directory the player's own files go. + */ +static char const * const SavedGamesFolder = "Saved Games"; + /* * The folders the configuration itself is looked for in, relative to the data directory. */ @@ -314,6 +319,22 @@ std::string User_File_Write_Name(char const * filename) } +/// +/// Names a saved game inside the folder they are kept in. That folder is deliberately not +/// one of the searched ones, and is made on the way so a launcher can browse it before the +/// first save is written. +/// +/// The name to open, delete or scan for. +std::string Saved_Game_Name(char const * filename) +{ + std::string const folder = UserDirectory + SavedGamesFolder; + + CreateDirectory(folder.c_str(), NULL); + + return(folder + '\\' + filename); +} + + static void Scan_Folder(char const * prefix, char const * pattern, std::vector & names) { std::string const search = std::string(prefix) + pattern; diff --git a/code/gamedirs.h b/code/gamedirs.h index 40068f9..7adefc0 100644 --- a/code/gamedirs.h +++ b/code/gamedirs.h @@ -37,5 +37,11 @@ char const * Game_Directory_Error(void); */ std::string User_File_Write_Name(char const * filename); +/* + * Where the player's saved games are. They are never searched for: the folder is named + * outright wherever a saved game is opened, listed or removed. + */ +std::string Saved_Game_Name(char const * filename); + std::vector Parse_Search_Folders(char const * list); std::vector Search_Files(char const * pattern); diff --git a/code/house.cpp b/code/house.cpp index 9f25998..f10f5de 100644 --- a/code/house.cpp +++ b/code/house.cpp @@ -311,6 +311,7 @@ HouseClass::HouseClass(HouseTypeClass const * type) : BuildingsLost(0), WhoLastHurtMe(HOUSE_NONE), Center(0,0,0), + SpawnWaypoint(-1), Radius(0), LATime(0), LAEnemy(HOUSE_NONE), @@ -6491,6 +6492,7 @@ void HouseClass::Serialize(SaveStreamClass & stream) stream.Serialize(EnemyAirForcePrediction); stream.Serialize(EnemyInfantryForcePrediction); stream.Serialize(PowerSurplus); + stream.Serialize(SpawnWaypoint); } diff --git a/code/house.h b/code/house.h index 604f5f2..bb2bb79 100644 --- a/code/house.h +++ b/code/house.h @@ -83,8 +83,6 @@ class ObjectTypeClass; class SaveStreamClass; template class DynamicVectorClass; -#define HOUSE_NAME_MAX 20 - /**************************************************************************** ** Certain aspects of the house "country" are initially set by the scenario @@ -565,6 +563,7 @@ class HouseClass : public AbstractClass ** the base. */ Coord Center; // Center of the base. + int SpawnWaypoint; // starting waypoint this house was placed at; -1 = never placed int Radius; // Average building distance from center (leptons). struct { int AirDefense; diff --git a/code/house.hh b/code/house.hh index 73bdac4..a0df86a 100644 --- a/code/house.hh +++ b/code/house.hh @@ -14,6 +14,8 @@ #pragma once +#define HOUSE_NAME_MAX 20 + /********************************************************************** ** The houses that can be played are listed here. Each has their own ** personality and strengths. diff --git a/code/init.cpp b/code/init.cpp index c4543ac..a3de064 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -153,6 +153,7 @@ #include "scheme.h" #include "script.h" #include "session.h" +#include "spawner.h" #include "side.h" #include "skirmish.h" #include "smudtype.h" @@ -412,18 +413,20 @@ int Init_Game(int , char * []) /* ** Play the startup animation. */ - if (Special.IsFromInstall == true) { - DebugString("Playing first time intro sequence.\n"); - Play_Movie("EVA.VQA", THEME_NONE, false); - } + if (!Spawner_Is_Requested()) { + if (Special.IsFromInstall == true) { + DebugString("Playing first time intro sequence.\n"); + Play_Movie("EVA.VQA", THEME_NONE, false); + } - DebugString("Playing startup movies.\n"); - Play_Movie("WWLOGO.VQA", THEME_NONE); - if (!Get_New_Menu()->MixFile) { - if (CCFileClass("FS_TITLE.VQA").Is_Available() == true) { - Play_Movie("FS_TITLE.VQA", THEME_NONE, false); - } else { - Play_Movie("STARTUP.VQA", THEME_NONE, false); + DebugString("Playing startup movies.\n"); + Play_Movie("WWLOGO.VQA", THEME_NONE); + if (!Get_New_Menu()->MixFile) { + if (CCFileClass("FS_TITLE.VQA").Is_Available() == true) { + Play_Movie("FS_TITLE.VQA", THEME_NONE, false); + } else { + Play_Movie("STARTUP.VQA", THEME_NONE, false); + } } } @@ -647,6 +650,21 @@ void Init_Campaigns(void) } +/// +/// Reads the countries and the sides they belong to from the rules, so a house's side is +/// known before anything asks for it. Only this reading establishes the roster. +/// +void Prepare_Side_Roster(void) +{ + Rule->Do_HouseTypes(*RuleINI); + Rule->Do_Sides(*RuleINI); + + for (int index = 0; index < HouseTypes.Count(); index++) { + HouseTypes[index]->Read_INI(*RuleINI); + } +} + + /// /// Can this campaign be played with the addons that are enabled? /// A base game campaign is offered only when no addon is running, and an addon's own @@ -1071,6 +1089,18 @@ bool Select_Game(bool ) } } + /* + * A launch takes the place of the menu once; an ended match or a refusal answers false, + * so the process leaves and the client sees it go. + */ + if (Spawner_Is_Requested()) { + if (!Spawner_Prepare(gameloaded)) { + return(false); + } + process = false; + Theme.Stop(true); + } + while (process) { /* @@ -1117,33 +1147,6 @@ bool Select_Game(bool ) break; } - switch (Options.Difficulty) { - case 0: - Scen->CDifficulty = DIFF_HARD; - Scen->Difficulty = DIFF_EASY; - break; - - case 1: - Scen->CDifficulty = DIFF_HARD; - Scen->Difficulty = DIFF_NORMAL; - break; - - case 2: - Scen->CDifficulty = DIFF_NORMAL; - Scen->Difficulty = DIFF_NORMAL; - break; - - case 3: - Scen->CDifficulty = DIFF_EASY; - Scen->Difficulty = DIFF_NORMAL; - break; - - case 4: - Scen->CDifficulty = DIFF_EASY; - Scen->Difficulty = DIFF_HARD; - break; - } - Theme.Stop(true); int timeout = (TickCount + 5 * TIMER_SECOND); @@ -1386,8 +1389,14 @@ bool Select_Game(bool ) Session.PlayerIsGDI = stricmp(HouseTypes[Session.Players[0]->Player.House]->Name(), "GDI") == 0; } - if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play) { - if (!Start_Scenario(Scen->ScenarioName, true, CAMPAIGN_NONE)) { + // The menu sets the pair on every path but a client launch, which chose it itself. + if (!Spawner_Is_Active()) { + Session.CampaignDifficulty = (DiffType)Options.Difficulty; + Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty); + } + + if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play || Spawner_Is_Active()) { + if (!Start_Scenario(Scen->ScenarioName, true, Spawner_Is_Active() ? Scen->Campaign : CAMPAIGN_NONE)) { if (Debug_Map) { return(false); } else { @@ -1404,6 +1413,13 @@ bool Select_Game(bool ) } } + // The mission read clears these, so a launch file's carried-over flags are set after it. + if (Spawner_Is_Active() && Session.Type == GAME_NORMAL) { + for (int index = 0; index < ARRAY_SIZE(Environment.Globals); index++) { + Scen->Set_Global_To(index, Environment.Globals[index]); + } + } + /* ** Save initialization values if we're recording this game. */ @@ -1655,6 +1671,12 @@ bool Parse_Command_Line(int argc, char * argv[]) continue; } + // A client asking the game to launch what SPAWN.INI describes. + if (stricmp(string, "-SPAWN") == 0) { + Spawner_Request(); + continue; + } + if (memcmp(string, "-TIME=", 6) == 0) { sscanf(&string[6], "%d", &TournamentTime); } @@ -1830,7 +1852,11 @@ void Init_Random(void) ** a recording; the random number generator is initialized by loading ** the game. */ - if (Session.LoadGame || Session.Play) { + if (Session.LoadGame) { + return; + } + + if (Session.Play) { Scen->RandomNumber = Seed; NonCriticalRandomNumber = Seed; DebugString("Seed is %08x\n", Seed); diff --git a/code/init.h b/code/init.h index 5d57aba..7b80b5f 100644 --- a/code/init.h +++ b/code/init.h @@ -42,6 +42,8 @@ void Title_Screen_Restore(bool force=false); void Init_Campaigns(void); +void Prepare_Side_Roster(void); + void Delete_All_Objects(void); void Init_Theater(TheaterType theater); diff --git a/code/language/language.rc b/code/language/language.rc index 4b237f1..1e0d043 100644 --- a/code/language/language.rc +++ b/code/language/language.rc @@ -1426,11 +1426,13 @@ STYLE WS_CHILD FONT 8, "MS Sans Serif" BEGIN CONTROL "Resume Mission",IDC_RESUME_MISSION,"Button", - BS_OWNERDRAW,120,48,99,14 + BS_OWNERDRAW,120,59,99,14 CONTROL "Game Controls",IDC_GAME_CONTROLS,"Button",BS_OWNERDRAW, - 120,12,99,14 + 120,8,99,14 + CONTROL "Save Game",IDC_SAVE_GAME,"Button",BS_OWNERDRAW, + 120,25,99,14 CONTROL "Abort Mission",IDC_ABORT_MISSION,"Button",BS_OWNERDRAW, - 120,30,99,14 + 120,42,99,14 CONTROL "Slider1",IDC_GAME_SPEED_SLIDER,"msctls_trackbar32", TBS_BOTH | TBS_NOTICKS,95,115,148,13 LTEXT "Game Speed",-1,39,115,58,13,SS_CENTERIMAGE | NOT diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 9988e7d..a3306cc 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -358,6 +358,16 @@ LRESULT CALLBACK LoadOptionsClass::Delete_Dialog_Proc(HWND window, UINT message, } +/// +/// Is a saved game of this name already there? Asked before one is written, since a name the +/// folder holds is written over rather than added to. +/// +static bool Saved_Game_Exists(char const * name) +{ + return(GetFileAttributes(Saved_Game_Name(name).c_str()) != INVALID_FILE_ATTRIBUTES); +} + + /*********************************************************************************************** * LoadOptionsClass::Process -- main processing routine * * * @@ -490,25 +500,17 @@ bool LoadOptionsClass::Dialog(void) } const char * filename = NULL; + char test_filename[256]; if (entry && entry->Valid) { filename = entry->Filename; } else { - char test_filename[256]; - - { /// the scope is important to make it match - temp_file nedes to be destroyed before assigning the string - CCFileClass temp_file; - do { - sprintf(test_filename, "SAVE%04lX.%3s", rand(), Extension); - temp_file.Set_Name(test_filename); - } while (temp_file.Is_Available() == true); - } - + Pick_Filename(test_filename); filename = test_filename; } if (filename != NULL) { - bool exists = CDFileClass(filename).Is_Available() == true; + bool exists = Saved_Game_Exists(filename); if (exists && WWMessageBox()._Process(TXT_CONFIRM_SAVE, 1, TXT_YES, TXT_NO, TXT_NONE)) State = STATE_PENDING; else { @@ -569,11 +571,9 @@ bool LoadOptionsClass::Dialog(void) /// Be sure the buffer is big enough to hold a complete filename. void LoadOptionsClass::Pick_Filename(char *name) { - CCFileClass file; do { sprintf(name, "SAVE%04lX.%3s", rand(), Extension); - file.Set_Name(name); - } while (file.Is_Available() == true); + } while (Saved_Game_Exists(name)); } @@ -606,25 +606,6 @@ void LoadOptionsClass::Clear_List(void) } -/* - * Recovers the directory entry for a saved game the scan turned up. The scan reports bare - * names, so the file is located the way an open would locate it and then asked about by the - * name it actually has. The entry names the file alone, without the directory it sits in. - */ -static bool Find_Saved_Game(char const * name, WIN32_FIND_DATAA * entry) -{ - CDFileClass located(name); - - HANDLE handle = FindFirstFile(located.File_Name(), entry); - if (handle == INVALID_HANDLE_VALUE) { - return(false); - } - - FindClose(handle); - return(true); -} - - /*********************************************************************************************** * LoadOptionsClass::Fill_List -- fills the list box & GameNum arrays * * * @@ -685,22 +666,28 @@ void LoadOptionsClass::Fill_List(HWND window) */ fdata = NULL; - for (std::string const & name : Search_Files(buffer)) { - if (!Find_Saved_Game(name.c_str(), &ff)) { - continue; - } + HANDLE hFind = FindFirstFile(Saved_Game_Name(buffer).c_str(), &ff); - if (fdata == NULL) { - fdata = new FileEntryClass; - } + if (hFind != INVALID_HANDLE_VALUE) { + do { + if ((ff.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) != 0) { + continue; + } - /* - ** get the game's info; if success, add it to the list - */ - if (Read_File(fdata, &ff) == true) { - Files.Add(fdata); - fdata = NULL; - } + if (fdata == NULL) { + fdata = new FileEntryClass; + } + + /* + ** get the game's info; if success, add it to the list + */ + if (Read_File(fdata, &ff) == true) { + Files.Add(fdata); + fdata = NULL; + } + } while (FindNextFile(hFind, &ff)); + + FindClose(hFind); } if (fdata != NULL) { @@ -786,21 +773,26 @@ bool LoadOptionsClass::Files_Present(void) sprintf(pattern, "*.%3s", Extension); WIN32_FIND_DATAA find_data; + HANDLE hFind = FindFirstFile(Saved_Game_Name(pattern).c_str(), &find_data); - for (std::string const & name : Search_Files(pattern)) { - if (_stricmp(name.c_str(), NET_SAVE_FILE_NAME) == 0) { - continue; - } + if (hFind != INVALID_HANDLE_VALUE) { + do { + if ((find_data.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) != 0) { + continue; + } - if (!Find_Saved_Game(name.c_str(), &find_data)) { - continue; - } + if (_stricmp(find_data.cFileName, NET_SAVE_FILE_NAME) == 0) { + continue; + } - FileEntryClass entry; - if (Read_File(&entry, &find_data) == true) { - files_found = true; - break; - } + FileEntryClass entry; + if (Read_File(&entry, &find_data) == true) { + files_found = true; + break; + } + } while (FindNextFile(hFind, &find_data)); + + FindClose(hFind); } return(files_found); @@ -883,7 +875,7 @@ bool LoadOptionsClass::Save_File(const char * file_name, const char * descr) /// bool; Was the file deleted? bool LoadOptionsClass::Delete_File(const char * file_name) { - if (DeleteFile(User_File_Write_Name(file_name).c_str()) == TRUE) { + if (DeleteFile(Saved_Game_Name(file_name).c_str()) == TRUE) { return(true); } return(false); diff --git a/code/mapgen.cpp b/code/mapgen.cpp index 349b884..cef9b17 100644 --- a/code/mapgen.cpp +++ b/code/mapgen.cpp @@ -29,6 +29,7 @@ #include "coord.h" #include "data.h" #include "dbgprint.h" +#include "gamedirs.h" #include "house.h" #include "houstype.h" #include "incdec.h" @@ -4366,6 +4367,17 @@ bool MapSeedClass::Save(const char * name) } +/// +/// Is this the generator's own map rather than settings a player saved? That one travels to +/// the other machines with the match, so it is kept where the game's own files are. +/// +/// bool; Is this the map a match is played on rather than a saved setting? +static bool Is_Shared_Map_File(char const * file_name) +{ + return(stricmp(file_name, RANDOM_MAP_FILE_NAME) == 0); +} + + /// /// Writes the map generator settings to a file. /// This routine records everything the random map dialog offers, so that loading the file @@ -4379,7 +4391,9 @@ bool MapSeedClass::Save_File(const char * file_name, const char * descr) { if (file_name != NULL) { DebugString("Saving random map: %s - %s\n", file_name, descr); - CCFileClass file(file_name); + CCFileClass shared(file_name); + RawFileClass owned(Saved_Game_Name(file_name).c_str()); + FileClass & file = Is_Shared_Map_File(file_name) ? (FileClass &)shared : (FileClass &)owned; INIClass ini; ini.Put_String("RandomMap", "Description", descr); ini.Put_Int("RandomMap", "Width", Width, 0); @@ -4439,7 +4453,9 @@ bool MapSeedClass::Load_File(const char * file_name) { if (file_name != NULL) { DebugString("Loading random map: %s\n", file_name); - CCFileClass file(file_name); + CCFileClass shared(file_name); + RawFileClass owned(Saved_Game_Name(file_name).c_str()); + FileClass & file = Is_Shared_Map_File(file_name) ? (FileClass &)shared : (FileClass &)owned; INIClass ini; if (ini.Load(file)) { @@ -4511,7 +4527,7 @@ bool MapSeedClass::Read_File(FileEntryClass * entry, WIN32_FIND_DATAA * ff) if (entry != NULL && ff != NULL) { if (stricmp(ff->cFileName, RANDOM_MAP_FILE_NAME)) { - CCFileClass file(ff->cFileName); + RawFileClass file(Saved_Game_Name(ff->cFileName).c_str()); INIClass ini; if (ini.Load(file)) { if (ini.Get_String("RandomMap", "Description", 0, buffer, sizeof(buffer)) > 0 ) diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index 65b91c1..65fd2f5 100644 --- a/code/netdlg2.cpp +++ b/code/netdlg2.cpp @@ -851,7 +851,6 @@ bool Net2Remote_Connect(void) // Add myself to the list, and to the Players vector. //------------------------------------------------------------------------ NodeNameType * who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.Handle); strcpy(who->Player.Serial, SerialNumber); who->Player.House = Session.House; @@ -2473,7 +2472,6 @@ static void Get_Join_Responses(void) // Create & add a node to the Vector //.................................................................. who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.GPacket.Name); strcpy(who->Player.Serial, Session.GPacket.Serial); who->Address = Session.GAddress; @@ -2530,7 +2528,6 @@ static void Get_Join_Responses(void) Clear_Vector(&Session.Players); who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.Handle); who->Player.House = Session.House; who->Player.Color = Session.ColorIdx; @@ -3104,7 +3101,6 @@ static void Get_Join_Responses(void) // Add node to the Vector list //.................................................................. who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.GPacket.Name); who->Address = Session.GAddress; who->Player.House = Session.GPacket.PlayerInfo.House; diff --git a/code/netshare.cpp b/code/netshare.cpp index 5909278..797a7ef 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -1261,6 +1261,20 @@ int CALLBACK Scenario_DlgProc(HWND window, UINT message, WPARAM wparam, LPARAM l } +/// +/// Puts the options every machine agreed on into the globals the simulation reads, so a match +/// against other machines is played under one set of rules however it was set up. +/// +void Commit_Session_Specials(void) +{ + Special.IsHarvesterImmune = Session.Options.HarvTruce; + Special.IsDestroyBridges = Session.Options.BridgeDestruction; + Special.IsTGrowth = true; + Special.IsTSpread = true; + Special.Apply_To_Game(); +} + + /// /// Performs the last setup step before a multiplayer game begins. /// This routine copies the agreed session options into the globals the game logic actually @@ -1274,11 +1288,7 @@ void PregameSetup(void) DebugString("Pregame setup for %d players.\n", Session.NumPlayers); Options.GameSpeed = Session.Options.GameSpeed; Session.CommProtocol = DEFAULT_COMM_PROTOCOL; - Special.IsHarvesterImmune = Session.Options.HarvTruce; - Special.IsDestroyBridges = Session.Options.BridgeDestruction; - Special.IsTGrowth = true; - Special.IsTSpread = true; - Special.Apply_To_Game(); + Commit_Session_Specials(); } diff --git a/code/netshare.h b/code/netshare.h index 27200fc..3d18e8e 100644 --- a/code/netshare.h +++ b/code/netshare.h @@ -20,6 +20,7 @@ int ODMessageBox(const char *text, int type, bool (*callback)(void), bool large int CALLBACK ODMessageBox_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); bool Set_Scenario_Info_From_Index(int index); +void Commit_Session_Specials(void); void PregameSetup(void); void Update_Network_Dialog_Preview(HWND win); void Receive_Random_Map_Preview(void); diff --git a/code/options.cpp b/code/options.cpp index de479fd..f69efe1 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -359,7 +359,7 @@ void OptionsClass::Load_Settings(void) DebugString("GameSpeed = %d\n", GameSpeed); Difficulty = ConfigINI.Get_Int("Options", "Difficulty", Difficulty); - Difficulty = std::min(Difficulty, 4); + Difficulty = std::min(Difficulty, (int)DIFF_COUNT - 1); Difficulty = std::max(Difficulty, 0); DebugString("Difficulty = %d\n", Difficulty); diff --git a/code/saveload.cpp b/code/saveload.cpp index 51c1bcc..a727165 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -149,8 +149,6 @@ static bool MultiplayerSavePending = false; static std::string PendingSaveFileName; static std::string PendingSaveDescription; -static int Reconcile_Players(void); - _COM_SMARTPTR_TYPEDEF(ILinkStream, __uuidof(ILinkStream)); @@ -620,8 +618,12 @@ static bool Put_All(IStream *stream, int save_net) return(false); } - if (Session.Type == GAME_SKIRMISH) { - DebugString("Writing Skirmish Session.Options\n"); + /* + * A campaign takes its options from the mission; every other kind was told them once, + * when the game was set up, so the save is the only place a resume can find them. + */ + if (Session.Type != GAME_NORMAL) { + DebugString("Writing Session.Options\n"); if (!Session.Options.Save(stream)) { DebugString("\t***** FAILED!\n"); return(false); @@ -870,8 +872,8 @@ static bool Get_All(IStream *stream, bool save_net) return(false); } - if (Session.Type == GAME_SKIRMISH) { - DebugString("Reading Skirmish Session.Options\n"); + if (Session.Type != GAME_NORMAL) { + DebugString("Reading Session.Options\n"); if (!Session.Options.Load(stream)) { DebugString("\t***** FAILED!\n"); return(false); @@ -928,7 +930,7 @@ static bool Save_Game(const char *file_name, char const * descr) DebugString("\nSAVING GAME [%s - %s]\n", file_name, descr); - MultiByteToWideChar(0,0, User_File_Write_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR)); + MultiByteToWideChar(0,0, Saved_Game_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR)); /* ** Open the file @@ -1181,8 +1183,8 @@ bool Load_Game(const char *file_name) */ IStoragePtr storage; - // Structured storage goes straight to Windows, so the file layer locates the save first. - MultiByteToWideChar(0,0,CDFileClass(file_name).File_Name(), -1, name, (sizeof(name)/sizeof(WCHAR))); + // Structured storage goes straight to Windows, so the saved game is named in full first. + MultiByteToWideChar(0,0,Saved_Game_Name(file_name).c_str(), -1, name, (sizeof(name)/sizeof(WCHAR))); if (FAILED(StgOpenStorage(name, 0, STGM_SHARE_DENY_WRITE, 0, 0, &storage))) { return(false); @@ -1217,6 +1219,10 @@ bool Load_Game(const char *file_name) */ Post_Load_Game(); + // The next mission of a resumed campaign is played at the pair the save carries. + Session.CampaignDifficulty = Scen->Difficulty; + Session.CampaignCDifficulty = Scen->CDifficulty; + Map.Init_IO(); Map.Activate(1); Map.Reposition_Sidebar(); @@ -1328,8 +1334,8 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) IStoragePtr storage; WCHAR wname[MAX_PATH]; - // Structured storage goes straight to Windows, so the file layer locates the save first. - MultiByteToWideChar(0, 0, CDFileClass(name).File_Name(), -1, wname, sizeof(wname) / sizeof(WCHAR)); + // Structured storage goes straight to Windows, so the saved game is named in full first. + MultiByteToWideChar(0, 0, Saved_Game_Name(name).c_str(), -1, wname, sizeof(wname) / sizeof(WCHAR)); HRESULT result = StgOpenStorage(wname, NULL, STGM_SHARE_EXCLUSIVE|STGM_READWRITE, NULL, 0, &storage); if (FAILED(result)) { @@ -1345,134 +1351,63 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) } -/*************************************************************************** - * Reconcile_Players -- Reconciles loaded data with the 'Players' vector * - * * - * This function is for supporting loading a saved multiplayer game. * - * When the game is loaded, we have to figure out which house goes with * - * which entry in the Players vector. We also have to figure out if * - * everyone who was originally in the game is still with us, and if not, * - * turn their stuff over to the computer. * - * * - * So, this function does the following: * - * - For every name in 'Players', makes sure that name is in the House * - * array; if not, it's a fatal error. * - * - For every human-controlled house, makes sure there's a player * - * with that name; if not, it turns that house over to the computer. * - * - Fills in the Player's house ID * - * * - * This assumes that each player MUST keep their name the same as it was * - * when the game was saved! It's also assumed that the network * - * connections have not been formed yet, since Player[i]->Player.ID will * - * be invalid until this routine has been called. * - * * - * INPUT: * - * none. * - * * - * OUTPUT: * - * true = OK, false = error * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 09/29/1995 BRR : Created. * - *=========================================================================*/ -static int Reconcile_Players(void) +/// +/// Gives each seated player the restored house carrying its name, so the connections formed +/// afterwards reach the right houses. +/// +/// bool; Do the seats and the saved houses agree? +bool Reconcile_Players(void) { - #if 0 - int i; - int found; - HousesType house; - HouseClass * housep; - - /* - ** If there are no players, there's nothing to do. - */ - if (Session.Players.Count()==0) + if (Session.Players.Count() == 0) { return(true); + } - /* - ** Make sure every name we're connected to can be found in a House - */ - for (i = 0; i < Session.Players.Count(); i++) { - found = 0; - for (house = HOUSE_MULTI1; house < HOUSE_MULTI1 + - Session.MaxPlayers; house++) { - - housep = Houses[house]; - if (!housep) { - continue; - } + for (int i = 0; i < Session.Players.Count(); i++) { + HouseClass * found = NULL; - if (!stricmp(Session.Players[i]->Name, housep->IniName)) { - found = 1; + for (int house = 0; house < Houses.Count(); house++) { + if (Houses[house]->IsHuman && stricmp(Session.Players[i]->Name, Houses[house]->IniName) == 0) { + found = Houses[house]; break; } } - if (!found) + + if (found == NULL) { return(false); + } + + Session.Players[i]->Player.ID = found->HeapID; } - // - // Loop through all Houses; if we find a human-owned house that we're - // not connected to, turn it over to the computer. - // - for (house = HOUSE_MULTI1; house < HOUSE_MULTI1 + - Session.MaxPlayers; house++) { - housep = Houses[house]; - if (!housep) { - continue; - } + // The first seat is this machine, and PlayerPtr the house that wrote the save. + if (Houses[Session.Players[0]->Player.ID] != PlayerPtr) { + return(false); + } - // - // Skip this house if it wasn't human to start with. - // + for (int house = 0; house < Houses.Count(); house++) { + HouseClass * housep = Houses[house]; if (!housep->IsHuman) { continue; } - // - // Try to find this name in the Players vector; if it's found, set - // its ID to this house. - // - found = 0; - for (i = 0; i < Session.Players.Count(); i++) { - if (!stricmp(Session.Players[i]->Name, housep->IniName)) { - found = 1; - Session.Players[i]->Player.ID = house; + bool seated = false; + for (int i = 0; i < Session.Players.Count(); i++) { + if (Session.Players[i]->Player.ID == housep->HeapID) { + seated = true; break; } } - /* - ** If this name wasn't found, remove it - */ - if (!found) { - - /* - ** Turn the player's house over to the computer's AI - */ + // A player who did not return leaves their house fighting on under the computer. + if (!seated) { housep->IsHuman = false; housep->IsStarted = true; -// housep->Smartness = IQ_MENSA; housep->IQ = Rule->MaxIQ; - housep->IniName = Text_String(TXT_COMPUTER); - - Session.NumPlayers--; + housep->IniName = Fetch_String(TXT_COMPUTER); } } - // - // If all went well, our Session.NumPlayers value should now equal the value - // from the saved game, minus any players we removed. - // - if (Session.NumPlayers == Session.Players.Count()) { - return(true); - } else { - return(false); - } - #endif + return(true); } diff --git a/code/saveload.h b/code/saveload.h index 67ee5d9..2d6bb8f 100644 --- a/code/saveload.h +++ b/code/saveload.h @@ -23,6 +23,7 @@ int Load_Misc_Values(IStream * stream); int Save_Misc_Values(IStream * stream); bool Get_Savefile_Info(char const * name, SaveVersionInfo * info); bool Load_Game(const char *file_name); +bool Reconcile_Players(void); bool Request_Save_Game(char const * file_name, char const * descr); void Process_Pending_Save_Game(void); void Reset_Multiplayer_Save_State(void); diff --git a/code/scenario.cpp b/code/scenario.cpp index f2b91b7..7c0814f 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -1429,6 +1429,11 @@ char const * Pick_Load_Background_Name(Point2D & pos) player = Session.Players[player]->Player.House; } + // Only two sides have loading art, so any other house is shown the first side's. + if (player < 0 || player > 1) { + player = 0; + } + int choice = (player << 1) + Random_Pick(0, 1); if (VisibleRect.Width == 640) { @@ -1582,8 +1587,8 @@ bool Read_Scenario_INI(CCINIClass const & ini, bool is_mapgen) Clear_Scenario(); if (Session.Type == GAME_NORMAL) { - Scen->Difficulty = (DiffType)Options.Difficulty; - Scen->CDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty); + Scen->Difficulty = Session.CampaignDifficulty; + Scen->CDifficulty = Session.CampaignCDifficulty; Scen->Special.IsFogOfWar = false; Special.IsFogOfWar = false; } else { @@ -2062,6 +2067,29 @@ void Write_Scenario_INI(char const * fname, bool mplayer) } +/// +/// Fetches the node a seat of the match was described by. The player list is held in each +/// machine's own order, so a seat is found by the house it was assigned, not by position. +/// +/// The node describing that seat, or NULL if the match does not hold it. +static NodeNameType * Seated_Node(int seat) +{ + for (int i = 0; i < Session.Players.Count(); i++) { + if (Session.Players[i]->Player.ID == seat) { + return(Session.Players[i]); + } + } + + for (int i = 0; i < Session.Computers.Count(); i++) { + if (Session.Computers[i]->Player.ID == seat) { + return(Session.Computers[i]); + } + } + + return(NULL); +} + + /*********************************************************************************************** * Assign_Houses -- Assigns multiplayer houses to various players * * * @@ -2160,6 +2188,8 @@ void Assign_Houses(void) housep->Assign_Handicap(DIFF_NORMAL); + housep->SpawnWaypoint = player->Player.SpawnChoice; + //..................................................................... // Record where we placed this player //..................................................................... @@ -2172,7 +2202,18 @@ void Assign_Houses(void) // Now assign computer players to the remaining houses. //------------------------------------------------------------------------ for (i = Session.Players.Count(); i < Session.Players.Count() + Session.Options.AIPlayers; i++) { + + /* + * A session source may have seated this computer player itself. What it left + * unnamed the game draws, exactly as it does for a game set up from the menu. + */ + int seatnum = i - Session.Players.Count(); + NodeNameType * seat = seatnum < Session.Computers.Count() ? Session.Computers[seatnum] : NULL; + pref_house = (HousesType)Random_Pick(0, 1); + if (seat != NULL && seat->Player.House != -1) { + pref_house = (HousesType)seat->Player.House; + } // Pick a color for this house; keep looping until we find one. int color = -1; @@ -2182,6 +2223,14 @@ void Assign_Houses(void) break; } } + + /* + * A seated color is taken as written, repeats included, since a cooperative team + * shares one. + */ + if (seat != NULL && seat->Player.Color != -1) { + color = seat->Player.Color; + } color_used[color] = true; /* @@ -2195,7 +2244,7 @@ void Assign_Houses(void) housep->Init_Data(color, pref_house, Session.Options.Credits); housep->Scheme = Session.Color_Index_To_Scheme(color); housep->Initialize_Radar_Color(); - housep->IniName = Fetch_String(TXT_COMPUTER); + housep->IniName = (seat != NULL && seat->Name[0] != '\0') ? seat->Name : Fetch_String(TXT_COMPUTER); if (Session.Type != GAME_NORMAL) { housep->IQ = Rule->MaxIQ; @@ -2205,7 +2254,35 @@ void Assign_Houses(void) if (Session.Players.Count() > 1 && Rule->IsCompEasyBonus && difficulty > DIFF_EASY) { difficulty = (DiffType)(difficulty - 1); } + if (seat != NULL && seat->Player.Handicap >= 0) { + difficulty = (DiffType)seat->Player.Handicap; + } housep->Assign_Handicap(difficulty); + + if (seat != NULL) { + housep->SpawnWaypoint = seat->Player.SpawnChoice; + seat->Player.ID = housep->HeapID; + } + } + + // A seat's mask names other seats, not the houses they became. + int seated = Session.Players.Count() + Session.Computers.Count(); + for (int seatnum = 0; seatnum < seated; seatnum++) { + NodeNameType * node = Seated_Node(seatnum); + if (node == NULL || node->Player.AlliesMask == 0) { + continue; + } + + for (int target = 0; target < seated; target++) { + if (target == seatnum || (node->Player.AlliesMask & (1u << target)) == 0) { + continue; + } + + NodeNameType * other = Seated_Node(target); + if (other != NULL) { + Houses[node->Player.ID]->Make_Ally(Houses[other->Player.ID]); + } + } } HouseClass * neutral_house = new HouseClass(HouseTypes[HouseTypeClass::From_Name("Neutral")]); @@ -2249,18 +2326,66 @@ static void Remove_AI_Players(void) /// -/// Fetches the starting locations available to a multiplayer game. -/// The scenario's own waypoints are preferred, but a map that does not supply enough of -/// them for everyone playing has the shortfall made up with random spots on open ground. +/// Makes up a shortfall of starting locations with open ground, since a map need not declare +/// a start position for everybody playing. +/// +/// The list of starting locations to append to. +/// How many of the locations may actually be started from; raised as +/// spots are appended. +/// How many are needed. +static void Append_Open_Start_Positions(DynamicVectorClass & waypts, int & usable, int wanted) +{ + if (usable >= wanted) { + return; + } + + DebugString("Multiplayer start waypoint deficiency - looking for more start positions\n"); + + while (usable < wanted) { + Cell trycell = Cell(Map.MapRect.X + Random_Pick(10, Map.MapRect.Width - 10), Map.MapRect.Y + 10 + Random_Pick(0, Map.MapRect.Height - 10)); + + trycell = Map.Nearby_Location(trycell, SPEED_TRACK, -1, MZONE_NORMAL, false, Point2D(8, 8)); + if (trycell != CELL_NONE) { + waypts.Add(trycell); + usable++; + DebugString("Random multiplayer start waypoint added at cell %d,%d\n", trycell.X, trycell.Y); + } + } +} + + +/// +/// Fetches the starting locations a multiplayer game may use, making up any shortfall with +/// open ground. Keeping identity numbers each entry by waypoint, undeclared ones left as holes. /// /// Is this one of the maps that shipped with the game? +/// Must an entry's place in the list be its waypoint number? /// Returns with the list of cells that players may be started from. -static DynamicVectorClass Build_Start_Waypoint_List(bool official) +static DynamicVectorClass Build_Start_Waypoint_List(bool official, bool keep_identity) { DynamicVectorClass waypts; + if (keep_identity) { + int usable = 0; + for (int waycount = 0; waycount < MAX_PLAYERS; waycount++) { + bool declared = Scen->Is_Valid_Waypoint(waycount); + waypts.Add(declared ? Scen->Get_Waypoint_Cell(waycount) : CELL_NONE); + if (declared) { + usable++; + } + } + + /* + * The spots making up a shortfall are appended past the numbered ones, so that no + * number comes to mean a place the map never named. + */ + Append_Open_Start_Positions(waypts, usable, Session.Players.Count() + Session.Options.AIPlayers); + + return(waypts); + } + int num_waypts = 0; - for (int i = 0; i < 8; i++) { + for (int i = 0; i < MAX_PLAYERS; i++) { if (Scen->Is_Valid_Waypoint(i)) { num_waypts++; } else { @@ -2276,7 +2401,7 @@ static DynamicVectorClass Build_Start_Waypoint_List(bool official) */ int look_for = std::max(num_waypts, Session.Players.Count()+Session.Options.AIPlayers); if (!official) { - look_for = 8; + look_for = MAX_PLAYERS; } for (int waycount = 0; waycount < look_for; waycount++) { @@ -2286,24 +2411,8 @@ static DynamicVectorClass Build_Start_Waypoint_List(bool official) } } - /* - ** If there are insufficient waypoints to account for all players, then randomly assign - ** starting points until there is enough. - */ - int deficiency = look_for - waypts.Count(); - if (deficiency > 0) { - DebugString("Multiplayer start waypoint deficiency - looking for more start positions\n"); - - while (waypts.Count() < look_for) { - Cell trycell = Cell(Map.MapRect.X + Random_Pick(10, Map.MapRect.Width - 10), Map.MapRect.Y + 10 + Random_Pick(0, Map.MapRect.Height - 10)); - - trycell = Map.Nearby_Location(trycell, SPEED_TRACK, -1, MZONE_NORMAL, false, Point2D(8, 8)); - if (trycell != CELL_NONE) { - waypts.Add(trycell); - DebugString("Random multiplayer start waypoint added at cell %d,%d\n", trycell.X, trycell.Y); - } - } - } + int usable = waypts.Count(); + Append_Open_Start_Positions(waypts, usable, look_for); return(waypts); } @@ -2366,16 +2475,52 @@ static void Create_Units(bool official) int average_cost = total_cost / total_objs; int max_value = unit_count * average_cost; + /* + * A house only asks for a position by number when a session source chose one for it, + * and that is what decides whether the numbers have to keep their identity. + */ + bool choices = false; + for (int index = 0; index < Houses.Count(); index++) { + if (Houses[index] != NULL && Houses[index]->SpawnWaypoint >= 0) { + choices = true; + break; + } + } + /* ** Build a list of the valid waypoints. This normally shouldn't be ** necessary because the scenario level designer should have assigned ** valid locations to the first N waypoints, but just in case, this ** loop verifies that. */ - DynamicVectorClass waypts = Build_Start_Waypoint_List(official); - bool taken[16]; + DynamicVectorClass waypts = Build_Start_Waypoint_List(official, choices); + bool taken[MAX_PLAYERS * 2]; for (int index = 0; index < ARRAY_SIZE(taken); index++) { - taken[index] = false; + taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE; + } + + /* + * A house that named a position holds it before anybody draws, so a house that named + * none cannot take one somebody asked for. Two naming the same position: first keeps it. + */ + int reserved[MAX_PLAYERS * 2]; + for (int index = 0; index < ARRAY_SIZE(reserved); index++) { + reserved[index] = -1; + } + + if (choices) { + for (int index = 0; index < Houses.Count(); index++) { + HouseClass * housep = Houses[index]; + if (housep == NULL || housep->Class->IsMultiplayPassive) { + continue; + } + + int spot = housep->SpawnWaypoint; + if (spot >= 0 && spot < waypts.Count() && !taken[spot]) { + reserved[spot] = index; + taken[spot] = true; + } + } } /* @@ -2425,10 +2570,18 @@ static void Create_Units(bool official) ** one of the valid locations at random. The other houses pick the furthest ** wapoint from the existing houses. */ - if (numtaken == 0) { - int pick = Random_Pick(0, waypts.Count() - 1); + if (choices && hptr->SpawnWaypoint >= 0 && hptr->SpawnWaypoint < waypts.Count() && + reserved[hptr->SpawnWaypoint] == (int)house) { + centroid = waypts[hptr->SpawnWaypoint]; + numtaken++; + } else if (numtaken == 0) { + int pick; + do { + pick = Random_Pick(0, waypts.Count() - 1); + } while (taken[pick]); centroid = waypts[pick]; taken[pick] = true; + hptr->SpawnWaypoint = pick; numtaken++; } else { @@ -2453,7 +2606,7 @@ static void Create_Units(bool official) if (!taken[index]) { for (int trypoint = 0; trypoint < waypts.Count(); trypoint++) { - if (taken[trypoint]) { + if (taken[trypoint] && waypts[trypoint] != CELL_NONE) { score[index] += Distance(waypts[index], waypts[trypoint]); } } @@ -2467,6 +2620,9 @@ static void Create_Units(bool official) int best = 0; int bestvalue = 0; for (int searchindex = 0; searchindex < waypts.Count(); searchindex++) { + if (waypts[searchindex] == CELL_NONE) { + continue; + } if (score[searchindex] > bestvalue || bestvalue == 0) { bestvalue = score[searchindex]; best = searchindex; @@ -2478,6 +2634,7 @@ static void Create_Units(bool official) */ centroid = waypts[best]; taken[best] = true; + hptr->SpawnWaypoint = best; numtaken++; } diff --git a/code/session.cpp b/code/session.cpp index da27427..a266f61 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -66,6 +66,7 @@ #include "rules.h" #include "savestream.h" #include "scenario.h" +#include "spawner.h" #include "special.h" #include "stats.h" #include "xstraw.h" @@ -174,6 +175,8 @@ SessionClass::SessionClass(void) ObiWan = 0; Solo = 0; + CampaignDifficulty = DIFF_NORMAL; + CampaignCDifficulty = DIFF_NORMAL; MasterPlayerID = -1; memset(MasterPlayerName, 0, sizeof(MasterPlayerName)); @@ -272,7 +275,11 @@ SessionClass::~SessionClass(void) void SessionClass::One_Time(void) { //Read_MultiPlayer_Settings(); - Read_Scenario_Descriptions(); + + // A client-launched game names its scenario outright and never shows the map list. + if (!Spawner_Is_Requested()) { + Read_Scenario_Descriptions(); + } UniqueID = Compute_Unique_ID(); DebugString("Session one time init. UniqueID is %08x\n", UniqueID); diff --git a/code/session.h b/code/session.h index a6b2928..613b3cc 100644 --- a/code/session.h +++ b/code/session.h @@ -44,6 +44,8 @@ #include "version.h" #include "win.h" +#include + #include "dialog.hh" #include "diff.hh" @@ -217,6 +219,9 @@ struct NodeNameType { int ProcessTime; // Length of time to process players main loop int Status; // int SquadID; // + int SpawnChoice; // starting waypoint asked for; -1 = the engine picks + int Handicap; // difficulty asked for; -1 = the session default + unsigned AlliesMask; // seats allied with, one bit per seat index } Player; struct { unsigned int LastTime; // last time we heard from this guy @@ -224,6 +229,17 @@ struct NodeNameType { int Color; // chat player's color } Chat; }; + + // A node asks for nothing, leaving the game its own start position and difficulty. + NodeNameType(void) + { + memset(this, 0, sizeof(*this)); + Player.SpawnChoice = -1; + Player.Handicap = -1; + + // Zeroing the node above wipes an address that names nobody in particular. + Address = IPXAddressClass(); + } }; @@ -510,6 +526,13 @@ class SessionClass int ObiWan; // 1 = player can see all int Solo; // 1 = player can play alone + /* + * The pair a campaign mission is played at. It lives here because the scenario's own + * copy is wiped before each mission, while a restart or the next one must keep it. + */ + DiffType CampaignDifficulty; + DiffType CampaignCDifficulty; + /* * If the local player is playing a GDI house, then this flag will be true. A starting * multiplayer scenario takes its side and its speech set from it. @@ -683,6 +706,9 @@ class SessionClass DynamicVectorClass Games; // list of games DynamicVectorClass Players; // list of players DynamicVectorClass Chat; // list of chat nodes + + // The computer players a session source seated, after the humans; the menu leaves it empty. + DynamicVectorClass Computers; int Suspended; /* diff --git a/code/skirmish.cpp b/code/skirmish.cpp index 1f3d5f7..8307389 100644 --- a/code/skirmish.cpp +++ b/code/skirmish.cpp @@ -220,11 +220,7 @@ bool Skirmish_Mode_Dialog(void) { int rc = -1; - Rule->Do_HouseTypes(*RuleINI); - Rule->Do_Sides(*RuleINI); - for (int i = 0; i < HouseTypes.Count(); i++) { - HouseTypes[i]->Read_INI(*RuleINI); - } + Prepare_Side_Roster(); Hide_Mouse(); Draw_Menu_Background(); @@ -417,6 +413,7 @@ BOOL Skirmish_On_WM_INITDIALOG(HWND window, WPARAM wparam, LPARAM lparam) Session.Options.ScenarioIndex = 0; SendDlgItemMessage(window, IDC_SCENARIONAME, WM_SETTEXT, 0, (LPARAM)Session.Options.ScenarioDescription); Clear_Vector(&Session.Players); + Clear_Vector(&Session.Computers); handle = GetDlgItem(window, IDC_SKIRMISH_BASES); if (handle) Button_SetCheck(handle, Session.Options.Bases ? BST_CHECKED : BST_UNCHECKED); diff --git a/code/spawner.cpp b/code/spawner.cpp new file mode 100644 index 0000000..0d7d184 --- /dev/null +++ b/code/spawner.cpp @@ -0,0 +1,544 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "always.h" + +#include "spawner.h" + +#include "spawnerconfig.h" + +#include "addon.h" +#include "campaign.h" +#include "ccfile.h" +#include "ccini.h" +#include "dbgprint.h" +#include "enviro.h" +#include "globals.h" +#include "goptions.h" +#include "houstype.h" +#include "init.h" +#include "ipxmgr.h" +#include "language\language.h" +#include "loaddlg.h" +#include "mplayer.h" +#include "netshare.h" +#include "msgbox.h" +#include "saveload.h" +#include "savever.h" +#include "scenario.h" +#include "session.h" + +#include +#include +#include +#include +#include + + +/* + * A launch is spent once for the life of the process: the client watches for the game to + * exit, so a finished or refused spawn ends it rather than falling into the menu. + */ +static_assert(HOUSE_NAME_MAX == MPLAYER_NAME_MAX, + "a seat is judged and ordered by the name the session carries"); + +static bool SpawnRequested = false; +static bool SpawnConsumed = false; +static SpawnerConfigClass SpawnConfig; + + +/// +/// Refuses the launch, telling the player why and leaving the reason in the log. +/// +/// A printf style description of the fault. +/// false, so a caller can refuse and return in one statement. +static bool Spawner_Refuse(char const * fault, ...) +{ + char buffer[256]; + + va_list args; + va_start(args, fault); + std::vsnprintf(buffer, sizeof(buffer), fault, args); + va_end(args); + + DebugString("[Spawner] Refusing to launch: %s\n", buffer); + WWMessageBox().Process(buffer, TXT_OK); + + return(false); +} + + +/// +/// Folds a seat's alliance list into the bitfield the houses are allied by. +/// +/// One bit set per seat this one is allied with. +static unsigned Spawner_Allies_Mask(SpawnerConfigClass::SlotType const & seat) +{ + unsigned mask = 0; + + for (int ally : seat.Alliances) { + if (ally >= 0 && ally < SpawnerConfigClass::SLOT_COUNT) { + mask |= 1u << ally; + } + } + + return(mask); +} + + +/// +/// The difficulty one seat is played at, saying so when it is not the one asked for. +/// +/// The difficulty to play the seat at, or -1 for the session default. +static int Spawner_Seat_Handicap(int index, int asked) +{ + int played = SpawnerConfigClass::Playable_Handicap(asked); + + if (played != asked) { + DebugString("[Spawner] Seat %d asked for difficulty %d and is played at %d.\n", + index + 1, asked, played); + } + + return(played); +} + + +/// +/// Tells the session who is playing at this machine. +/// +static void Spawner_Seat_Local(void) +{ + SpawnerConfigClass::SlotType const & local = SpawnConfig.Slots[SpawnConfig.LocalSlot]; + + std::snprintf(Session.Handle, sizeof(Session.Handle), "%s", + local.Name.empty() ? "Player" : local.Name.c_str()); + Session.House = local.Country; + Session.ColorIdx = local.Color; + Session.PrefColor = Session.ColorIdx; +} + + +/// +/// Puts one person's seat into the list the houses are created from. +/// +static void Spawner_Seat_Human(int index) +{ + SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; + + NodeNameType * node = new NodeNameType; + std::snprintf(node->Name, sizeof(node->Name), "%s", + seat.Name.empty() ? "Player" : seat.Name.c_str()); + node->Player.House = seat.Country; + node->Player.Color = seat.Color; + node->Player.ProcessTime = -1; + node->Player.SpawnChoice = seat.StartingPosition; + node->Player.AlliesMask = Spawner_Allies_Mask(seat); + + // Through a tunnel a machine is named by its tunnel number, carried where a port would go. + if (SpawnConfig.TunnelPort != 0) { + node->Address.Set_Address(0, htons((unsigned short)seat.Port)); + } else if (seat.Port > 0) { + node->Address.Set_Address(inet_addr(seat.Address.c_str()), htons((unsigned short)seat.Port)); + } + + Session.Players.Add(node); +} + + +/// +/// Puts the people playing into the list the houses are created from, this machine's own +/// seat first, since the game takes the first entry to be the local player. +/// +static void Spawner_Seat_Humans(void) +{ + Spawner_Seat_Human(SpawnConfig.LocalSlot); + + for (int index = 0; index < SpawnConfig.HumanCount; index++) { + if (index != SpawnConfig.LocalSlot) { + Spawner_Seat_Human(index); + } + } + + Session.NumPlayers = SpawnConfig.HumanCount; +} + + +/// +/// Puts the computer players the client seated into the list the houses are created from. +/// +static void Spawner_Seat_Computers(void) +{ + // A seat played at the easiest of the rules' tables is the hardest opponent to beat. + static char const * const _ai_names[DIFF_COUNT] = { "Hard AI", "Medium AI", "Easy AI" }; + + for (int index = SpawnConfig.HumanCount; index < SpawnerConfigClass::SLOT_COUNT; index++) { + SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; + if (seat.Occupancy != SpawnerConfigClass::OccupancyType::Computer) { + continue; + } + + NodeNameType * node = new NodeNameType; + node->Player.House = seat.Country; + node->Player.Color = seat.Color; + node->Player.Handicap = Spawner_Seat_Handicap(index, seat.Handicap); + node->Player.SpawnChoice = seat.StartingPosition; + node->Player.AlliesMask = Spawner_Allies_Mask(seat); + + // The session's difficulty runs the other way from a seat's, so it is turned around here. + if (SpawnConfig.AINamesByDifficulty) { + int played = node->Player.Handicap >= 0 + ? node->Player.Handicap + : (DIFF_COUNT - 1 - SpawnConfig.AIDifficulty); + std::snprintf(node->Name, sizeof(node->Name), "%s", + _ai_names[std::clamp(played, 0, DIFF_COUNT - 1)]); + } + + Session.Computers.Add(node); + } +} + + +/// +/// Tells the session what every house plays under, in the launch file's own order. +/// +static void Spawner_Bind_Options(void) +{ + Session.Options.Bases = SpawnConfig.Bases; + Session.Options.Credits = SpawnConfig.Credits; + Session.Options.BridgeDestruction = SpawnConfig.BridgeDestroy; + Session.Options.Goodies = SpawnConfig.Crates; + Session.Options.ShortGame = SpawnConfig.ShortGame; + Session.Options.GameSpeed = SpawnConfig.GameSpeed; + Session.Options.CrapEngineers = SpawnConfig.MultiEngineer; + Session.Options.UnitCount = SpawnConfig.UnitCount; + Session.Options.AIPlayers = SpawnConfig.AIPlayers; + Session.Options.AIDifficulty = (DiffType)SpawnConfig.AIDifficulty; + Session.Options.AlliesAllowed = SpawnConfig.AlliesAllowed; + Session.Options.FogOfWar = SpawnConfig.FogOfWar; + Session.Options.MCVRedeploy = SpawnConfig.MCVRedeploy; + + /* + * Recorded for every kind of launch, but only a match against other machines commits it + * to the simulation; a skirmish has never played by it, from a file or from the menu. + */ + Session.Options.HarvTruce = SpawnConfig.HarvesterTruce; + + // Game options too, though the session keeps these two outside its own block. + Options.GameSpeed = SpawnConfig.GameSpeed; + BuildLevel = SpawnConfig.TechLevel; + + // Init_Random settles this for a game played alone, and draws its own when it is zero. + CustomSeed = SpawnConfig.Seed; + + /* + * Read, not honored. Every field the reader carries is bound above, consumed to refuse a + * launch, or named here, so a new field forces a decision rather than a silent omission. + * A key this game will never take is not read at all, so everything below awaits the + * feature that will honor it. + * + * MapName - shown while loading; bound with the scenario below. + * IsCampaign, LoadSaveGame, + * SaveGameName - read to decide the kind of launch and name the save. + * Slots[].IsSpectator - read to refuse a launch. + * IsHost - which machine hosts matters once one can leave. + * Tournament, GameID, + * WriteStatistics - naming a match and reporting how it went. + * AutoSaveInterval, + * NextCampaignAutoSave, + * NextSkirmishAutoSave - saving on a schedule. + * BuildOffAlly, AttackNeutralUnits, + * ScrapMetal, AutoSurrender, + * ContinueWithoutHumans - options the game has no setting of its own for yet. + * CoachMode - watching and advising rather than playing. + * QuickMatch, SkipScoreScreen, + * PlayMoviesInMultiplayer, + * CustomLoadScreen, + * CustomLoadScreenX, + * CustomLoadScreenY, + * DifficultyName - what a player is shown around the match. + */ +} + + +/// +/// Tells the session which scenario is being played, in place of the menu's map list. +/// +static void Spawner_Bind_Scenario(void) +{ + std::snprintf(Scen->ScenarioName, sizeof(Scen->ScenarioName), "%s", SpawnConfig.ScenarioName.c_str()); + std::snprintf(Session.ScenarioFileName, sizeof(Session.ScenarioFileName), "%s", SpawnConfig.ScenarioName.c_str()); + std::snprintf(Session.Options.ScenarioDescription, sizeof(Session.Options.ScenarioDescription), + "%s", SpawnConfig.MapName.c_str()); + + Session.Options.ScenarioIndex = -1; + Session.ScenarioFileLength = CCFileClass(Scen->ScenarioName).Size(); + Session.ScenarioIsOfficial = false; + Session.ScenarioDigest[0] = '\0'; +} + + +/// +/// Opens the network a game against other machines is played over, through the tunnel the +/// file names or straight to the addresses its seats carry. +/// +/// bool; Is the network ready to carry the match? +static bool Spawner_Wire_Network(void) +{ + if (SpawnConfig.TunnelPort != 0) { + Ipx.Configure_Tunnel(htons((unsigned short)SpawnConfig.TunnelId), + inet_addr(SpawnConfig.TunnelAddress.c_str()), htons((unsigned short)SpawnConfig.TunnelPort)); + } else { + Ipx.Configure_Direct_Peers((unsigned short)SpawnConfig.ListenPort); + } + + // The local seat leads the list, so everybody after it is another machine. + for (int index = 1; index < Session.Players.Count(); index++) { + Ipx.Add_Peer(Session.Players[index]->Address); + } + + if (!Ipx.Init()) { + return(Spawner_Refuse("The network could not be opened.")); + } + + return(true); +} + + +/// +/// Resumes the saved game a launch file names. The save carries the game and its houses; a +/// match against other machines takes its seats from the file, at the addresses they answer +/// on now. +/// +/// Set when the save loads, so the caller starts no scenario. +/// bool; Is the saved game running? +static bool Spawner_Resume(bool & gameloaded) +{ + if (SpawnConfig.SaveGameName.empty()) { + return(Spawner_Refuse("The file asks to resume a saved game without naming one.")); + } + + SaveVersionInfo info; + if (!Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)) { + return(Spawner_Refuse("The saved game %s is missing or unreadable.", SpawnConfig.SaveGameName.c_str())); + } + + if (info.Get_Internal_Version() != ExpectedGameVersion) { + return(Spawner_Refuse("The saved game was made by another version of the game.")); + } + + // No client launches a game the menu arranged, so no file describes such a match. + GameType type = (GameType)info.Get_Game_Type(); + if (type == GAME_IPX) { + return(Spawner_Refuse("Resuming a game arranged over the local network is not supported.")); + } + + /* + * The save restores the houses while the file seats the same people afresh, so the + * network is open before the load and the queue shakes hands at the resumed frame. + */ + if (type == GAME_INTERNET) { + std::string fault; + if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) { + return(Spawner_Refuse("%s", fault.c_str())); + } + + Clear_Vector(&Session.Players); + Clear_Vector(&Session.Computers); + + Spawner_Seat_Local(); + Spawner_Seat_Humans(); + + if (!Spawner_Wire_Network()) { + return(false); + } + + Session.LoadGame = true; + } + + if (!LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())) { + return(Spawner_Refuse("The saved game %s could not be loaded.", SpawnConfig.SaveGameName.c_str())); + } + + if (type == GAME_INTERNET && !Reconcile_Players()) { + return(Spawner_Refuse("The saved game and the file do not agree on who is playing.")); + } + + /* + * A save carries the options it was played under, but the speed is the player's own and + * is taken from the file that asked for the resume. + */ + Options.GameSpeed = SpawnConfig.GameSpeed; + + gameloaded = true; + + return(true); +} + + +/// +/// Assembles the campaign mission a launch asks for: the mission, the handicap pair, and the +/// scenario flags a client carries over from an earlier mission. +/// +/// bool; Can the campaign the file describes be played? +static bool Spawner_Setup_Campaign(void) +{ + if (SpawnConfig.CampaignDifficulty < 0 || SpawnConfig.CampaignDifficulty >= DIFF_COUNT || + SpawnConfig.CampaignCDifficulty < 0 || SpawnConfig.CampaignCDifficulty >= DIFF_COUNT) { + return(Spawner_Refuse("A campaign is played at difficulty 0, 1 or 2, and the file says %d and %d.", + SpawnConfig.CampaignDifficulty, SpawnConfig.CampaignCDifficulty)); + } + + if (SpawnConfig.CampaignID < -1 || SpawnConfig.CampaignID >= Campaigns.Count()) { + return(Spawner_Refuse("The file names campaign %d, and there are %d.", + SpawnConfig.CampaignID, Campaigns.Count())); + } + + Session.Type = GAME_NORMAL; + Options.GameSpeed = SpawnConfig.GameSpeed; + Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty; + Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty; + Scen->Campaign = (CampaignType)SpawnConfig.CampaignID; + + // A fresh launch carries nothing over from an earlier mission, so the file's flags stand in. + new (&Environment) EnvironmentClass; + for (int index = 0; index < SpawnerConfigClass::GLOBAL_FLAG_COUNT; index++) { + Environment.Globals[index] = SpawnConfig.GlobalFlags[index]; + } + + std::snprintf(Scen->ScenarioName, sizeof(Scen->ScenarioName), "%s", SpawnConfig.ScenarioName.c_str()); + + return(true); +} + + +/// +/// Assembles the session a launch asks for, in place of what a setup dialog commits. +/// +static void Spawner_Setup_Session(void) +{ + Session.Type = SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Multiplayer + ? GAME_INTERNET : GAME_SKIRMISH; + + // Every machine must draw alike, and no lobby is there to hand a seed around. + if (Session.Type == GAME_INTERNET) { + Seed = SpawnConfig.Seed; + } + + Clear_Vector(&Session.Players); + Clear_Vector(&Session.Computers); + + Spawner_Bind_Options(); + + if (Session.Type == GAME_INTERNET) { + Commit_Session_Specials(); + } + + Spawner_Seat_Local(); + Spawner_Seat_Humans(); + Spawner_Seat_Computers(); + Spawner_Bind_Scenario(); +} + + +/// +/// Notes that a client asked the game to launch what its file describes. +/// +void Spawner_Request(void) +{ + SpawnRequested = true; +} + + +/// +/// Did a client ask the game to launch what its file describes? +/// +bool Spawner_Is_Requested(void) +{ + return(SpawnRequested); +} + + +/// +/// Is the game being played the one a launch file described? A path that must leave a +/// client's choices alone asks this rather than the command line. +/// +bool Spawner_Is_Active(void) +{ + return(SpawnConsumed); +} + + +/// +/// Reads the launch file and assembles the game it describes, in place of the menu. Answers +/// false once that game has ended, so the process leaves instead of showing one. +/// +/// Set when the launch resumed a saved game. +/// bool; Is a game ready to start? +bool Spawner_Prepare(bool & gameloaded) +{ + if (SpawnConsumed) { + return(false); + } + + CCFileClass file("SPAWN.INI"); + if (!file.Is_Available()) { + return(Spawner_Refuse("SPAWN.INI is missing, and it says what to launch.")); + } + + CCINIClass ini; + ini.Load(file, false); + SpawnConfig.Read_INI(ini); + + SpawnConsumed = true; + + /* + * Every kind of launch is played at this speed, so it is judged before they part. + */ + if (SpawnConfig.GameSpeed < 0 || SpawnConfig.GameSpeed >= OptionsClass::MAX_SPEED_SETTING) { + return(Spawner_Refuse("The file asks for game speed %d, and the game has 0 through %d.", + SpawnConfig.GameSpeed, OptionsClass::MAX_SPEED_SETTING - 1)); + } + + // A seat names its country by the rules' own numbering, so the roster is read first. + Prepare_Side_Roster(); + + if (SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Resume) { + return(Spawner_Resume(gameloaded)); + } + + Disable_Addon(ADDON_ANY); + if (SpawnConfig.Firestorm) { + Enable_Addon(ADDON_FIRESTORM); + Set_Required_Addon(ADDON_FIRESTORM); + } + + if (SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Campaign) { + if (!Spawner_Setup_Campaign()) { + return(false); + } + } else { + std::string fault; + if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) { + return(Spawner_Refuse("%s", fault.c_str())); + } + + Spawner_Setup_Session(); + } + + DebugString("[Spawner] Launching %s with session identity %08x.\n", + Scen->ScenarioName, SpawnConfig.Session_Identity_CRC()); + + // The network comes last, once the session it will carry is assembled whole. + if (Session.Type == GAME_INTERNET && !Spawner_Wire_Network()) { + return(false); + } + + return(true); +} diff --git a/code/spawner.h b/code/spawner.h new file mode 100644 index 0000000..0dc9bcc --- /dev/null +++ b/code/spawner.h @@ -0,0 +1,17 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + + +void Spawner_Request(void); +bool Spawner_Is_Requested(void); +bool Spawner_Is_Active(void); +bool Spawner_Prepare(bool & gameloaded); diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp new file mode 100644 index 0000000..213a319 --- /dev/null +++ b/code/spawnerconfig.cpp @@ -0,0 +1,514 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "spawnerconfig.h" + +#include "crc.h" +#include "diff.hh" +#include "ini.h" + +#include +#include +#include +#include +#include +#include + + +namespace { + +/* + * The section a launch file keeps the match's settings in. It doubles as the section the + * machine reading the file describes itself in, so the first seat is read from here. + */ +char const * const SETTINGS = "Settings"; + + +/// +/// Reads a string entry. +/// +/// The value written, or the fallback. +std::string Read_Text(INIClass const & ini, char const * section, char const * entry, std::string const & fallback) +{ + char buffer[512]; + if (ini.Get_String(section, entry, "", buffer, sizeof(buffer)) == 0) { + return(fallback); + } + return(buffer); +} + + +/// +/// Reads one of the eight numbered entries a section names its seats by. +/// +/// The value written for that seat. +int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fallback) +{ + std::string entry = "Multi" + std::to_string(slot + 1); + return(ini.Get_Int(section, entry.c_str(), fallback)); +} + + +/// +/// Reads a dotted address, so that a seat naming an unreachable machine is refused where +/// every other fault is. The game's own resolver is not reachable from here. +/// +/// bool; Is this four numbers between 0 and 255? +bool Is_Address(std::string const & text) +{ + unsigned quad[4] = {}; + char tail = '\0'; + + if (std::sscanf(text.c_str(), "%u.%u.%u.%u%c", &quad[0], &quad[1], &quad[2], &quad[3], &tail) != 4) { + return(false); + } + + for (unsigned part : quad) { + if (part > 255) { + return(false); + } + } + + return(quad[0] != 0 || quad[1] != 0 || quad[2] != 0 || quad[3] != 0); +} + + +/// +/// Names the fault that refuses a launch. +/// +/// A printf style description of the fault. +/// false, so a caller can name a fault and refuse in one statement. +bool Fault(std::string & fault, char const * format, ...) +{ + char buffer[256]; + + va_list args; + va_start(args, format); + std::vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + fault = buffer; + return(false); +} + +} + + +/// +/// Reads the match's seats. A seat is human because the file wrote a section for it, and the +/// seats are then sorted into the order their houses will be created in, because everything +/// naming a seat by position afterwards means that order. +/// +void SpawnerConfigClass::Read_Slots(INIClass const & ini) +{ + std::array staging; + + for (int index = 0; index < SLOT_COUNT; index++) { + std::string section = index == 0 ? SETTINGS : "Other" + std::to_string(index); + + SlotType & slot = staging[index]; + if (ini.Section_Present(section.c_str())) { + slot.Occupancy = OccupancyType::Human; + // A seat is judged and ordered by the name the game keeps, as every machine is. + slot.Name = Read_Text(ini, section.c_str(), "Name", "").substr(0, HOUSE_NAME_MAX - 1); + slot.Color = ini.Get_Int(section.c_str(), "Color", -1); + slot.Country = ini.Get_Int(section.c_str(), "Side", -1); + slot.Address = Read_Text(ini, section.c_str(), "Ip", slot.Address); + slot.Port = ini.Get_Int(section.c_str(), "Port", -1); + } else { + slot.Color = Read_Slot_Int(ini, "HouseColors", index, -1); + slot.Country = Read_Slot_Int(ini, "HouseCountries", index, -1); + slot.Handicap = Read_Slot_Int(ini, "HouseHandicaps", index, -1); + } + } + + /* + * Sorting by color makes a seat's index the house it becomes. Every machine writes its + * own file with itself first, so a name breaks a color tie rather than file order. + */ + std::vector humans; + std::vector rest; + for (int index = 0; index < SLOT_COUNT; index++) { + (staging[index].Occupancy == OccupancyType::Human ? humans : rest).push_back(index); + } + std::stable_sort(humans.begin(), humans.end(), [&staging](int left, int right) { + if (staging[left].Color != staging[right].Color) { + return(staging[left].Color < staging[right].Color); + } + return(_stricmp(staging[left].Name.c_str(), staging[right].Name.c_str()) < 0); + }); + + HumanCount = (int)humans.size(); + LocalSlot = 0; + + int filled = 0; + for (int index : humans) { + if (index == 0) { + LocalSlot = filled; + } + Slots[filled++] = staging[index]; + } + + /* + * What the file wrote for a seat no section claimed describes a computer player, and the + * options say how many of those are playing. + */ + for (int index : rest) { + SlotType & slot = Slots[filled]; + slot = staging[index]; + slot.Occupancy = (filled - HumanCount) < AIPlayers ? OccupancyType::Computer : OccupancyType::Empty; + filled++; + } + + /* + * These name their seats by the sorted order, so they are read once the sorting is done. + */ + static char const * const _ordinals[SLOT_COUNT] = { + "HouseAllyOne", "HouseAllyTwo", "HouseAllyThree", "HouseAllyFour", + "HouseAllyFive", "HouseAllySix", "HouseAllySeven", "HouseAllyEight" + }; + + for (int index = 0; index < SLOT_COUNT; index++) { + SlotType & slot = Slots[index]; + + std::string entry = "Multi" + std::to_string(index + 1); + slot.IsSpectator = ini.Get_Bool("IsSpectator", entry.c_str(), false); + slot.StartingPosition = Read_Slot_Int(ini, "SpawnLocations", index, -1); + + /* + * A start position the map cannot hold is one the game picks instead, which is what + * a file asking for no particular position already means. + */ + if (slot.StartingPosition < -1 || slot.StartingPosition >= SLOT_COUNT) { + slot.StartingPosition = -1; + } + + std::string section = "Multi" + std::to_string(index + 1) + "_Alliances"; + if (!ini.Section_Present(section.c_str())) { + continue; + } + + for (int ally = 0; ally < SLOT_COUNT; ally++) { + slot.Alliances[ally] = ini.Get_Int(section.c_str(), _ordinals[ally], -1); + } + } +} + + +/// +/// What kind of game this file asks for. Resuming a saved game answers by itself, since the +/// save carries the type, the options and the houses. +/// +SpawnerConfigClass::LaunchType SpawnerConfigClass::Launch_Type(void) const +{ + if (LoadSaveGame) { + return(LaunchType::Resume); + } + if (IsCampaign) { + return(LaunchType::Campaign); + } + if (HumanCount > 1) { + return(LaunchType::Multiplayer); + } + return(LaunchType::Skirmish); +} + + +/// +/// The identity of the match this file asks for. It gathers every value the course of the +/// match depends upon and nothing merely shown, so two machines handed the same match agree. +/// The version leads: one file under two readings is not one match. +/// +int SpawnerConfigClass::Session_Identity_CRC(void) const +{ + CRCEngine crc; + + crc(SCHEMA_VERSION); + + crc(ScenarioName.c_str()); + crc(IsCampaign); + crc(CampaignID); + crc(CampaignDifficulty); + crc(CampaignCDifficulty); + crc(LoadSaveGame); + crc(SaveGameName.c_str()); + + crc(Bases); + crc(Credits); + crc(BridgeDestroy); + crc(Crates); + crc(ShortGame); + crc(BuildOffAlly); + crc(GameSpeed); + crc(MultiEngineer); + crc(UnitCount); + crc(AIPlayers); + crc(AIDifficulty); + crc(AlliesAllowed); + crc(HarvesterTruce); + crc(FogOfWar); + crc(MCVRedeploy); + crc(Seed); + crc(TechLevel); + crc(Firestorm); + crc(AttackNeutralUnits); + crc(ScrapMetal); + + for (bool flag : GlobalFlags) { + crc(flag); + } + + for (SlotType const & slot : Slots) { + crc(static_cast(slot.Occupancy)); + crc(slot.Color); + crc(slot.Country); + crc(slot.Handicap); + crc(slot.IsSpectator); + crc(slot.StartingPosition); + + for (int ally : slot.Alliances) { + crc(ally); + } + } + + return(crc()); +} + + +/// +/// The difficulty a seat is played at. A client may ask for an easier opponent than the game +/// holds, and any easier request comes to the easiest opponent it has. +/// +/// The difficulty to play the seat at, or -1 for the session default. +int SpawnerConfigClass::Playable_Handicap(int asked) +{ + if (asked < 0) { + return(-1); + } + /* + * A table makes the opposite of what it is named: a seat played at the hardest of them is + * the easiest opponent, and that is what a request for an easier one comes to. + */ + if (asked > DIFF_HARD) { + return(DIFF_HARD); + } + return(asked); +} + + +/// +/// Judges whether this reading describes a game that can be played. The countries and colors +/// are handed in because they are the rules', settled only once the game has loaded them. +/// +/// Where to leave the sentence describing the first fault found. +/// bool; Can the game this file describes be played? +bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const +{ + /* + * A resumed match against other machines is seated from the file like any other, so it + * is held to the same rules; its kind is the save's rather than the file's. + */ + LaunchType kind = Launch_Type(); + bool multiplayer = kind == LaunchType::Multiplayer || + (kind == LaunchType::Resume && HumanCount > 1); + + if (kind != LaunchType::Campaign && HumanCount == 0) { + return(Fault(fault, "The file seats nobody at this machine.")); + } + + if (AIDifficulty < 0 || AIDifficulty >= DIFF_COUNT) { + return(Fault(fault, "The file plays the computer at difficulty %d, and there are %d.", + AIDifficulty, DIFF_COUNT)); + } + + int free_seats = SLOT_COUNT - HumanCount; + if (AIPlayers < 0 || AIPlayers > free_seats) { + return(Fault(fault, "The file asks for %d computer players, and %d seats are left.", + AIPlayers, free_seats)); + } + + for (int index = 0; index < SLOT_COUNT; index++) { + SlotType const & slot = Slots[index]; + if (slot.Occupancy == OccupancyType::Empty) { + continue; + } + + bool human = slot.Occupancy == OccupancyType::Human; + + // A computer seat may leave its country and color to the game; a person's seat names both. + if ((human || slot.Country != -1) && (slot.Country < 0 || slot.Country >= countries)) { + return(Fault(fault, "Seat %d is given country %d, and there are %d to choose from.", + index + 1, slot.Country, countries)); + } + + // A computer seat may share a color; only a color with no scheme refuses outright. + if ((human || slot.Color != -1) && (slot.Color < 0 || slot.Color >= colors)) { + return(Fault(fault, "Seat %d is given color %d, and there are %d to choose from.", + index + 1, slot.Color, colors)); + } + + if (slot.Handicap < -1 || slot.Handicap > 6) { + return(Fault(fault, "Seat %d is given difficulty %d, which names none.", + index + 1, slot.Handicap)); + } + + for (int ally : slot.Alliances) { + if (ally < -1 || ally >= SLOT_COUNT || + (ally >= 0 && Slots[ally].Occupancy == OccupancyType::Empty)) { + return(Fault(fault, "Seat %d is allied to seat %d, which the match does not hold.", + index + 1, ally + 1)); + } + } + + if (slot.IsSpectator) { + return(Fault(fault, "Seat %d watches rather than plays, which this game cannot yet do.", + index + 1)); + } + + /* + * The seats are ordered by color, and the client keys what it writes for each of them + * by an order of its own that no other machine can rebuild. Two people of one color + * would therefore take each other's start position and alliances, so a match against + * other machines gives every person a color and a name of their own. + */ + if (human && multiplayer) { + if (slot.Name.empty()) { + return(Fault(fault, "Seat %d is played by somebody the file does not name.", index + 1)); + } + + for (int other = 0; other < index; other++) { + if (Slots[other].Occupancy != OccupancyType::Human) { + continue; + } + + if (_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0) { + return(Fault(fault, "Seats %d and %d are both played by %s.", + other + 1, index + 1, slot.Name.c_str())); + } + + if (Slots[other].Color == slot.Color) { + return(Fault(fault, "Seats %d and %d are both given color %d.", + other + 1, index + 1, slot.Color)); + } + } + + /* + * Through a tunnel a machine is named by the number carried where its port would + * go, so every seat but this one needs that number either way. + */ + if (index != LocalSlot) { + if (slot.Port < 1 || slot.Port > 65535) { + return(Fault(fault, "Seat %d is reached on port %d, which names no machine.", + index + 1, slot.Port)); + } + + if (TunnelPort == 0 && !Is_Address(slot.Address)) { + return(Fault(fault, "Seat %d is reached at %s, which names no machine.", + index + 1, slot.Address.c_str())); + } + } + } + } + + return(true); +} + + +/// +/// Reads what the CnCNet client asked the game to launch. Reading cannot fail: an unwritten +/// key has a settled meaning, a nonsense value keeps it, and an unknown key is passed over. +/// +void SpawnerConfigClass::Read_INI(INIClass const & ini) +{ + IsCampaign = ini.Get_Bool(SETTINGS, "IsSinglePlayer", IsCampaign); + IsHost = ini.Get_Bool(SETTINGS, "Host", IsHost); + CampaignID = ini.Get_Int(SETTINGS, "CampaignID", CampaignID); + Tournament = ini.Get_Int(SETTINGS, "Tournament", Tournament); + GameID = ini.Get_Int(SETTINGS, "GameID", GameID); + + ScenarioName = Read_Text(ini, SETTINGS, "Scenario", ScenarioName); + MapName = Read_Text(ini, SETTINGS, "UIMapName", MapName); + + LoadSaveGame = ini.Get_Bool(SETTINGS, "LoadSaveGame", LoadSaveGame); + + /* + * A saved game is opened by name in the game's own folder, so a name written with a + * path is reduced to its last element. + */ + SaveGameName = std::filesystem::path(Read_Text(ini, SETTINGS, "SaveGameName", SaveGameName)).filename().string(); + + AutoSaveInterval = ini.Get_Int(SETTINGS, "AutoSaveGame", AutoSaveInterval); + + /* + * The client counts its automatic saves from one, while the game numbers them from zero. + */ + NextCampaignAutoSave = ini.Get_Int(SETTINGS, "NextSPAutoSaveId", 1) - 1; + NextSkirmishAutoSave = ini.Get_Int(SETTINGS, "NextSkirmishAutoSaveId", 1) - 1; + + Bases = ini.Get_Bool(SETTINGS, "Bases", Bases); + Credits = ini.Get_Int(SETTINGS, "Credits", Credits); + BridgeDestroy = ini.Get_Bool(SETTINGS, "BridgeDestroy", BridgeDestroy); + Crates = ini.Get_Bool(SETTINGS, "Crates", Crates); + ShortGame = ini.Get_Bool(SETTINGS, "ShortGame", ShortGame); + BuildOffAlly = ini.Get_Bool(SETTINGS, "BuildOffAlly", BuildOffAlly); + GameSpeed = ini.Get_Int(SETTINGS, "GameSpeed", GameSpeed); + MultiEngineer = ini.Get_Bool(SETTINGS, "MultiEngineer", MultiEngineer); + UnitCount = ini.Get_Int(SETTINGS, "UnitCount", UnitCount); + AIPlayers = ini.Get_Int(SETTINGS, "AIPlayers", AIPlayers); + AIDifficulty = ini.Get_Int(SETTINGS, "AIDifficulty", AIDifficulty); + AlliesAllowed = ini.Get_Bool(SETTINGS, "AlliesAllowed", AlliesAllowed); + HarvesterTruce = ini.Get_Bool(SETTINGS, "HarvesterTruce", HarvesterTruce); + FogOfWar = ini.Get_Bool(SETTINGS, "FogOfWar", FogOfWar); + MCVRedeploy = ini.Get_Bool(SETTINGS, "MCVRedeploy", MCVRedeploy); + Seed = ini.Get_Int(SETTINGS, "Seed", Seed); + TechLevel = ini.Get_Int(SETTINGS, "TechLevel", TechLevel); + Firestorm = ini.Get_Bool(SETTINGS, "Firestorm", Firestorm); + CampaignDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeHuman", CampaignDifficulty); + CampaignCDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeComputer", CampaignCDifficulty); + + /* + * One key carries the port twice: a machine listens on it and a tunnel names the machine + * by it. Absent, a tunnel has no name while the game still has a port to listen on. + */ + TunnelId = ini.Get_Int(SETTINGS, "Port", TunnelId); + ListenPort = ini.Get_Int(SETTINGS, "Port", ListenPort); + TunnelAddress = Read_Text(ini, "Tunnel", "Ip", TunnelAddress); + TunnelPort = ini.Get_Int("Tunnel", "Port", TunnelPort); + + QuickMatch = ini.Get_Bool(SETTINGS, "QuickMatch", QuickMatch); + SkipScoreScreen = ini.Get_Bool(SETTINGS, "SkipScoreScreen", SkipScoreScreen); + WriteStatistics = ini.Get_Bool(SETTINGS, "WriteStatistics", WriteStatistics); + AINamesByDifficulty = ini.Get_Bool(SETTINGS, "DifficultyBasedAINames", AINamesByDifficulty); + CoachMode = ini.Get_Bool(SETTINGS, "CoachMode", CoachMode); + AutoSurrender = ini.Get_Bool(SETTINGS, "AutoSurrender", AutoSurrender); + AttackNeutralUnits = ini.Get_Bool(SETTINGS, "AttackNeutralUnits", AttackNeutralUnits); + ScrapMetal = ini.Get_Bool(SETTINGS, "ScrapMetal", ScrapMetal); + ContinueWithoutHumans = ini.Get_Bool(SETTINGS, "ContinueWithoutHumans", ContinueWithoutHumans); + PlayMoviesInMultiplayer = ini.Get_Bool(SETTINGS, "PlayMoviesInMultiplayer", PlayMoviesInMultiplayer); + CustomLoadScreen = Read_Text(ini, SETTINGS, "CustomLoadScreen", CustomLoadScreen); + DifficultyName = Read_Text(ini, SETTINGS, "DifficultyName", DifficultyName); + + std::string position = Read_Text(ini, SETTINGS, "CustomLoadScreenPos", ""); + if (!position.empty()) { + int x = 0; + int y = 0; + if (std::sscanf(position.c_str(), "%d,%d", &x, &y) == 2) { + CustomLoadScreenX = x; + CustomLoadScreenY = y; + } + } + + for (int index = 0; index < GLOBAL_FLAG_COUNT; index++) { + std::string entry = "GlobalFlag" + std::to_string(index); + GlobalFlags[index] = ini.Get_Bool("GlobalFlags", entry.c_str(), false); + } + + Read_Slots(ini); +} diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h new file mode 100644 index 0000000..b7e69e0 --- /dev/null +++ b/code/spawnerconfig.h @@ -0,0 +1,146 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + +#include "house.hh" + +#include +#include + +class INIClass; + + +/* + * What a client asked the game to launch. The file's spelling, defaults and shape belong to + * the CnCNet client; reading cannot fail, and what was read is judged where a launch begins. + */ +class SpawnerConfigClass +{ + public: + + // Counts changes to what the game makes of a launch file, never the file's own vocabulary. + static constexpr int SCHEMA_VERSION = 1; + + // One seat per house a match may hold, and the fifty scenario flags the engine keeps. + static constexpr int SLOT_COUNT = 8; + static constexpr int GLOBAL_FLAG_COUNT = 50; + + // Resume overrides the rest: a saved game carries its own type, options and houses. + enum class LaunchType { + Skirmish, + Campaign, + Multiplayer, + Resume, + }; + + // A file marks a seat human by writing a section for it; an unwritten one is a computer. + enum class OccupancyType { + Empty, + Human, + Computer, + }; + + /* + * One seat of the match, held in the order the houses are created in, so a seat's index + * is the house it becomes -- which is what alliances and start positions name. + */ + struct SlotType { + OccupancyType Occupancy = OccupancyType::Empty; + std::string Name; + int Color = -1; + int Country = -1; + int Handicap = -1; + bool IsSpectator = false; + int StartingPosition = -1; + std::array Alliances = {-1, -1, -1, -1, -1, -1, -1, -1}; + std::string Address = "0.0.0.0"; + int Port = -1; + }; + + void Read_INI(INIClass const & ini); + LaunchType Launch_Type(void) const; + int Session_Identity_CRC(void) const; + + // The rules' tables are handed in, so a reading can be judged without the game running. + bool Is_Playable(int countries, int colors, std::string & fault) const; + + static int Playable_Handicap(int asked); + + // What kind of game to start. + bool IsCampaign = false; + bool IsHost = false; + int CampaignID = -1; + int Tournament = 0; + int GameID = 0; + + // The scenario and the saved game. + std::string ScenarioName = "spawnmap.ini"; + std::string MapName; + bool LoadSaveGame = false; + std::string SaveGameName; + int AutoSaveInterval = 10800; + int NextCampaignAutoSave = 0; + int NextSkirmishAutoSave = 0; + + // The options every house plays under. + bool Bases = true; + int Credits = 10000; + bool BridgeDestroy = true; + bool Crates = false; + bool ShortGame = false; + bool BuildOffAlly = false; + int GameSpeed = 0; + bool MultiEngineer = false; + int UnitCount = 0; + int AIPlayers = 0; + int AIDifficulty = 1; + bool AlliesAllowed = false; + bool HarvesterTruce = false; + bool FogOfWar = false; + bool MCVRedeploy = true; + int Seed = 0; + int TechLevel = 10; + bool Firestorm = true; + int CampaignDifficulty = 1; + int CampaignCDifficulty = 1; + std::array GlobalFlags = {}; + + // Where the machines reach one another, settled by whatever service arranged the match. + int TunnelId = 0; + int ListenPort = 1234; + std::string TunnelAddress = "0.0.0.0"; + int TunnelPort = 0; + + // What a player is shown. + bool QuickMatch = false; + bool SkipScoreScreen = false; + bool WriteStatistics = false; + bool AINamesByDifficulty = false; + bool CoachMode = false; + bool AutoSurrender = true; + bool AttackNeutralUnits = false; + bool ScrapMetal = false; + bool ContinueWithoutHumans = false; + bool PlayMoviesInMultiplayer = false; + std::string CustomLoadScreen; + int CustomLoadScreenX = 0; + int CustomLoadScreenY = 0; + std::string DifficultyName; + + // The match's seats, and where in them the machine reading the file sits. + std::array Slots; + int HumanCount = 0; + int LocalSlot = 0; + + private: + + void Read_Slots(INIClass const & ini); +}; diff --git a/code/startup.cpp b/code/startup.cpp index 9e76aaa..22199f9 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -115,6 +115,7 @@ #include "shapeset.h" #include "side.h" #include "sidebar.h" +#include "spawner.h" #include "smudge.h" #include "smudtype.h" #include "sun.h" @@ -660,7 +661,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho ** Check for forced intro movie run disabling. If the conquer ** configuration file says "no", then don't run the intro. */ - if (!Special.IsFromInstall) { + if (!Special.IsFromInstall && !Spawner_Is_Requested()) { Special.IsFromInstall = ConfigINI.Get_Bool("Intro", "PlayIntro", true); } @@ -668,7 +669,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho ** Regardless of whether we should run it or not, here we're ** gonna change it to say "no" in the future. */ - if (Special.IsFromInstall == true) { + if (Special.IsFromInstall == true && !Spawner_Is_Requested()) { ConfigINI.Put_Bool("Intro", "PlayIntro", false); // Left closed, so that saving opens it for writing itself. diff --git a/code/theme.cpp b/code/theme.cpp index 07c32ad..37a8bd5 100644 --- a/code/theme.cpp +++ b/code/theme.cpp @@ -316,10 +316,7 @@ ThemeType ThemeClass::Next_Song(ThemeType theme) const { int i; - /* - * A score that repeats is played again, but only while the game actually holds it. One - * it does not would otherwise answer this forever, and nothing else would ever be picked. - */ + // A score the game does not hold would repeat forever, and nothing else be picked. if ((unsigned)theme >= (unsigned)Themes.Count() || !Themes[theme]->Available || (!Themes[theme]->Repeat && !IsRepeat)) { if (IsShuffle == true) { @@ -437,9 +434,8 @@ int ThemeClass::Play_Song(ThemeType theme) Audio.StreamLowImpact = false; /* - * A score that would not start is not the one playing. Recording it as the - * current one silences the game for good: stopping a score that never - * started does nothing, so the one that failed would stay current. + * Stopping a score that never started does nothing, so recording one that failed to + * start as the current score would silence the game for good. */ if (Current == -1) { DebugString("Theme::PlaySong(%d) - Unavailable\n", theme); diff --git a/manual/changes/campaign-difficulty-range.md b/manual/changes/campaign-difficulty-range.md new file mode 100644 index 0000000..339ad8f --- /dev/null +++ b/manual/changes/campaign-difficulty-range.md @@ -0,0 +1,14 @@ +--- +title: Hold the campaign difficulty setting to the settings it names +category: fix +release: 0.2.0 +targets: [] +credit: [ZivDero] +--- + +The campaign difficulty read from the settings file is now held to the three +difficulties the game has, rather than to five. The game has offered three +since its sliders were built that way, so a setting chosen in the game is +unaffected; a file edited by hand to name a fourth or fifth could previously +start a mission whose computer difficulty fell below the easiest one and read +the difficulty table from outside itself. diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md new file mode 100644 index 0000000..e63b644 --- /dev/null +++ b/manual/changes/client-driven-launch.md @@ -0,0 +1,26 @@ +--- +title: Launch and play a game from a client's launch file +category: feature +release: 0.2.0 +targets: +- type: command + id: launch:spawn + effect: added +- type: format + id: spawn-ini + effect: added +- type: format + id: save-games + effect: changed +credit: [ZivDero, Rampastring, dkeeton, FunkyFr3sh, CCHyper, Belonit, hifi, Iran] +--- + +Starting the game with `-SPAWN` now plays the match `SPAWN.INI` describes: a skirmish, a +campaign mission, a game against other machines through a CnCNet tunnel or straight between +them, or any of those resumed from a saved game. The startup movies and the menu are +skipped, and the game exits when the match ends. + +A client-launched game against other machines can now be saved from its options dialog. A +game set up from the menu is assembled as before. + +The people credited here wrote the earlier spawners this one follows. diff --git a/manual/changes/saved-games-folder.md b/manual/changes/saved-games-folder.md new file mode 100644 index 0000000..2e0da00 --- /dev/null +++ b/manual/changes/saved-games-folder.md @@ -0,0 +1,20 @@ +--- +title: Keep saved games in a folder of their own +category: feature +release: 0.2.0 +targets: +- type: format + id: save-games + effect: changed +credit: [ZivDero] +--- + +Saved games now live in a `Saved Games` folder, beside the game or inside the user data +directory when one is named. Every save, load, listing and deletion names that folder, which +is where launchers that browse saved games look. + +The settings the random map generator saves keep to that folder too. Saves made by earlier +builds sit beside the game, or in the user data directory when one is named, and are no +longer listed; moving the files into `Saved Games` restores them. + +After a load, the campaign difficulty now comes from the save rather than the menu setting. diff --git a/manual/changes/user-data-directory.md b/manual/changes/user-data-directory.md index 542fd7d..6edc42f 100644 --- a/manual/changes/user-data-directory.md +++ b/manual/changes/user-data-directory.md @@ -13,7 +13,7 @@ credit: [ZivDero] game writes, creates or deletes goes there — the settings file, hotkeys, saved games, the hall of fame, recordings, saved random maps, screenshots and the files a multiplayer game downloads — and the directory is created when it is not -there yet. +there yet. Saved games take a `Saved Games` folder of their own inside it. It is read from before anywhere else, so a player's own copy of a file is the one the game uses, whatever a deployment ships under the same name. Files a player diff --git a/manual/content/formats/opents-ini.md b/manual/content/formats/opents-ini.md index a5ac33d..902f521 100644 --- a/manual/content/formats/opents-ini.md +++ b/manual/content/formats/opents-ini.md @@ -44,9 +44,11 @@ The game data directory is what [`-DATADIR`](/using/command-line/data-directory/ Everything the game opens follows that order: archives, rules, artwork, scenarios and launch files alike. A loose file still stands in for an archived one, so a copy found in any of these folders is used ahead of an archived copy of the same name. -A player's own copy is therefore the one the game reads, whatever a deployment ships under the same name. That is what makes a shared installation work: the settings, hotkeys and saved games a player has are theirs, and the rest is read from the copy everyone shares. +A player's own copy is therefore the one the game reads, whatever a deployment ships under the same name. That is what makes a shared installation work: the settings and hotkeys a player has are theirs, and the rest is read from the copy everyone shares. -Wildcard searches — for rules, battle files, map packs, saved games, map archives and movie archives — cover every directory in the list rather than stopping at the first that holds a match. A name held by more than one is used once, from the one that comes first, which is the same copy an ordinary open of that name would land on. +Wildcard searches — for rules, battle files, map packs, map archives and movie archives — cover every directory in the list rather than stopping at the first that holds a match. A name held by more than one is used once, from the one that comes first, which is the same copy an ordinary open of that name would land on. + +[Saved games](/formats/save-games/) are the exception to all of this. They keep to a `Saved Games` folder inside the user data directory, and are named there outright rather than searched for, so that a launcher browsing them finds them in one place. :::caution[Files the game writes are not searched for] Settings, saved games, recordings and everything else the game writes go to the user data directory, or to the game's own directory when there is none. A file the game deletes is its own copy, so throwing away a player's hotkeys falls back to the ones a deployment shipped rather than removing them. Nothing listed here is ever written to or deleted from. diff --git a/manual/content/formats/save-games.md b/manual/content/formats/save-games.md index 8ad78e4..45489dc 100644 --- a/manual/content/formats/save-games.md +++ b/manual/content/formats/save-games.md @@ -23,7 +23,11 @@ source_files: The save dialog creates `.SAV` files. Each file is an OLE compound document: the listing details live in the document's own property set, and the game state goes into a single `CONTENTS` stream that is compressed as it is written. -The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list. +## Where the files are + +Saved games keep to a `Saved Games` folder of their own, beside the game or inside the [user directory](/using/game-data/) when one is named, created the first time the game asks for a saved game. Every save, load, listing and deletion names that folder outright: unlike the files the game reads, a saved game is never looked for anywhere else. A client that browses saved games therefore finds them in one place, whichever layout the game was installed in. + +The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list. The random map generator keeps its saved settings in the same folder, under names of its own; the map a host generates for a match is not one of them, and stays with the game's files so that it can travel to the other machines. ## When the file is written @@ -41,10 +45,11 @@ The `CONTENTS` stream is a fixed sequence of records — the scenario, the envir The project-version stamp decides whether a file is offered at all, and only the running version's stamp is accepted. The load dialog reads the property set -of every `.SAV` in the game directory and skips every file stamped by anything -else, including the Tiberian Sun release and another OpenTS release-cycle -version. A save that reaches the engine without passing through the dialog, as -a network save does, is checked the same way and refused. Development snapshots +of every `.SAV` in the saved-games folder and skips every file stamped by +anything else, including the Tiberian Sun release and another OpenTS +release-cycle version. A save that reaches the engine without passing through +the dialog, as one resumed from a [launch file](/formats/spawn-ini/) does, is +checked the same way and refused. Development snapshots within one cycle share the stamp; that mechanical match is not a promise that their save layouts or simulation state interoperate. A listed save that was not made in a campaign is marked with a leading `*`. diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md new file mode 100644 index 0000000..d7a4bad --- /dev/null +++ b/manual/content/formats/spawn-ini.md @@ -0,0 +1,179 @@ +--- +format_id: spawn-ini +title: Client launch file +summary: Describes the match a client asks the game to launch when it starts the game with -SPAWN. +kind: file +source_files: +- code/spawnerconfig.cpp +- code/spawnerconfig.h +- code/spawner.cpp +filenames: +- SPAWN.INI +related: +- type: format + id: ini-syntax +- type: command + id: launch:spawn +--- + +A client that sets up matches outside the game writes this file beside the game and starts +the game with [`-SPAWN`](/using/command-line/spawn). The game then plays the match the file +describes instead of showing its own menu, and exits when that match ends. + +The vocabulary below is the client's, not the game's: the spelling of every key, and what +each means when it is left out, are settled by what clients already write. Reading the file +never fails. A key the game does not know is passed over, a value it cannot make sense of +keeps the meaning an absent key would have, and whether the result describes a game that +can be played is judged once, at the moment the launch is attempted. + +## What the file asks for + +The `[Settings]` section says what kind of game to start. + +| Key | Meaning | +| --- | --- | +| `Scenario` | The scenario file to play. Defaults to `spawnmap.ini`. | +| `IsSinglePlayer` | Play a campaign mission rather than a match. | +| `LoadSaveGame`, `SaveGameName` | Resume the named saved game. | + +A file that seats more than one person asks for a game against other machines. + +## Resuming a saved game + +`LoadSaveGame=yes` resumes the saved game `SaveGameName` names, and settles the question by +itself: a saved game carries the kind of game it was, the options it was played under and +the houses that played it, so nothing else in the file decides those. A client resuming a +campaign writes little more than the name of the save. + +The name is a file inside the game's saved-games folder, and a name written with a path of +its own is reduced to its last part. A save the folder does not hold, or one made by +another version of the game, refuses the launch with the reason shown. + +A save from a game against other machines resumes as well. Every machine loads its own +copy of the save — the synchronized in-game save writes one on each of them, named +`SAVEGAME.NET` — while the file seats the same people again, with the addresses their +machines answer on now. A player who does not return leaves their house fighting on under +the computer, and before play resumes the machines compare the games they loaded, so +mismatched saves refuse rather than drift apart. The launch is refused when the seats and +the save disagree on who is playing, or when the save came from a game the menu arranged +over the local network. + +## A campaign mission + +`IsSinglePlayer=yes` plays the mission `Scenario` names. `CampaignID` says which campaign +the mission belongs to, counted from zero in the order the battle files declare them, or +`-1` for a mission outside any campaign. The campaign decides what the mission leads on to +and which ending it plays, and it is what the game's own introduction is gated on, exactly +as when a campaign is chosen from the menu. + +`DifficultyModeHuman` and `DifficultyModeComputer` each name a difficulty from 0 to 2 — the +player's houses and the computer's, applied independently, so all nine pairings can be +played where the menu offers only its three. A restart or the next mission keeps the pair. + +`[GlobalFlags]` seeds the scenario flags a mission chain carries forward: entries +`GlobalFlag0` through `GlobalFlag49` are set on the mission as it starts, so a mission +launched partway through a chain begins in the state the missions before it left. + +The mission's own briefing and opening movies play as they do from the menu; only the +game's startup movies are skipped. + +## The options every house plays under + +Read from `[Settings]`: `Bases`, `Credits`, `BridgeDestroy`, `Crates`, `ShortGame`, +`GameSpeed`, `MultiEngineer`, `UnitCount`, `AIPlayers`, `AIDifficulty`, `AlliesAllowed`, +`FogOfWar`, `MCVRedeploy`, `TechLevel`, `Firestorm`, and `Seed`. + +A written `Seed` makes a launch repeatable: the same file played twice places every house +the same way. A seed of `0` leaves the placement to chance, which is also what an absent +`Seed` means. + +`HarvesterTruce` is played by in a game against other machines. A skirmish records it with +the rest of the match's options but is not played by it, as a skirmish set up from the menu +is not: only a match against other machines commits harvester immunity to the simulation. + +## Who is playing + +A seat is a person's because the file writes a section for it: `[Settings]` describes the +player at this machine, and `[Other1]` through `[Other7]` describe the others. Each names +`Name`, `Side` (the country), and `Color`. + +A seat no section claims is a computer player, described by position instead: + +| Section | Entry | Meaning | +| --- | --- | --- | +| `[HouseColors]` | `Multi1`–`Multi8` | The color that seat plays. | +| `[HouseCountries]` | `Multi1`–`Multi8` | The country that seat plays. | +| `[HouseHandicaps]` | `Multi1`–`Multi8` | The difficulty that seat plays at. | + +A computer seat may write `-1` for its country or color and leave the choice to the game, +as a game set up from the menu does. A person's seat names both. `AIPlayers` says how many +of the unclaimed seats are actually played by a computer. + +The seats are then ordered the way the game creates houses — the people first, by ascending +color — and everything below that names a seat by number means that order. + +| Section | Entry | Meaning | +| --- | --- | --- | +| `[SpawnLocations]` | `Multi1`–`Multi8` | The map start position that seat begins at. | +| `[Multi1_Alliances]`–`[Multi8_Alliances]` | `HouseAllyOne`–`HouseAllyEight` | The seats that seat is allied with. | + +A start position the map does not declare, or one another seat has already taken, is left +to the game to choose, which is also what writing no position means. Alliances are made +exactly as written, before the first frame is played, and quietly: a match whose file +forbids new pacts still starts with the ones it wrote. + +A computer player may share the color a person plays. Two people may not, in a game against +other machines: the seats are ordered by color, and the client keys what it writes for each +of them by an order no other machine can rebuild, so two people of one color would take each +other's start position and alliances. + +## A game against other machines + +Each machine writes its own file, with itself in `[Settings]` and everybody else in the +`[OtherN]` sections. Those sections carry `Ip` and `Port` as well, naming the address a +machine answers on. A `[Tunnel]` section with its own `Ip` and `Port` routes the match +through a tunnel instead, and each machine is then named by the tunnel number its own `Port` +key carries rather than by its address. + +Every person must be named, and no two may be named the same, whatever the letters' case. +The seats are ordered by color, and a name is what breaks a tie between two of one color, so +a match without those names is not the same match on every machine. Colors themselves may +still be shared. + +The seed is taken exactly as written, the same on every machine — including `0`, which in a +match against other machines is a seed like any other rather than a draw from chance. + +When a `[Tunnel]` section names a server, the match is played through it; otherwise each +machine is reached straight at the address its section carries, while this machine listens +on the port its own `Port` key names. + +## When something is wrong + +A file describing a game that cannot be played is refused: the reason is shown and written +to the log, and the game exits rather than falling back to its menu. A launch is refused +when it seats nobody at this machine, asks for more computer players than there are seats, +names a country or color the loaded rules do not have, plays the computer at a difficulty +the game does not have, asks for a game speed it does not have, names a difficulty that is +not one, allies a seat with one the match does not hold, or asks for a seat that watches +rather than plays. A match against other machines is refused as well when a person is left +unnamed, when two are named the same or given one color, and when a machine other than this +one is given no port to answer on or no address to answer at. + +A difficulty easier than the three the game has is not refused: the seat is played as the +easiest opponent the game does have. The two run opposite ways: the easiest opponent is the +one played at the hardest of the game's three settings. + +## What the game does not take from a launch file + +The timing keys are not read at all, `ReconnectTimeout` and `ConnTimeout` among them. How +far ahead the machines run, how often they exchange their orders, and how long they wait for +one that has gone quiet are the game's own business, and no launch file changes them. +`MapHash` is not read either: the machines compare the games they have loaded before play +begins, which settles the same question for themselves. + +These keys are read but do not change anything yet, each awaiting the behavior that will +honor it: `IsHost`, `Tournament`, `GameID`, `WriteStatistics`, the automatic-save scheduling +keys, `BuildOffAlly`, `AttackNeutralUnits`, `ScrapMetal`, `AutoSurrender`, +`ContinueWithoutHumans`, `CoachMode`, `QuickMatch`, `SkipScoreScreen`, +`PlayMoviesInMultiplayer`, `CustomLoadScreen`, `CustomLoadScreenPos`, and +`DifficultyName`. diff --git a/manual/content/using/game-data.md b/manual/content/using/game-data.md index 8c8a5e5..90e3c32 100644 --- a/manual/content/using/game-data.md +++ b/manual/content/using/game-data.md @@ -32,4 +32,6 @@ Do not place game data in the CMake build directory. The build copies OpenTS exe `-DATADIR=` reads the game's data from the directory named instead of requiring it beside the executable, and `-USERDIR=` keeps what the game writes — settings, saved games, recordings and downloaded maps — in a directory of its own. Together they let one copy of the data serve several people, each writing only to their own directory and reading their own files ahead of the shared ones. +[Saved games](/formats/save-games/) go one step further, into a `Saved Games` folder of their own inside that directory. They are the one thing the game both writes and browses, so they are named there outright rather than looked for among the folders the game reads from. + The data may be sorted into folders rather than left in one directory. Without any configuration the game also searches `INI`, `MIX` and `Maps`; [`OPENTS.INI`](/formats/opents-ini/) names other folders and the order they are searched in. diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index aa54443..1dce919 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -482,6 +482,14 @@ launch_options: availability: *all sites: - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-USERDIR=' } + - id: launch:spawn + title: Client launch + syntax: -SPAWN + description: Launches the game SPAWN.INI describes, in place of the startup movies and the menu. + audience: player + availability: *all + sites: + - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-SPAWN' } - id: launch:tournament-time title: Tournament time limit syntax: -TIME= diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml index 4d58e14..18b1b31 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -1772,6 +1772,17 @@ launch_options: _provenance: source: code/init.cpp guard: null +- id: launch:spawn + route_id: spawn + kind: launch + title: Client launch + description: Launches the game SPAWN.INI describes, in place of the startup movies and the menu. + audience: player + availability: *id001 + syntax: -SPAWN + _provenance: + source: code/init.cpp + guard: null - id: launch:tournament-time route_id: tournament-time kind: launch diff --git a/manual/data/ini-read-exclusions.yaml b/manual/data/ini-read-exclusions.yaml index e81cf51..753d06d 100644 --- a/manual/data/ini-read-exclusions.yaml +++ b/manual/data/ini-read-exclusions.yaml @@ -136,3 +136,14 @@ site_exclusions: keys: [SearchPaths] classification: excluded reason: The folder list belongs to the deployment file that describes where a distribution keeps its own files, and is documented as part of that format rather than as game data. + + - path: code/spawnerconfig.cpp + function: SpawnerConfigClass::Read_Slots + keys: [Color, Side, Port] + classification: excluded + reason: Each names one seat of a match inside a section the CnCNet client numbers per seat, not an authored game-data setting. + - path: code/spawnerconfig.cpp + function: SpawnerConfigClass::Read_INI + keys: [AIDifficulty, AIPlayers, AlliesAllowed, AttackNeutralUnits, AutoSaveGame, AutoSurrender, Bases, BridgeDestroy, BuildOffAlly, CampaignID, CoachMode, ContinueWithoutHumans, Crates, Credits, DifficultyBasedAINames, DifficultyModeComputer, DifficultyModeHuman, Firestorm, FogOfWar, GameID, GameSpeed, HarvesterTruce, Host, IsSinglePlayer, LoadSaveGame, MCVRedeploy, MultiEngineer, NextSPAutoSaveId, NextSkirmishAutoSaveId, PlayMoviesInMultiplayer, Port, QuickMatch, ScrapMetal, Seed, ShortGame, SkipScoreScreen, TechLevel, Tournament, UnitCount, WriteStatistics] + classification: excluded + reason: The CnCNet client writes these per launch to describe one match, so they are not part of the authored game-data key catalog. diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index 374d7b3..106f9d2 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -192,3 +192,264 @@ test('Building main-shape Image is additive to the inherited ObjectType Image re ], 'Building main-shape selection'); assert.doesNotMatch(fetchImage, /\bGraphicName\s*=/); }); + +test('Every field the launch file reader carries is bound or named as unhonored', () => { + const header = source('code/spawnerconfig.h'); + const spawner = source('code/spawner.cpp'); + + assert.match( + spawner, + /Read, not honored/, + 'the binding step keeps its ledger of fields it deliberately leaves alone', + ); + + const fields = []; + for (const line of header.split('\n')) { + const declaration = /^\t{2,3}(?!static |enum |struct |\/)[A-Za-z_][^;(]*?[\s>*&]([A-Za-z_]\w*)\s*(?:=[^;]*)?;\s*$/.exec(line); + if (declaration) fields.push(declaration[1]); + } + assert.ok(fields.length > 30, `expected the reader to carry many fields, found ${fields.length}`); + + for (const field of fields) { + assert.match( + spawner, + new RegExp(String.raw`\b${field}\b`), + `${field} is read from a launch file but code/spawner.cpp neither binds it nor names it in the "Read, not honored" ledger`, + ); + } +}); + +test('A session node is left to its own constructor rather than zeroed by hand', () => { + assert.doesNotMatch( + source('code/netdlg2.cpp'), + /memset\(who, 0, sizeof\(\*who\)\)/, + 'zeroing a node by hand would wipe the defaults its constructor sets', + ); +}); + +test('House assignment takes each seat as written before the neutral houses exist', () => { + const assign = functionBody(source('code/scenario.cpp'), 'void Assign_Houses(void)'); + + assertOrdered(assign, [ + 'housep->SpawnWaypoint = player->Player.SpawnChoice;', + 'seat->Player.House != -1', + 'seat->Player.Color != -1', + 'seat->Player.Handicap >= 0', + 'housep->SpawnWaypoint = seat->Player.SpawnChoice;', + 'seat->Player.ID = housep->HeapID;', + ], 'a seated house takes its country, color, difficulty and start position'); + + assertOrdered(assign, [ + 'Seated_Node(seatnum)', + 'Make_Ally', + 'HouseTypeClass::From_Name("Neutral")', + ], 'the alliance table names seats, so it is applied before any house that is not one'); +}); + +test('A chosen start position keeps its number and is claimed before the game picks', () => { + const scenario = source('code/scenario.cpp'); + + const build = functionBody( + scenario, + 'static DynamicVectorClass Build_Start_Waypoint_List(bool official, bool keep_identity)', + ); + assertOrdered(build, [ + 'if (keep_identity) {', + 'waypts.Add(declared ? Scen->Get_Waypoint_Cell(waycount) : CELL_NONE);', + 'Append_Open_Start_Positions(', + 'return(waypts);', + ], 'the numbered list keeps an undeclared position as a hole and appends any shortfall past it'); + + const create = functionBody( + scenario.slice(scenario.search(/static void Create_Units\(bool official\)\s*\{/)), + 'static void Create_Units(bool official)', + ); + assertOrdered(create, [ + 'Houses[index]->SpawnWaypoint >= 0', + 'Build_Start_Waypoint_List(official, choices)', + 'taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE;', + 'reserved[spot] = index;', + 'reserved[hptr->SpawnWaypoint] == (int)house', + '} else if (numtaken == 0) {', + ], 'every named position is held before the game picks for anybody who named none'); +}); + +test('The campaign handicap pair lives on the session, and the mission reader never asks the spawner', () => { + const scenario = source('code/scenario.cpp'); + + assertOrdered( + functionBody(scenario, 'bool Read_Scenario_INI(CCINIClass const & ini, bool is_mapgen)'), + [ + 'Scen->Difficulty = Session.CampaignDifficulty;', + 'Scen->CDifficulty = Session.CampaignCDifficulty;', + ], + 'the mission takes the pair the session carries', + ); + assert.doesNotMatch( + scenario, + /#include "spawner\.h"/, + 'the mission reader has no line to the spawner', + ); + + assertOrdered( + functionBody(source('code/init.cpp'), 'bool Select_Game(bool )'), + [ + 'Session.CampaignDifficulty = (DiffType)Options.Difficulty;', + 'Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty);', + ], + 'the menu derives the pair the mission reader used to compute, ahead of the start', + ); +}); + +test('A campaign spawn writes the game its own state and nothing more', () => { + const spawner = source('code/spawner.cpp'); + + assertOrdered(functionBody(spawner, 'static bool Spawner_Setup_Campaign(void)'), [ + 'Session.Type = GAME_NORMAL;', + 'Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty;', + 'Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty;', + 'Scen->Campaign = (CampaignType)SpawnConfig.CampaignID;', + 'new (&Environment) EnvironmentClass;', + 'Environment.Globals[index] = SpawnConfig.GlobalFlags[index];', + ], 'a campaign launch lands in the game’s own state'); + + assertOrdered(functionBody(source('code/init.cpp'), 'bool Select_Game(bool )'), [ + 'Spawner_Is_Active() ? Scen->Campaign : CAMPAIGN_NONE', + 'Scen->Set_Global_To(index, Environment.Globals[index]);', + ], 'a spawned mission is named by the file and starts with the flags it carried'); +}); + +test('A resume is judged before it is loaded, and the save answers for the rest', () => { + assertOrdered(functionBody(source('code/spawner.cpp'), 'static bool Spawner_Resume(bool & gameloaded)'), [ + 'SpawnConfig.SaveGameName.empty()', + 'Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)', + 'info.Get_Internal_Version() != ExpectedGameVersion', + 'type == GAME_IPX', + 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)', + 'Spawner_Seat_Humans();', + 'Spawner_Wire_Network()', + 'Session.LoadGame = true;', + 'LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())', + 'Reconcile_Players()', + 'gameloaded = true;', + ], 'a network resume seats the players and opens the network before the save is read'); + + for (const dialog of ['IDD_OPT_CTRL_WOL']) { + const template = source('code/language/language.rc'); + const body = template.slice(template.indexOf(dialog + ' DIALOG')); + assert.match( + body.slice(0, body.indexOf('END')), + /IDC_SAVE_GAME/, + `${dialog} offers the synchronized save the options handler has always known`, + ); + } + + assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Reconcile_Players(void)'), [ + 'stricmp(Session.Players[i]->Name, Houses[house]->IniName) == 0', + 'Session.Players[i]->Player.ID = found->HeapID;', + 'Houses[Session.Players[0]->Player.ID] != PlayerPtr', + 'housep->IsHuman = false;', + 'housep->IniName = Fetch_String(TXT_COMPUTER);', + ], 'every seat is matched and this machine identified before any house changes hands'); + + assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Load_Game(const char *file_name)'), [ + 'Session.Type = (GameType)info.Get_Game_Type();', + 'Post_Load_Game();', + 'Session.CampaignDifficulty = Scen->Difficulty;', + 'Session.CampaignCDifficulty = Scen->CDifficulty;', + ], 'a load takes the kind of game and the campaign pair from the save'); +}); + +test('Saved games are named in one folder rather than searched for', () => { + const gamedirs = source('code/gamedirs.cpp'); + + assertOrdered(functionBody(gamedirs, 'std::string Saved_Game_Name(char const * filename)'), [ + 'UserDirectory + SavedGamesFolder', + 'CreateDirectory(folder.c_str(), NULL);', + ], 'a saved game is named inside the user directory, and the folder is made on the way'); + + for (const [file, signature] of [ + ['code/saveload.cpp', 'static bool Save_Game(const char *file_name, char const * descr)'], + ['code/saveload.cpp', 'bool Load_Game(const char *file_name)'], + ['code/saveload.cpp', 'bool Get_Savefile_Info(char const * name, SaveVersionInfo * info)'], + ['code/loaddlg.cpp', 'void LoadOptionsClass::Fill_List(HWND window)'], + ['code/loaddlg.cpp', 'bool LoadOptionsClass::Files_Present(void)'], + ['code/loaddlg.cpp', 'bool LoadOptionsClass::Delete_File(const char * file_name)'], + ]) { + assert.match( + functionBody(source(file), signature), + /Saved_Game_Name\(/, + `${signature} names the folder saved games are kept in`, + ); + } + + assert.doesNotMatch( + functionBody(source('code/loaddlg.cpp'), 'void LoadOptionsClass::Fill_List(HWND window)') + + functionBody(source('code/loaddlg.cpp'), 'bool LoadOptionsClass::Files_Present(void)'), + /Search_Files\(/, + 'the listing no longer scans the folders the game reads from', + ); +}); + +test('A match against other machines is assembled whole and wired to its network last', () => { + const spawner = source('code/spawner.cpp'); + + assertOrdered(functionBody(spawner, 'bool Spawner_Prepare(bool & gameloaded)'), [ + 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)', + 'Spawner_Setup_Session();', + 'SpawnConfig.Session_Identity_CRC()', + 'Session.Type == GAME_INTERNET && !Spawner_Wire_Network()', + ], 'the match is judged, assembled and named before its network is opened'); + + assertOrdered(functionBody(spawner, 'static bool Spawner_Wire_Network(void)'), [ + 'Ipx.Configure_Tunnel(', + 'Ipx.Configure_Direct_Peers(', + 'Ipx.Add_Peer(Session.Players[index]->Address);', + 'if (!Ipx.Init()) {', + ], 'the transport is chosen, the peers named, and only then the network opened'); + + assertOrdered(functionBody(source('code/scenario.cpp'), 'static NodeNameType * Seated_Node(int seat)'), [ + 'Session.Players[i]->Player.ID == seat', + 'Session.Computers[i]->Player.ID == seat', + ], 'a seat is found by the house it was assigned, not by its place in the list'); + + + assert.match( + functionBody(spawner, 'static void Spawner_Setup_Session(void)'), + /LaunchType::Multiplayer\s*\n?\s*\?\s*GAME_INTERNET : GAME_SKIRMISH;/, + 'one assembly serves both kinds of match', + ); + + assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Human(int index)'), [ + 'if (SpawnConfig.TunnelPort != 0) {', + 'node->Address.Set_Address(0, htons((unsigned short)seat.Port));', + 'inet_addr(seat.Address.c_str())', + ], 'a tunnelled machine is named by its tunnel number before an address is read'); + + assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Humans(void)'), [ + 'Spawner_Seat_Human(SpawnConfig.LocalSlot);', + 'if (index != SpawnConfig.LocalSlot) {', + ], 'the local seat leads the player list the rest of the game reads'); + + assertOrdered(functionBody(spawner, 'static void Spawner_Setup_Session(void)'), [ + 'GAME_INTERNET : GAME_SKIRMISH;', + 'Seed = SpawnConfig.Seed;', + ], 'one seed is taken as written, since no lobby hands one around'); + + assertOrdered( + functionBody( + source('code/spawnerconfig.cpp'), + 'bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const', + ), + [ + 'kind == LaunchType::Multiplayer ||', + '(kind == LaunchType::Resume && HumanCount > 1)', + 'if (human && multiplayer) {', + 'slot.Name.empty()', + '_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0', + 'Slots[other].Color == slot.Color', + 'slot.Port < 1 || slot.Port > 65535', + ], + 'the seat order the machines share is what the name and color rules are held for', + ); +}); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5de329d..de530ae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,4 @@ +add_subdirectory(cpudetect) add_subdirectory(gamedirs) add_subdirectory(logstress) -add_subdirectory(cpudetect) +add_subdirectory(spawner) diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp index d2f9a6a..e8f9245 100644 --- a/tests/gamedirs/gamedirscontract.cpp +++ b/tests/gamedirs/gamedirscontract.cpp @@ -506,6 +506,49 @@ void Test_Without_A_User_Directory_Nothing_Moves(void) } +/* + * Saved games are the one thing the game both writes and browses, so they keep to a folder of + * their own that is named outright rather than searched for. + */ +void Test_Saved_Games_Folder(void) +{ + Reset(); + Init_Search_Folders(); + + Check(Saved_Game_Name("SAVE0001.SAV") == "Saved Games\\SAVE0001.SAV", + "a saved game is named inside the folder saved games are kept in"); + Check(File_Exists(Root + "\\Saved Games"), + "asking for a saved game makes the folder to keep it in"); + + for (int index = 0; ; index++) { + char const * path = CDFileClass::Search_Path(index); + if (path == NULL) { + break; + } + + Check(std::string(path).find("Saved Games") == std::string::npos, + "the folder saved games are kept in is not one of the searched folders"); + } + + Reset(); + Set_User_Directory((Root + "\\User\\Saves").c_str()); + Apply_Game_Directories(); + + std::string const expected = Root + "\\User\\Saves\\Saved Games"; + Check(Saved_Game_Name("SAVE0002.SAV") == expected + "\\SAVE0002.SAV", + "a user directory takes the saved games with it"); + Check(File_Exists(expected), + "the folder is made inside the user directory"); + + /* + * A pattern is named the same way a file is, since the listing scans the one folder rather + * than every folder the game reads from. + */ + Check(Saved_Game_Name("*.SAV") == expected + "\\*.SAV", + "a pattern is named in the same folder the saved games are"); +} + + bool Make_Root(void) { char temp[MAX_PATH]; @@ -566,6 +609,7 @@ int main(void) Test_A_Name_With_A_Directory_Is_Left_Alone(); Test_Placing_A_File_Is_Repeatable(); Test_Without_A_User_Directory_Nothing_Moves(); + Test_Saved_Games_Folder(); Reset(); Remove_Root(); diff --git a/tests/spawner/CMakeLists.txt b/tests/spawner/CMakeLists.txt new file mode 100644 index 0000000..dc597a9 --- /dev/null +++ b/tests/spawner/CMakeLists.txt @@ -0,0 +1,42 @@ +# The launch file reader is compiled straight into the harness along with the INI reader it +# is written against. It lives outside code/ so that the recursive glob building the engine +# cannot pick this target's entry point up. +add_executable(SpawnContract + "${CMAKE_CURRENT_SOURCE_DIR}/spawncontract.cpp" + "${CMAKE_SOURCE_DIR}/code/spawnerconfig.cpp" + "${CMAKE_SOURCE_DIR}/code/ini.cpp" + "${CMAKE_SOURCE_DIR}/code/readline.cpp" + "${CMAKE_SOURCE_DIR}/code/trim.cpp" + "${CMAKE_SOURCE_DIR}/code/buff.cpp" + "${CMAKE_SOURCE_DIR}/code/straw.cpp" + "${CMAKE_SOURCE_DIR}/code/xstraw.cpp" + "${CMAKE_SOURCE_DIR}/code/cstraw.cpp" + "${CMAKE_SOURCE_DIR}/code/pipe.cpp" + "${CMAKE_SOURCE_DIR}/code/xpipe.cpp" + "${CMAKE_SOURCE_DIR}/code/b64straw.cpp" + "${CMAKE_SOURCE_DIR}/code/b64pipe.cpp" + "${CMAKE_SOURCE_DIR}/code/base64.cpp" + "${CMAKE_SOURCE_DIR}/code/crc.cpp" + "${CMAKE_SOURCE_DIR}/code/pk.cpp" + "${CMAKE_SOURCE_DIR}/code/int.cpp" + "${CMAKE_SOURCE_DIR}/code/mpmath.cpp" +) + +target_compile_features(SpawnContract PRIVATE cxx_std_20) + +target_include_directories(SpawnContract PRIVATE "${CMAKE_SOURCE_DIR}/code") + +target_compile_definitions(SpawnContract PRIVATE WIN32 _WINDOWS _MBCS NOMINMAX) + +target_compile_options(SpawnContract PRIVATE + $<$:/MTd /EHsc /Zc:__cplusplus> + $<$:/MT /EHsc /Zc:__cplusplus> +) + +target_link_libraries(SpawnContract PRIVATE kernel32 user32 shell32) + +set_target_properties(SpawnContract PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +add_test(NAME spawncontract COMMAND SpawnContract) diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp new file mode 100644 index 0000000..de284cb --- /dev/null +++ b/tests/spawner/spawncontract.cpp @@ -0,0 +1,756 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Pins the launch file the CnCNet client writes to start a game, without the engine or any game +// data: what an unwritten key means, the order the seats end up in, the repairs the reader +// makes silently, and which values two machines have to agree on. Whether a reading +// describes a playable game is judged at launch, so nothing here refuses anything. + +#include +#include +#include + +#include "ini.h" +#include "spawnerconfig.h" +#include "xstraw.h" + +namespace { + +using LaunchType = SpawnerConfigClass::LaunchType; +using OccupancyType = SpawnerConfigClass::OccupancyType; + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-64s %s\n", what, condition ? "ok" : "FAILED"); + + if (!condition) { + Failures++; + } +} + + +// What the client writes to resume a saved campaign. It names almost nothing: the saved game +// carries the houses, the options and the kind of game they were played as. +char const _Resume[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "SaveGameName=SAVEGAME.001\n" + "LoadSaveGame=Yes\n" + "SidebarHack=True\n" + "CustomLoadScreen=Resources/l600s01.pcx\n" + "Firestorm=No\n" + "GameSpeed=1\n"; + + +// What the client writes to start a campaign mission. It names no player and no color, since +// the map says who is playing. +char const _Campaign[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "CampaignID=-1\n" + "GameSpeed=1\n" + "Firestorm=False\n" + "CustomLoadScreen=Resources/l600s01.pcx\n" + "IsSinglePlayer=Yes\n" + "SidebarHack=True\n" + "Side=0\n" + "BuildOffAlly=False\n" + "DifficultyModeHuman=0\n" + "DifficultyModeComputer=2\n"; + + +// What the client writes to start a game against computer players. The player describes +// himself in the settings, and the computer players are named by position. +char const _Skirmish[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "Name=Commander\n" + "Side=1\n" + "Color=4\n" + "Port=1234\n" + "AIPlayers=2\n" + "Credits=5000\n" + "ShortGame=Yes\n" + "Protocol=0\n" + "NextSkirmishAutoSaveId=5\n" + "\n" + "[HouseColors]\n" + "Multi2=2\n" + "Multi3=7\n" + "\n" + "[HouseCountries]\n" + "Multi2=0\n" + "Multi3=1\n" + "\n" + "[HouseHandicaps]\n" + "Multi2=2\n" + "Multi3=0\n" + "\n" + "[SpawnLocations]\n" + "Multi1=3\n" + "Multi2=9\n" + "Multi3=-2\n" + "\n" + "[Multi1_Alliances]\n" + "HouseAllyOne=1\n"; + + +// What the client writes for a game against other machines. The player who wrote it holds the +// higher color, so the sorting puts him second. +char const _Network[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "Name=Second\n" + "Side=1\n" + "Color=5\n" + "Port=50000\n" + "Host=No\n" + "\n" + "[Other1]\n" + "Name=First\n" + "Side=0\n" + "Color=1\n" + "Ip=10.0.0.7\n" + "Port=50001\n" + "\n" + "[Tunnel]\n" + "Ip=88.99.11.22\n" + "Port=50010\n" + "\n" + "[IsSpectator]\n" + "Multi1=Yes\n" + "\n" + "[Multi2_Alliances]\n" + "HouseAllyOne=0\n"; + + +SpawnerConfigClass Read(char const * text, int length) +{ + INIClass ini; + BufferStraw straw(text, length); + ini.Load(straw); + + SpawnerConfigClass config; + config.Read_INI(ini); + return(config); +} + + +bool Judge(char const * text, int length, int countries, int colors, std::string & fault) +{ + SpawnerConfigClass config = Read(text, length); + fault.clear(); + return(config.Is_Playable(countries, colors, fault)); +} + +} + + +int main(void) +{ + /* + * A file naming nothing at all still describes a playable game, because every key the + * client leaves out has a settled meaning. + */ + { + char const empty[] = "[Settings]\n"; + SpawnerConfigClass config = Read(empty, sizeof(empty) - 1); + + Check(config.Bases && config.Credits == 10000 && config.MCVRedeploy, + "the unwritten keys keep the meaning clients expect"); + Check(config.TunnelId == 0 && config.ListenPort == 1234, + "one absent port names no tunnel and still listens"); + Check(config.ScenarioName == "spawnmap.ini", + "the scenario a client always writes is also the default"); + Check(config.NextCampaignAutoSave == 0 && config.NextSkirmishAutoSave == 0, + "the first automatic save is numbered from zero"); + + bool any = false; + for (bool flag : config.GlobalFlags) { + any = any || flag; + } + Check(!any, "no scenario flag is set unasked"); + } + + /* + * Resuming a saved game asks for almost nothing; the save answers for the rest, so the + * reading alone decides the kind of launch. + */ + { + SpawnerConfigClass config = Read(_Resume, sizeof(_Resume) - 1); + + Check(config.Launch_Type() == LaunchType::Resume, "resuming a save is what the file asks for"); + Check(config.SaveGameName == "SAVEGAME.001", "the saved game is named"); + Check(!config.Firestorm, "the expansion is left out when the file says so"); + } + + /* + * A saved game is opened by name in the game's own folder, so a name written with a path + * is reduced to the name without ceremony. + */ + { + char const traversal[] = + "[Settings]\n" + "LoadSaveGame=Yes\n" + "SaveGameName=..\\..\\Windows\\SAVEGAME.001\n"; + SpawnerConfigClass config = Read(traversal, sizeof(traversal) - 1); + + Check(config.SaveGameName == "SAVEGAME.001", "only the name of the saved game is read"); + + char const forward[] = + "[Settings]\n" + "SaveGameName=saves/deep/SAVEGAME.002\n"; + config = Read(forward, sizeof(forward) - 1); + + Check(config.SaveGameName == "SAVEGAME.002", "a forward slash hides nothing either"); + + char const drive[] = + "[Settings]\n" + "SaveGameName=C:SAVEGAME.003\n"; + config = Read(drive, sizeof(drive) - 1); + + Check(config.SaveGameName == "SAVEGAME.003", "a drive letter is not part of the name"); + } + + /* + * A campaign takes its houses from the map, so a file naming no player is complete. + */ + { + SpawnerConfigClass config = Read(_Campaign, sizeof(_Campaign) - 1); + + Check(config.Launch_Type() == LaunchType::Campaign, "a single player game is a campaign"); + Check(config.CampaignDifficulty == 0 && config.CampaignCDifficulty == 2, + "the two difficulties are read apart"); + Check(config.CampaignID == -1, "a mission outside any campaign says so"); + } + + /* + * The seats end up in the order the houses are created in, which is what everything + * naming a seat by position afterwards means. + */ + { + SpawnerConfigClass config = Read(_Skirmish, sizeof(_Skirmish) - 1); + + Check(config.Launch_Type() == LaunchType::Skirmish, "one player and computers is a skirmish"); + Check(config.HumanCount == 1 && config.LocalSlot == 0, "the only player holds the first seat"); + Check(config.Slots[0].Occupancy == OccupancyType::Human && + config.Slots[0].Name == "Commander" && + config.Slots[0].Color == 4 && config.Slots[0].Country == 1, + "the player is read from the settings themselves"); + Check(config.Slots[1].Occupancy == OccupancyType::Computer && config.Slots[1].Color == 2 && + config.Slots[1].Country == 0 && config.Slots[1].Handicap == 2, + "a seat no section claimed is a computer player named by position"); + Check(config.Slots[2].Occupancy == OccupancyType::Computer && config.Slots[2].Color == 7, + "the second computer player follows the first"); + Check(config.Slots[3].Occupancy == OccupancyType::Empty, "no more seats are filled than are asked for"); + Check(config.Slots[0].StartingPosition == 3, "a start position is read for the seat that asked"); + Check(config.Slots[1].StartingPosition == -1, + "a start position the map cannot hold becomes the game's own choice"); + Check(config.Slots[2].StartingPosition == -1, + "a position below the game's own choice is that choice"); + Check(config.Slots[0].Alliances[0] == 1, "an alliance is read as the seat it names"); + Check(config.NextSkirmishAutoSave == 4, "a client's save numbering is shifted to the game's"); + } + + /* + * The player who wrote the file is not always the first house, and the game has to know + * which seat is his once the sorting is done. + */ + { + SpawnerConfigClass config = Read(_Network, sizeof(_Network) - 1); + + Check(config.HumanCount == 2, "a written section is what makes a seat a player"); + Check(config.Slots[0].Name == "First" && config.Slots[1].Name == "Second", + "the players are sorted into the order their houses are created in"); + Check(config.LocalSlot == 1, "this machine knows which of the seats is its own"); + Check(config.Slots[0].Address == "10.0.0.7" && config.Slots[0].Port == 50001, + "the other machine is read with the address it answers on"); + Check(config.TunnelId == 50000 && config.ListenPort == 50000, + "one written port both listens and names this machine to a tunnel"); + Check(config.TunnelAddress == "88.99.11.22" && config.TunnelPort == 50010, + "the tunnel is read from its own section"); + Check(config.Launch_Type() == LaunchType::Multiplayer, + "two players is a game against another machine"); + Check(config.Slots[0].IsSpectator && !config.Slots[1].IsSpectator, + "a watcher is named by the seat order the houses take"); + Check(config.Slots[1].Alliances[0] == 0, + "an alliance section names the sorted seat too"); + } + + /* + * Every machine writes its own file with itself first, so a color tie must be broken by + * what the seats say rather than where the file said it, or the matches would differ. + */ + { + char const view_a[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=3\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + char const view_b[] = + "[Settings]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "Ip=10.0.0.8\n" + "Port=50001\n"; + + SpawnerConfigClass a = Read(view_a, sizeof(view_a) - 1); + SpawnerConfigClass b = Read(view_b, sizeof(view_b) - 1); + Check(a.Slots[0].Name == "Alpha" && b.Slots[0].Name == "Alpha", + "a color tie seats the match identically on every machine"); + Check(a.LocalSlot == 0 && b.LocalSlot == 1, + "each machine still knows which of the tied seats is its own"); + Check(a.Session_Identity_CRC() == b.Session_Identity_CRC(), + "the tied match carries one identity on both machines"); + } + + /* + * Two machines handed the same match agree on its identity, and a difference in what + * either of them merely displays cannot move it. + */ + { + SpawnerConfigClass one = Read(_Skirmish, sizeof(_Skirmish) - 1); + SpawnerConfigClass two = Read(_Skirmish, sizeof(_Skirmish) - 1); + + Check(one.Session_Identity_CRC() == two.Session_Identity_CRC(), + "the same match is given the same identity twice"); + + two.MapName = "A Map By Another Name"; + two.DifficultyName = "Gentle"; + two.Slots[0].Name = "Somebody Else"; + Check(one.Session_Identity_CRC() == two.Session_Identity_CRC(), + "what a player is shown is left out of the identity"); + + two.Credits = one.Credits + 1; + Check(one.Session_Identity_CRC() != two.Session_Identity_CRC(), + "a value the match is played by moves the identity"); + + SpawnerConfigClass three = Read(_Skirmish, sizeof(_Skirmish) - 1); + three.Slots[1].Country = one.Slots[1].Country + 1; + Check(one.Session_Identity_CRC() != three.Session_Identity_CRC(), + "a computer player's country moves the identity"); + + SpawnerConfigClass four = Read(_Skirmish, sizeof(_Skirmish) - 1); + four.GlobalFlags[49] = !four.GlobalFlags[49]; + Check(one.Session_Identity_CRC() != four.Session_Identity_CRC(), + "a scenario flag moves the identity"); + + /* + * A resume is a match of its own: the saved game decides everything the fields above + * would otherwise have decided, so which save is being resumed is part of the identity. + */ + SpawnerConfigClass five = Read(_Skirmish, sizeof(_Skirmish) - 1); + five.LoadSaveGame = !five.LoadSaveGame; + Check(one.Session_Identity_CRC() != five.Session_Identity_CRC(), + "resuming a save rather than starting one moves the identity"); + + SpawnerConfigClass six = Read(_Resume, sizeof(_Resume) - 1); + SpawnerConfigClass seven = Read(_Resume, sizeof(_Resume) - 1); + seven.SaveGameName = "SAVEGAME.002"; + Check(six.Session_Identity_CRC() != seven.Session_Identity_CRC(), + "resuming another saved game moves the identity"); + + /* + * Where the machines reach one another is how a match is carried rather than what it + * plays out as, and each machine writes its own view of it, so it is left out as well. + */ + SpawnerConfigClass eight = Read(_Network, sizeof(_Network) - 1); + SpawnerConfigClass nine = Read(_Network, sizeof(_Network) - 1); + nine.TunnelAddress = "203.0.113.9"; + nine.TunnelPort = 50010; + nine.ListenPort = 60000; + nine.Slots[0].Address = "10.0.0.8"; + nine.Slots[0].Port = 50003; + Check(eight.Session_Identity_CRC() == nine.Session_Identity_CRC(), + "where the machines reach one another is left out of the identity"); + } + + /* + * The timing the machines keep is the game's own, and a key the game does not know is + * passed over, so neither can move a match's identity. + */ + { + char const plain[] = + "[Settings]\n" + "Credits=7000\n" + "Seed=42\n"; + char const noisy[] = + "[Settings]\n" + "Credits=7000\n" + "Seed=42\n" + "Protocol=2\n" + "FrameSendRate=3\n" + "MaxAhead=100\n" + "PreCalcMaxAhead=1\n" + "MaxLatencyLevel=2\n" + "SomeFutureClientKey=1\n"; + + SpawnerConfigClass a = Read(plain, sizeof(plain) - 1); + SpawnerConfigClass b = Read(noisy, sizeof(noisy) - 1); + Check(a.Session_Identity_CRC() == b.Session_Identity_CRC(), + "timing and unknown keys cannot move a match's identity"); + } + + /* + * The scenario flags are named by their number, and a load screen position is taken + * whole or not at all. + */ + { + char const flags[] = + "[Settings]\n" + "CustomLoadScreenPos=317,401\n" + "\n" + "[GlobalFlags]\n" + "GlobalFlag0=yes\n" + "GlobalFlag49=yes\n"; + SpawnerConfigClass config = Read(flags, sizeof(flags) - 1); + + Check(config.GlobalFlags[0] && config.GlobalFlags[49], + "a scenario flag is read by its number"); + + bool between = false; + for (int index = 1; index < 49; index++) { + between = between || config.GlobalFlags[index]; + } + Check(!between, "no flag is set by a neighbor's spelling"); + Check(config.CustomLoadScreenX == 317 && config.CustomLoadScreenY == 401, + "the load screen position is read whole"); + + char const malformed[] = + "[Settings]\n" + "CustomLoadScreenPos=oops\n"; + config = Read(malformed, sizeof(malformed) - 1); + + Check(config.CustomLoadScreenX == 0 && config.CustomLoadScreenY == 0, + "a position the reader cannot make sense of is no position"); + + char const half[] = + "[Settings]\n" + "CustomLoadScreenPos=12\n"; + config = Read(half, sizeof(half) - 1); + + Check(config.CustomLoadScreenX == 0 && config.CustomLoadScreenY == 0, + "half a position is no position either"); + } + + /* + * Reading a launch file cannot fail, so whether what it describes can be played is + * judged separately, against the tables the game has loaded by the time it launches. + */ + { + std::string fault; + + Check(Judge(_Skirmish, sizeof(_Skirmish) - 1, 2, 8, fault), + "a match the loaded rules can hold is played"); + + char const crowded[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=8\n"; + Check(!Judge(crowded, sizeof(crowded) - 1, 2, 8, fault) && + fault.find("8") != std::string::npos && fault.find("7") != std::string::npos, + "more computer players than seats names both counts"); + + char const negative[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=-1\n"; + Check(!Judge(negative, sizeof(negative) - 1, 2, 8, fault), + "fewer than no computer players is refused"); + + char const nameless_country[] = + "[Settings]\n" + "Name=Commander\n" + "Color=0\n"; + Check(!Judge(nameless_country, sizeof(nameless_country) - 1, 2, 8, fault), + "a person's country is never the game's to draw"); + + char const nameless_color[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n"; + Check(!Judge(nameless_color, sizeof(nameless_color) - 1, 2, 8, fault), + "a person's color is never the game's to draw either"); + + char const drawn_computer[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=1\n" + "\n" + "[HouseColors]\n" + "Multi2=-1\n" + "\n" + "[HouseCountries]\n" + "Multi2=-1\n"; + Check(Judge(drawn_computer, sizeof(drawn_computer) - 1, 2, 8, fault), + "a computer seat may leave its country and color to the game"); + + char const past_countries[] = + "[Settings]\n" + "Name=Commander\n" + "Side=2\n" + "Color=0\n"; + Check(!Judge(past_countries, sizeof(past_countries) - 1, 2, 8, fault), + "a country the rules did not declare is refused"); + + char const past_colors[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=8\n"; + Check(!Judge(past_colors, sizeof(past_colors) - 1, 2, 8, fault), + "a color the game has no scheme for is refused"); + + char const shared_color[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=3\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(!Judge(shared_color, sizeof(shared_color) - 1, 2, 8, fault), + "two people of one color are refused against other machines"); + + char const shared_with_computer[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=3\n" + "AIPlayers=1\n" + "\n" + "[HouseColors]\n" + "Multi2=3\n"; + Check(Judge(shared_with_computer, sizeof(shared_with_computer) - 1, 2, 8, fault), + "a computer player may take the color its opponent plays"); + + char const past_difficulty[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=1\n" + "\n" + "[HouseHandicaps]\n" + "Multi2=7\n"; + Check(!Judge(past_difficulty, sizeof(past_difficulty) - 1, 2, 8, fault), + "a difficulty naming none is refused"); + + char const easy_difficulty[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=1\n" + "\n" + "[HouseHandicaps]\n" + "Multi2=6\n"; + Check(Judge(easy_difficulty, sizeof(easy_difficulty) - 1, 2, 8, fault), + "a difficulty easier than the game holds is played, not refused"); + + Check(SpawnerConfigClass::Playable_Handicap(-1) == -1 && SpawnerConfigClass::Playable_Handicap(0) == 0 && + SpawnerConfigClass::Playable_Handicap(2) == 2 && SpawnerConfigClass::Playable_Handicap(3) == 2 && + SpawnerConfigClass::Playable_Handicap(6) == 2, + "an easier opponent than the game has comes to the easiest opponent it has"); + + char const two_machines[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(Judge(two_machines, sizeof(two_machines) - 1, 2, 8, fault), + "a match against another machine with everybody named is played"); + + char const nameless_machine[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(!Judge(nameless_machine, sizeof(nameless_machine) - 1, 2, 8, fault), + "a person the file leaves unnamed is refused against other machines"); + + char const one_name[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=alpha\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(!Judge(one_name, sizeof(one_name) - 1, 2, 8, fault) && + fault.find("1") != std::string::npos && fault.find("2") != std::string::npos, + "two people under one name are refused however either is spelled"); + + char const alone[] = + "[Settings]\n" + "Side=0\n" + "Color=0\n"; + Check(Judge(alone, sizeof(alone) - 1, 2, 8, fault), + "somebody playing alone need not be named"); + + char const nobody[] = + "[GlobalFlags]\n" + "GlobalFlag0=yes\n"; + Check(!Judge(nobody, sizeof(nobody) - 1, 2, 8, fault), + "a file seating nobody at this machine is refused"); + + char const past_ai_difficulty[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIDifficulty=7\n"; + Check(!Judge(past_ai_difficulty, sizeof(past_ai_difficulty) - 1, 2, 8, fault), + "a computer difficulty the game does not have is refused"); + + char const unreachable[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n"; + Check(!Judge(unreachable, sizeof(unreachable) - 1, 2, 8, fault), + "a machine the file gives no port is refused"); + + char const nowhere[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.\n" + "Port=50002\n"; + Check(!Judge(nowhere, sizeof(nowhere) - 1, 2, 8, fault), + "an address naming no machine is refused"); + + char const tunnelled[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Port=50002\n" + "\n" + "[Tunnel]\n" + "Ip=88.99.11.22\n" + "Port=50010\n"; + Check(Judge(tunnelled, sizeof(tunnelled) - 1, 2, 8, fault), + "a tunnelled machine is named by its number rather than an address"); + + char const one_kept_name[] = + "[Settings]\n" + "Name=CommanderAlphaOmegaX\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=CommanderAlphaOmegaY\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(!Judge(one_kept_name, sizeof(one_kept_name) - 1, 2, 8, fault), + "two names the game keeps as one are refused"); + + char const unheld_ally[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "\n" + "[Multi1_Alliances]\n" + "HouseAllyOne=5\n"; + Check(!Judge(unheld_ally, sizeof(unheld_ally) - 1, 2, 8, fault), + "an alliance with a seat nobody occupies is refused"); + + char const past_seats[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "\n" + "[Multi1_Alliances]\n" + "HouseAllyOne=8\n"; + Check(!Judge(past_seats, sizeof(past_seats) - 1, 2, 8, fault), + "an alliance with a seat the match does not hold is refused"); + + char const watcher[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "\n" + "[IsSpectator]\n" + "Multi1=Yes\n"; + Check(!Judge(watcher, sizeof(watcher) - 1, 2, 8, fault), + "a seat that watches rather than plays is refused"); + + Check(!Judge(_Skirmish, sizeof(_Skirmish) - 1, 0, 8, fault), + "a match is refused rather than read against countries the rules never declared"); + } + + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); + return(Failures == 0 ? 0 : 1); +}