diff --git a/.gitignore b/.gitignore index 385ac1a4b2..e7c47fe2d6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ ChapterMaster.resource_order tmp/ stitch.config.json + + +test_framework/tests/logs +test_framework/tests/results +test_framework/tests/screenshots \ No newline at end of file diff --git a/scripts/scr_cheatcode/scr_cheatcode.gml b/scripts/scr_cheatcode/scr_cheatcode.gml index f802f062c5..db5edb9860 100644 --- a/scripts/scr_cheatcode/scr_cheatcode.gml +++ b/scripts/scr_cheatcode/scr_cheatcode.gml @@ -296,6 +296,9 @@ function scr_cheatcode(argument0) { var _fleet = get_nearest_player_fleet(0,0); add_ship_to_fleet(new_player_ship("Gloriana"),_fleet); break; + case "dumpstats": + scr_dump_stats(); + break; } } diff --git a/scripts/scr_save/scr_save.gml b/scripts/scr_save/scr_save.gml index e14ca9cc24..a09b1c6966 100644 --- a/scripts/scr_save/scr_save.gml +++ b/scripts/scr_save/scr_save.gml @@ -168,3 +168,91 @@ function scr_save(save_part,save_id, autosaving = false) { log_message($"Autosaving took {diff} seconds!"); } } + +/// Testing/debugging function that uses easily parsed ini format so that the test runner can +/// check things like number of marines per company per role, requistion, forge points etc +function scr_dump_stats(){ + var _marine_totals = { + company_0: { + total: 0, + chapter_master: 0, + honour_guard: 0, + veteran: 0, + terminator: 0, + captain: 0, + ancient: 0, + champion: 0, + apothecary: 0, + chaplain: 0, + librarian: 0, + tactical: 0, + assault: 0, + devastator: 0, + scout: 0, + dreadnought: 0, + } + } + for(var c = 1; c < 11; c++){ + _marine_totals[$$"company_{c}"] = variable_clone(_marine_totals.company_0); + } + + for(var coy = 0; coy < 11; coy++){ + for(var m = 0; m < 500; m++){ + if(obj_ini.name[coy][m] != ""){ + var _unit = fetch_unit([coy,m]); + if(_unit == ""){ + continue; + } + var _propname = $"company_{coy}"; + _marine_totals[$_propname][$"total"] += 1; + switch(_unit.role()){ + case obj_ini.role[100][eROLE.Ancient]: _marine_totals[$_propname][$"ancient"] += 1; break; + case obj_ini.role[100][eROLE.Apothecary]: _marine_totals[$_propname][$"apothecary"] += 1; break; + case obj_ini.role[100][eROLE.ChapterMaster]: _marine_totals[$_propname][$"chapter_master"] += 1; break; + case obj_ini.role[100][eROLE.Veteran]: _marine_totals[$_propname][$"veteran"] += 1; break; + case obj_ini.role[100][eROLE.Terminator]: _marine_totals[$_propname][$"terminator"] += 1; break; + case obj_ini.role[100][eROLE.Tactical]: _marine_totals[$_propname][$"tactical"] += 1; break; + case obj_ini.role[100][eROLE.Assault]: _marine_totals[$_propname][$"assault"] += 1; break; + case obj_ini.role[100][eROLE.Devastator]: _marine_totals[$_propname][$"devastator"] += 1; break; + case obj_ini.role[100][eROLE.Scout]: _marine_totals[$_propname][$"scout"] += 1; break; + case obj_ini.role[100][eROLE.Captain]: _marine_totals[$_propname][$"captain"] += 1; break; + case obj_ini.role[100][eROLE.Champion]: _marine_totals[$_propname][$"champion"] += 1; break; + case obj_ini.role[100][eROLE.Chaplain]: _marine_totals[$_propname][$"chaplain"] += 1; break; + case obj_ini.role[100][eROLE.Dreadnought]: _marine_totals[$_propname][$"dreadnought"] += 1; break; + case obj_ini.role[100][eROLE.Librarian]: _marine_totals[$_propname][$"librarian"] += 1; break; + case "Codiciery": _marine_totals[$_propname][$"librarian"] += 1; break; + case "Lexicanum": _marine_totals[$_propname][$"librarian"] += 1; break; + } + } + } + } + + // todo + var _ship_totals = { + battle_barge: 0, + strike_cruiser: 0, + hunter: 0, + gladius: 0 + } + + + + ini_open("gamestats.ini"); + ini_write_real("Resources", "requisition", obj_controller.requisition); + ini_write_real("Resources", "forge_points", obj_controller.forge_points); + ini_write_real("Resources", "apothecary_recruit_points", obj_controller.apothecary_recruit_points); + ini_write_real("Resources", "marines", obj_controller.marines); + + + for(var c = 0; c < 11; c ++){ + var stats = _marine_totals[$$"company_{c}"]; + var _role_names = struct_get_names(stats); + var _role_len = array_length(_role_names); + for (var k = 0; k < _role_len; k++){ + var _role_name = _role_names[k]; + var _val = stats[$_role_name]; + ini_write_real($"Marines.Coy{c}", _role_name, _val); + } + } + ini_close(); +} \ No newline at end of file diff --git a/test_framework/CMTestingFramework.ahk b/test_framework/CMTestingFramework.ahk new file mode 100644 index 0000000000..159a25197b --- /dev/null +++ b/test_framework/CMTestingFramework.ahk @@ -0,0 +1,597 @@ +; CM Testing Framework (AutoHotkey v2) +; This framework provides the foundation for automated testing of the CM application + +#Requires AutoHotkey v2.0 +#SingleInstance Force + +class CMTestingFramework { + ; Configuration + + __New() { + this.appPath := "D:\Documents\ChapterMaster\ChapterMaster.exe" ; Path to compiled ChapterMaster.exe file to run tests against + this.screenshotDir := A_ScriptDir "\screenshots\" ; Not in use yet + this.logDir := A_ScriptDir "\logs\" ; Stores all the framework logs and errors produced by the test runner + this.resultDir := A_ScriptDir "\results\" ; Stores all test results + this.appDataPath := "C:\Users\" . A_UserName . "\AppData\Local\ChapterMaster" ; The base path to CM user folder for saves, logs etc + this.errorLogPath := this.appDataPath . "\Logs" ; ChapterMaster logs and error files + this.savesPath := this.appDataPath . "\Save Files" ; ChapterMaster saves + this.savesIniPath := this.appDataPath . "\saves.ini" ; ChapterMaster save stats + this.gameStatsIniPath := this.appDataPath . "\gamestats.ini" ; Can be created by running the cheat code 'dumpstats' ingame, helpful for checking ingame values with the test runner + this.testSettingsPath := this.appDataPath . "\testing_options.ini" ; Configuration for this CMTestingFramework app, use to override certain settings per-developer/tester + + this.mouseSpeed := 8 ; 1-10, with 10 being slowest (more human-like) + this.keypressDelay := 50 ; number of milliseconds between keypresses when typing with .Send(text). + + ; State tracking + this.currentTest := "" + this.testSteps := [] + this.testResult := "" + this.testStartTime := 0 + this.testEndTime := 0 + ; Ensure directories exist + DirCreate(this.screenshotDir) + DirCreate(this.logDir) + DirCreate(this.resultDir) + + + if(FileExist(this.testSettingsPath)){ + prev_path := this.appPath + this.appPath := IniRead(this.testSettingsPath, "Config", "app_path", prev_path) + } + + ; UI Coordinate helpers + this.UIMap := CMUIMap() + + ; Initialize error handling + OnError(ObjBindMethod(this, "ErrorHandler")) + } + + ; Start CM application + LaunchApp() { + try { + Run(this.appPath) + WinWait("ahk_exe " . this.appPath) + this.LogStep("Application launched successfully") + return true + } catch as e { + this.LogError("Failed to launch application: " . e.Message) + return false + } + } + + CloseApp() { + try { + WinClose("ahk_exe " . this.appPath) + if(WinWaitClose("ahk_exe " . this.appPath, , 5) == 1){ + return true + } else { + this.LogError("Timeout while waiting to close app") + return false + } + } catch as e { + this.LogError("Failed to properly close application: " . e.Message) + return false + } + } + + ; Start a new test + StartTest(testName) { + this.currentTest := testName + this.testSteps := [] + this.testResult := "" + this.testStartTime := A_Now + + this.LogStep("Starting test: " . testName) + return this + } + + ; End current test and save results + EndTest(status := "Success") { + this.testEndTime := A_Now + duration := DateDiff(this.testEndTime, this.testStartTime, "Seconds") + + this.LogStep("Test completed in " . duration . " seconds") + + ; Create test result report + result := "# Test Report: " . this.currentTest . "`n" + result .= "* Date: " . FormatTime(A_Now, "yyyy-MM-dd") . "`n" + result .= "* Time: " . FormatTime(A_Now, "HH:mm:ss") . "`n" + result .= "* Duration: " . duration . " seconds`n" + result .= "* Status: " . status . "`n`n" + result .= "## Test Steps:`n" + + for step in this.testSteps { + result .= "* " . step . "`n" + } + + ; Save test result to file + fileName := this.resultDir . FormatTime(A_Now, "yyyyMMdd_HHmmss") . "_" . this.currentTest . ".md" + try { + f := FileOpen(fileName, "w").Write(result) + f.Close() + this.testResult := result + this.CloseApp() + return true + } catch as e { + this.LogError("Failed to save test results: " . e.Message) + this.CloseApp() + return false + } + + } + + ; Log a test step + LogStep(description) { + timestamp := FormatTime(A_Now, "HH:mm:ss") + step := timestamp . " - " . description + this.testSteps.Push(step) + FileAppend(step . "`n", this.logDir . "test_log.txt") + return this + } + + ; Log an error + LogError(errorMsg) { + timestamp := FormatTime(A_Now, "HH:mm:ss") + errorStep := timestamp . " - ERROR: " . errorMsg + this.testSteps.Push(errorStep) + FileAppend(errorStep . "`n", this.logDir . "error_log.txt") + return this + } + + ; Mouse Simulation Functions + + ; Move mouse in a human-like manner + MoveMouse(x, y) { + MouseMove(x, y, this.mouseSpeed) + this.LogStep("Mouse moved to coordinates: " . x . ", " . y) + return this + } + + ; Click at current position or specified coordinates + Click(x := "", y := "", button := "left") { + if (x != "" && y != "") { + this.MoveMouse(x, y) + Sleep(100) + } + + ; GameMaker behaves a lil funky with the instant clicks of AHK and sometimes they dont register + Click(button, 'D') + Sleep(30) + Click(button, 'U') + this.LogStep("Mouse " . button . "-clicked at current position") + + if (!this.CheckForCrash()) + return this + + } + + ; Double-click at current position or specified coordinates + DoubleClick(x := "", y := "", button := "left") { + if (x != "" && y != "") { + this.MoveMouse(x, y) + } + + Click(button " Double") + this.LogStep("Mouse double-" . button . "-clicked at current position") + + if (!this.CheckForCrash()) + return this + } + + ; Drag mouse from one position to another + DragMouse(x1, y1, x2, y2, button := "left") { + this.MoveMouse(x1, y1) + MouseClickDrag(button, x1, y1, x2, y2, this.mouseSpeed) + this.LogStep("Mouse dragged from " . x1 . ", " . y1 . " to " . x2 . ", " . y2) + + if (!this.CheckForCrash()) + return this + } + + ; Keyboard Simulation Functions + + ; Send text (typing) + SendText(text) { + SetKeyDelay(this.keypressDelay) + SendEvent(text) + this.LogStep("Text sent: " . text) + + if (!this.CheckForCrash()) + return this + } + + ; Send keyboard combo (like Ctrl+S) + SendCombo(keys) { + Send(keys) + this.LogStep("Key combination sent: " . keys) + + if (!this.CheckForCrash()) + return this + } + + ; Press and hold a key + KeyDown(key) { + Send("{" . key . " down}") + this.LogStep("Key down: " . key) + if (!this.CheckForCrash()) + return this + } + + ; Release a key + KeyUp(key) { + Send("{" . key . " up}") + this.LogStep("Key up: " . key) + if (!this.CheckForCrash()) + return this + } + + ; Wait for specified milliseconds + Wait(ms) { + Sleep(ms) + this.LogStep("Waited for " . ms . " ms") + if (!this.CheckForCrash()) + return this + } + + ; Wait for specified seconds + WaitSeconds(secs) { + ms := secs * 1000 + return this.Wait(ms) + } + + ; Screenshot Functions + + ; Take a screenshot of the current window + ; !!! NOT IMPLEMENTED + ; TakeScreenshot(description := "") { + ; if (description == "") + ; description := "screenshot" + + ; ; Create filename with timestamp + ; filename := FormatTime(A_Now, "yyyyMMdd_HHmmss") . "_" . this.currentTest . "_" . description . ".png" + ; path := this.screenshotDir . filename + + ; try { + ; ; Capture active window + ; WinGetPos(&x, &y, &w, &h, "A") + ; screenshot := Gui() + ; screenshot.Add("Picture", "w" . w . " h" . h, path) + ; WinCapture(x, y, w, h, path) + + ; this.LogStep("Screenshot saved: " . filename) + ; return path + ; } catch as e { + ; this.LogError("Failed to take screenshot: " . e.Message) + ; return "" + ; } + ; } + + ; Error Handling Functions + + ; Check if application is still running + IsAppRunning() { + return WinExist("ahk_exe " . this.appPath) + } + + ; Check for application crash + CheckForCrash() { + if (!this.IsAppRunning()) { + this.LogError("Application crashed during " . this.currentTest) + this.ReadErrorLogs() + this.EndTest("Failure") + return true + } else if(this.CheckForCrashDialog()) { + this.LogError("Crash dialog detected via window class") + this.ReadErrorLogs() + this.CloseDialog() + this.EndTest("Failure") + return true + } + return false + } + + CheckForCrashDialog() { + ; Check if any dialog from the app exists with specific window class + if (WinExist("ahk_exe " . this.appPath . " ahk_class #32770")) { ; #32770 is common for dialogs + return true + } + return false + } + + CloseDialog() { + ; Check if any dialog from the app exists with specific window class + if (WinExist("ahk_exe " . this.appPath . " ahk_class #32770")) { ; #32770 is common for dialogs + WinClose("ahk_exe " . this.appPath . " ahk_class #32770") + WinWaitClose("ahk_exe " . this.appPath . " ahk_class #32770") + } + return + } + + ; Read error logs after crash + ReadErrorLogs() { + try { + ; Find the most recent error log + ; errorLogFiles := [] + + latestFile := {path: "", time: 0} + loop files, this.errorLogPath . "\*_error.log" { + if(latestFile.time < A_LoopFileTimeModified){ + latestFile.path := A_LoopFileFullPath + latestFile.time := A_LoopFileTimeModified + } + } + + if (latestFile.time > 0) { + latestLog := latestFile.path + logContent := FileRead(latestLog) + + ; Extract relevant information + this.LogStep("Error log found: " . latestLog) + this.LogError("Error log content: " . SubStr(logContent, 1, 1000) . (StrLen(logContent) > 1000 ? "..." : + "")) + + ; Save error log with test results + FileCopy(latestLog, this.resultDir . FormatTime(A_Now, "yyyyMMdd_HHmmss") . "_error.log") + } else { + this.LogError("No error logs found in " . this.errorLogPath) + } + } catch as e { + this.LogError("Failed to read error logs: " . e.Message) + } + } + + ; !NOT IMPLEMENTED + ; ReadSaveFile(slot := 1) { + ; saveFile := this.savesPath . "\save" . slot . ".json" + ; saveFileString := FileRead(saveFile) + + ; FileCopy(saveFile, this.resultDir . FormatTime(A_Now, "yyyyMMdd_HHmmss") . "_savefile.json") + ; saveObject := JSON.parse(saveFileString, false, false) + ; return saveObject.Save + ; } + + ; General error handler + ErrorHandler(exception, mode) { + this.LogError("Framework error: " . exception.Message) + ; this.TakeScreenshot("framework_error") + return true ; Continue running + } + + ; Restart the application + RestartApp() { + if (this.IsAppRunning()) { + WinClose("ahk_exe " . this.appPath) + WinWaitClose("ahk_exe " . this.appPath, , 5) + } + + return this.LaunchApp() + } + + + ; Click on an element based on its name in CMUIMap + ; framework.ClickElement("MainMenu.NewGame") + ClickElement(elementPath) { + element := this.UIMap.GetElement(elementPath) + if (element) { + this.Click(element.x, element.y) + this.LogStep("Clicked element: " . elementPath) + return this + } else { + this.LogError("Element not found: " . elementPath) + return this + } + } + + MoveToElement(elementPath) { + element := this.UIMap.GetElement(elementPath) + if (element) { + this.MoveMouse(element.x, element.y) + this.LogStep("Moved to element: " . elementPath) + return this + } else { + this.LogError("Element not found: " . elementPath) + return this + } + } + + ; Element Discovery Tool + DiscoverUIElements() { + ; Create a GUI + discoveryGui := Gui(, "UI Element Discovery Tool") + discoveryGui.Add("Text", , "Press F7 to capture mouse position") + discoveryGui.Add("Text", "vCoordinates w200 h20", "X: 0, Y: 0") + discoveryGui.Add("Text", , "Section Name:") + ; Should match CMUIMap top level sections + discoveryGui.Add("DropDownList", "vSectionName w150", ["MainMenu", "Creation", "GameScreen", "InGameMenu", "SaveMenu", "LoadMenu", "Apothecarium", "Armamentarium", "Fleet", "Diplomacy"]) + discoveryGui.Add("Text", , "Element Name:") + discoveryGui.Add("Edit", "vElementName w150") + discoveryGui.Add("Button", "Default", "Save").OnEvent("Click", SavePosition) + discoveryGui.Show() + + ; Set up hotkey + Hotkey("F7", CapturePosition) + + CapturePosition(*) { + MouseGetPos(&x, &y) + discoveryGui["Coordinates"].Value := "{x: " . x . ", y: " . y . "}" + } + + SavePosition(*) { + ; MouseGetPos(&x, &y) + sectionName := discoveryGui["SectionName"].Value + elementName := discoveryGui["ElementName"].Value + + if (sectionName && elementName) { + ; Append to UI map file + FileAppend("this." . sectionName . "." . elementName . " := " . discoveryGui["Coordinates"].Value . "`n", + A_ScriptDir . "\ui_elements.txt") + MsgBox("Saved " . sectionName . "." . elementName . " at " . discoveryGui["Coordinates"].Value) + } else { + MsgBox("Please enter both section and element names") + } + } + } + + ;;; This section contains some snippets of common things to quickly get a test running + + ; Quick start new game as given chapter + ; chapterName must have coords registered in CMUIMap already + StartGameAs(chapterName := ""){ + if(chapterName == ""){ + this.LogError("No chapter name provided to StartGameAs") + return false + } + + this.Wait(7000) + .ClickElement("MainMenu.NewGame") + .Wait(8500) + .ClickElement("Creation." . chapterName) + .Wait(3000) + .ClickElement("Creation.SkipArrow") + .Wait(4000) + .Click(500, 400) + .Click(500, 400) + .Click(500, 400) + .Click(500, 400) + .Click(500, 400) + .Wait(1000) + return this + } + + ; Need to make sure save slots 1 2 and 3 are registered in CMUIMap + SaveGameToSlot(slotNum := "1"){ + this.ClickElement("GameScreen.Menu") + .ClickElement("InGameMenu.Save") + .ClickElement("SaveMenu.SaveSlot" . slotNum) + return this + } + + ; must be called from the normal game screen + DumpGameStats(){ + this.SendText("p") + .WaitSeconds(1) + .SendText("dumpstats") + .WaitSeconds(1) + .SendCombo("{enter}") + + return this + } + + ; Copy gamestats.ini to the test results folder so that they can be inspected upon failure + CopyGameStats(){ + FileCopy(this.gameStatsIniPath, this.resultDir . FormatTime(A_Now, "yyyyMMdd_HHmmss") . "_gamestats.ini") + } +} + +; This class stores xy coordinates for common game elements and buttons +; All coordinates assume a game size of 1280x720 +class CMUIMap { + ; Add to this list when a new category of ui elements is wanted + __Init(){ + this.MainMenu := {} + this.Creation := {} + this.GameScreen := {} + this.InGameMenu := {} + this.SaveMenu := {} + this.LoadMenu := {} + this.ChapterManagement := {} + this.Apothecarium := {} + this.Armamentarium := {} + this.Fleet := {} + this.Diplomacy := {} + } + + ; To populate new coords, use DiscoverUIElements.ahk, see HowToUse.md for instructions + __New() { + this.MainMenu.NewGame := { x: 629, y: 422 } + this.MainMenu.LoadGame := { x: 625, y: 452 } + this.MainMenu.Options := { x: 634, y: 487 } + this.MainMenu.Exit := { x: 634, y: 522 } + this.Creation.DarkAngels := { x: 371, y: 155 } + this.Creation.BlackTemplars := { x: 388, y: 248 } + this.Creation.CustomSlot1 := { x: 373, y: 422 } + this.Creation.Deathwatch := { x: 539, y: 517 } + this.Creation.Lamenters := { x: 546, y: 249 } + this.Creation.AngryMarines := { x: 373, y: 524 } + this.Creation.CreateCustom := { x: 625, y: 519 } + this.Creation.CreateRandom := { x: 679, y: 521 } + this.Creation.NextArrow := { x: 760, y: 631 } + this.Creation.BackArrow := { x: 523, y: 639 } + this.Creation.SkipArrow := { x: 822, y: 635 } + this.Creation.AdvSlot1 := { x: 369, y: 479 } + this.Creation.DisadvSlot1 := { x: 680, y: 481 } + this.Creation.Homeworld := { x: 430, y: 203 } + this.Creation.FleetBased := { x: 630, y: 202 } + this.Creation.Penitent := { x: 778, y: 201 } + this.Creation.Leader := { x: 388, y: 581 } + this.Creation.Champion := { x: 584, y: 583 } + this.Creation.Psyker := { x: 775, y: 582 } + this.Creation.StrengthUp := { x: 388, y: 273 } + this.Creation.StrengthDown := { x: 363, y: 276 } + this.Creation.CooperationUp := { x: 391, y: 321 } + this.Creation.CooperationDown := { x: 359, y: 321 } + this.Creation.PurityUp := { x: 390, y: 367 } + this.Creation.PurityDown := { x: 356, y: 364 } + this.Creation.StabilityUp := { x: 388, y: 405 } + this.Creation.StabilityDown := { x: 367, y: 407 } + this.Creation.Obliterated := { x: 384, y: 416 } + this.GameScreen.ChapterManagement := { x: 95, y: 686 } + this.GameScreen.ChapterSettings := { x: 197, y: 687 } + this.GameScreen.Apothecarium := { x: 342, y: 688 } + this.GameScreen.Reclusium := { x: 414, y: 690 } + this.GameScreen.Librarium := { x: 518, y: 690 } + this.GameScreen.Armamentarium := { x: 610, y: 693 } + this.GameScreen.Recruitment := { x: 705, y: 689 } + this.GameScreen.Fleet := { x: 792, y: 692 } + this.GameScreen.Diplomacy := { x: 952, y: 691 } + this.GameScreen.EventLog := { x: 1075, y: 683 } + this.GameScreen.EndTurn := { x: 1190, y: 690 } + this.GameScreen.Help := { x: 1139, y: 27 } + this.GameScreen.Menu := { x: 1226, y: 25 } + this.InGameMenu.Save := { x: 745, y: 231 } + this.InGameMenu.Load := { x: 753, y: 298 } + this.InGameMenu.Options := { x: 750, y: 363 } + this.InGameMenu.Exit := { x: 749, y: 422 } + this.InGameMenu.Return := { x: 746, y: 551 } + this.SaveMenu.SaveSlot1 := { x: 1109, y: 362 } + this.LoadMenu.LoadSlot1 := { x: 1109, y: 362 } + this.LoadMenu.LoadAutosave := { x: 1127, y: 241 } + this.ChapterManagement.Headquarters := { x: 632, y: 173 } + this.ChapterManagement.Company1 := { x: 79, y: 419 } + this.ChapterManagement.Company10 := { x: 1189, y: 362 } + this.ChapterManagement.Company5 := { x: 575, y: 378 } + this.ChapterManagement.SquadView := { x: 862, y: 137 } + this.ChapterManagement.ShowProfile := { x: 979, y: 134 } + this.ChapterManagement.SelectAll := { x: 870, y: 502 } + this.Apothecarium.AddTestSlave := { x: 370, y: 642 } + this.Armamentarium.EnterForge := { x: 495, y: 322 } + this.Armamentarium.Equipment := { x: 816, y: 67 } + this.Armamentarium.Armour := { x: 898, y: 64 } + this.Armamentarium.Vehicles := { x: 1002, y: 72 } + this.Armamentarium.Ships := { x: 1218, y: 70 } + this.Fleet.ShipSlot1 := { x: 882, y: 93 } + this.Diplomacy.ImperiumAudience := { x: 244, y: 275 } + this.Diplomacy.MechanicusAudience := { x: 235, y: 385 } + this.Diplomacy.MeetChaosEmmisary := { x: 691, y: 189 } + + + ; Add more sections and elements as needed + } + + ; Get element coordinates by path (e.g., "MainMenu.NewGame") + GetElement(path) { + parts := StrSplit(path, ".") + if (parts.Length != 2) + return false + + section := parts[1] + element := parts[2] + + if (this.HasOwnProp(section)) { + sectionObj := this.%section% + if (sectionObj.HasOwnProp(element)) + return sectionObj.%element% + } + return false + } +} diff --git a/test_framework/DiscoverUIElements.ahk b/test_framework/DiscoverUIElements.ahk new file mode 100644 index 0000000000..69ce4fb316 --- /dev/null +++ b/test_framework/DiscoverUIElements.ahk @@ -0,0 +1,21 @@ +; UI Element Discovery Tool +; This test case demonstrates how to use the CMTestingFramework +#Requires AutoHotkey v2.0 +#Include CMTestingFramework.ahk + +DiscoverUIElementsRunner() { + ; Initialize testing framework + framework := CMTestingFramework() + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + framework.DiscoverUIElements() + +} + +; Run the test +DiscoverUIElementsRunner() diff --git a/test_framework/HowToUse.md b/test_framework/HowToUse.md new file mode 100644 index 0000000000..5c842efe79 --- /dev/null +++ b/test_framework/HowToUse.md @@ -0,0 +1,296 @@ +# Creating Test Cases for CM Using AutoHotkey Testing Framework + +This document provides guidance for CM developers on how to create automated test cases using our AutoHotkey v2 testing framework. + +## Getting Started + +1. **Install Prerequisites** + - Install AutoHotkey v2 from [https://www.autohotkey.com/](https://www.autohotkey.com/) + - Ensure you have access to the main CMTestingFramework.ahk file + +2. **File Structure** + - Place your test case scripts in the `tests` folder of the `test_framework` directory in the repo + - Name your test scripts descriptively (e.g., `TestObliteratedCustomStart.ahk`, `TestMenuChecks.ahk`) + - Tests file names need to start with `Test` in order to be picked up by the test suite runner that runs all tests together + - Test filenames and test names and ahk function names dont have to line up, but its easier for everyone if they do + - Test runner needs to be pointed at a compiled ChapterMaster.exe file, see `CMTestingFramework.appPath` + - use `testing_options.ini` in the %LocalAppData%/ChapterMaster folder to override the path of the ChapterMaster.exe file for your machine if different + +3. **Game Settings** + - The test runner assumes you are running in **Windowed 720p** in order for pixels to line up. + - Go to your %LocalAppData%\ChapterMaster folder and open `saves.ini` and look for the [Settings] section + - If it says "fullscreen", run ChapterMaster and turn off Fullscreen in the options + - Close ChapterMaster and open `saves.ini` again. You should see 4 numbers separated by `|` pipes. Edit the last 2 to be `1280|720`. The first 2 are just the x,y coordinates of the top left corner of the window on your monitor. + +4. **Running Tests individually** + - Open the repo, go to `test_framework/tests` and open the desired test + - Easy Method: + - Ensure you have installed the VSCode extension [AHK++ (AutoHotkey Plus Plus)](https://marketplace.visualstudio.com/items/?itemName=mark-wiemer.vscode-autohotkey-plus-plus) + - Open the test case file in VSCode (not the framework) + - Right-click anywhere in the file, select `AHK++ > Run AHK Script` + - Using this method keeps you in the editor while you're working on your test case + - AHK native method + - In the file explorer, right-click your test case .ahk file and select "Run Script" (should be first option) + - Better for running tests if you're not editing them + - Results are stored in `test_framework/tests/results` and should be sorted by date + - Errors caught by the test framework are stored in `test_framework/tests/logs/error_log.txt` which includes both game crashes and errors with the .ahk tests themselves + +5. **Running all tests as a Suite** + - Alongside CMTestingFramework.ahk should be a file **RunAllTests.ahk**. Running this file using the method above will execute all test files in the `tests` folder whose filenames start with "Test" + - Proper stats on test suite pass/fail/error are wip + +## Creating a Basic Test Case + +Each test script follows this structure: + +```autohotkey +; Test Name: [Test Name] +; Description: [Brief description of what this test does] +; Author: [Your Name] +; Date: [Creation/Last Modified Date] + +#Requires AutoHotkey v2.0 +#Include ..\CMTestingFramework.ahk + +TestMyFeature() { + ; Initialize framework + framework := CMTestingFramework() + + ; Start test + framework.StartTest("MyFeatureName") + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + ; [Your test steps here] + + ; End test and save results + framework.EndTest() +} + +; Run the test +TestMyFeature() +``` + +## Key Framework Methods + +Here are the core methods to use in your test cases: + +### Setup and Cleanup +- `framework.StartTest("TestName")` - Initialize a test with a name +- `framework.LaunchApp()` - Launch the CM application +- `framework.EndTest()` - Complete the test and save results +- `framework.CloseApp()` - Exit CM but keep the test running until `EndTest()` is called + +### Mouse Interaction +The `Click`, `DoubleClick`, and `ClickElement` functions have a small builtin wait timer so that you dont have to call Wait explicitly after every click if you dont actually have to wait for something to happen before moving on. +The property `mouseSpeed` on CMTestingFramework controls how fast the mouse moves. Its default setting is pretty slow, high speeds can cause clicks to not register properly sometimes. +To edit the mouse speed for a single test, you can use the below snippet: +```autohotkey +framework := CMTestingFramework() +framework.mouseSpeed := 2 ; 1 - 10, 10 being slowest +``` +- `framework.Click(x, y, button="left")` - Moves Mouse to and left-Clicks at specific coordinates. +- `framework.DoubleClick(x, y, button="left")` - Double-click at coordinates +- `framework.MoveMouse(x, y)` - Move mouse to coordinates +- `framework.DragMouse(x1, y1, x2, y2)` - Click and drag from one point to another +- `framework.ClickElement("Section.ElementName")` - Click on a named UI element +- `framework.MoveToElement("Section.ElementName")` - Move mouse to a named UI element + +### Keyboard Interaction +The property `keypressDelay` on CMTestingFramework controls how fast each keystroke is sent. Its default setting is pretty average, at 50 milliseconds between keypresses. You can increase or decrease this per test. +`keypressDelay` only affects `SendText()` +To edit the keypress speed for a single test, you can use the below snippet: +```autohotkey +framework := CMTestingFramework() +framework.keypressDelay := 10 ; number of milliseconds between keypresses when using .SendText() +``` + +- `framework.SendText("text")` - Type text +- `framework.SendCombo("{Ctrl down}{s}{Ctrl up}")` - Send key combinations +- `framework.KeyDown("key")` and `framework.KeyUp("key")` - Press and release keys + +### Premade Step Sequences +- `framework.StartGameAs("ChapterName")` - Opens game, clicks New Game, selects ChapterName, Skips through Creation screen, dismisses intro sprawl +- `framework.SaveToSlot("1")` - Opens the ingame menu and saves the game to the slot specified. Only works for slots 1 - 3 +- `framework.DumpGameStats()` - triggers an ingame cheatcode to export some game values to a file called `gamestats.ini` which can be read from to check that ingame values are matching expectations. +- `framework.CopyGameStats()` - copies the `gamestats.ini` file into the test results folder, which can be handy if you want to manually inspect values after running a test + +### Wait and Timing +- `framework.Wait(milliseconds)` - Pause execution for specified time in ms +- `framework.WaitSeconds(seconds)` - Pause execution for specified time in seconds + +### Documentation +- `framework.LogStep("description")` - Record a test step with description +- `framework.LogError("description")` - Record a test step error with description. Builtin methods will do this if they fail to perform an action like clicking or if the game crashes. + +### Error Handling +- `framework.CheckForCrash()` - Detect if application crashed +- `framework.CheckForCrashDialog()` - Automatically called by `CheckForCrash()`, this method specifically checks if the Crash popup window was created. +- `framework.RestartApp()` - Restart the application after a crash + + +## Using Named UI Elements + +Instead of using raw coordinates, you can reference UI elements by name: + +### Using ClickElement and MoveToElement + +These methods allow you to interact with UI elements by name rather than remembering coordinates: + +```autohotkey +; Click on a button using its name +framework.ClickElement("MainMenu.NewGame") + +; Move to an element without clicking +framework.MoveToElement("GameScreen.SaveGame") +``` + +The format is always `"Section.ElementName"`, where: +- `Section` is a logical grouping of UI elements (e.g., MainMenu, GameScreen, Dialog) +- `ElementName` is the specific element within that section + +### Benefits of Using Named Elements + +- **Readability**: Tests are more descriptive and easier to understand +- **Maintainability**: If UI coordinates change, you only need to update the UI Map, not each test +- **Consistency**: Ensures the same element is always referenced the same way + +### Example with Named Elements + +```autohotkey +framework.StartTest("StartGameWithNamedElements") + .LaunchApp() + .Wait(7000) + .ClickElement("MainMenu.NewGame") + .Wait(8000) + .ClickElement("Creation.DarkAngels") + .Wait(3000) + .ClickElement("Creation.SkipArrow") + .Wait(2000) + .EndTest() +``` + +## Discovering UI Element Coordinates + +### Using the DiscoverUIElements Tool + +The framework includes a tool to help identify and record UI element coordinates: + +1. **Launch the Discovery Tool** + - Open `DiscoverUIElements.ahk` and run with AHK++ or just run the script from windows explorer + +2. **Using the Tool** + - Position your mouse over a UI element in the CM application + - Make sure the window is in focus, you can click in the game window to make sure it is + - Press F7 to capture the current mouse coordinates + - Enter a Section Name (e.g., "MainMenu") + - Enter an Element Name (e.g., "NewGameButton") + - Click "Save" + +3. **Finding the Saved Coordinates** + - The tool saves coordinates to `ui_elements.txt` in your script directory + - Each entry is formatted for easy pasting into the `CMUIMap` class + +4. **After Capturing Elements** + - Copy the contents of `ui_elements.txt` + - Add them to the `CMUIMap` class in the appropriate sections + - Use your newly mapped elements in tests with `ClickElement()` and `MoveToElement()` + - If you need to create a new Section, you need to update `CMTestingFramework.DiscoverUIElements` method and `CMUIMap` class in CMTestingFramework.ahk to add the section otherwise you will get .ahk compiler errors when running tests + +### Manual Coordinate Finding + +You can still use AutoHotkey's Window Spy as described below: + +1. Use AutoHotkey's built-in Window Spy tool (right-click the AutoHotkey icon in the system tray and select "Window Spy") +2. Hover your mouse over the UI element you want to interact with +3. Note the "Mouse Position" coordinates shown in Window Spy. Use the `client` value which should say `default` next to it. + +## Example Test Case Using Named Elements + +Here's a complete example test case using named UI elements: + +```autohotkey +#Requires AutoHotkey v2.0 +#Include ..\CMTestingFramework.ahk + +TestStartGame() { + ; Initialize testing framework + framework := CMTestingFramework() + + ; Start test + framework.StartTest("StartGame") + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + ; Wait for application to fully load + framework.Wait(7000) + .TakeScreenshot("Main_Menu") + + framework.ClickElement("MainMenu.NewGame") ; Click on "New Game" using named element + .Wait(8000) + .TakeScreenshot("Creation") + + framework.ClickElement("Creation.DarkAngels") ; Click Dark Angels by name + .Wait(3000) + .ClickElement("Creation.NextArrow") ; Click Next button + .Wait(2000) + .ClickElement("Creation.NextArrow") ; Click Next button again + .Wait(2000) + .ClickElement("Creation.NextArrow") ; Click Next button again + .Wait(2000) + .ClickElement("Creation.NextArrow") ; Click Next button again + .Wait(2000) + .ClickElement("Creation.NextArrow") ; Click Next button again + .Wait(2000) + .ClickElement("Creation.NextArrow") ; Click Next button again + .Wait(2000) + .TakeScreenshot("Game_Started") + + ; End test and save results + framework.EndTest() + + MsgBox("Test completed. Results saved to: " . framework.resultDir) +} + +; Run the test +TestStartGame() +``` + +## Best Practices + +1. **Use Named Elements**: Prefer `ClickElement()` over raw coordinates when possible + +2. **Map New Elements**: When encountering a new UI element, add it to the UI Map + +3. **Start Simple**: Begin with basic workflows before attempting complex scenarios + +4. **Add Delays**: Include sufficient wait times between actions (use `framework.Wait()`) + +5. **Handle Errors**: The framework will report hard errors and crashes, but you can use `framework.LogStep()` and `framework.LogError()` to write your own output to the test file at any point during a test to clarify what went right/wrong + +6. **Organize by Workflow**: Create separate test files for different functional areas + +7. **Comment Your Code**: Include clear comments explaining the purpose of each step + +8. **Use Consistent Naming**: Name your tests and files with clear, descriptive names + +9. **Test One Thing**: Each test should focus on a single feature or workflow + +## Troubleshooting + +- **Coordinate Issues**: If clicks aren't hitting the right spots, first make sure you're running in **Windowed 720p**, see Getting Started section 3. Verify coordinates in Window Spy or use the DiscoverUIElements tool if still having issues +- **Timing Problems**: Increase wait times if actions seem to execute before the app is ready. You can slow down the mouse speed and keyboard typing speed too if needed on a per-test basis. See Mouse Interaction and Keyboard Interaction sections +- **Application Not Found**: Check the application path in CMTestingFramework.ahk. You can overwrite this value with an `testing_options.ini` file in you %LocalAppData%/ChapterMaster folder +- **Element Not Found Error**: Verify the element path is correct (e.g., "MainMenu.NewGame") + +## Getting Help + +If you encounter issues creating test cases, contact @VanWeapon in the discord. diff --git a/test_framework/RunAllTests.ahk b/test_framework/RunAllTests.ahk new file mode 100644 index 0000000000..f8dc441fed --- /dev/null +++ b/test_framework/RunAllTests.ahk @@ -0,0 +1,73 @@ +; Executes all test files in the /tests folder which start with "Test" + +#Requires AutoHotkey v2.0 +#SingleInstance Force + +RunAllTests() { + ; Get the script's directory + scriptDir := A_ScriptDir + + ; Define the tests directory + testsDir := scriptDir . "\tests" + + ; Check if the tests directory exists + if (!DirExist(testsDir)) { + MsgBox("Tests directory not found: " . testsDir) + return + } + + ; Array to hold test results + testResults := [] + + ; Find all .ahk files that begin with "Test" + testFiles := [] + loop files, testsDir . "\Test*.ahk" { + testFiles.Push(A_LoopFileFullPath) + } + + ; Check if any test files were found + if (testFiles.Length = 0) { + MsgBox("No test files found starting with 'Test' in: " . testsDir) + return + } + + ; Display progress + MsgBox("Found " . testFiles.Length . " test files. Running tests in sequence...") + + DirCreate(scriptDir . "\tests\results") + ; Create a log file for results + logFile := scriptDir . "\tests\results\TestSuiteResults_" . FormatTime(A_Now, "yyyyMMdd_HHmmss") . ".log" + FileAppend("CM Test Suite Run - " . FormatTime(A_Now, "yyyy-MM-dd HH:mm:ss") . "`n`n", logFile) + + ; Run each test file + for testFile in testFiles { + testName := GetFileNameWithoutExtension(testFile) + + FileAppend("Running test: " . testName . "...`n", logFile) + + ; Run the test and wait for it to complete + RunWait('"' . A_AhkPath . '" "' . testFile . '"') + + ; Test completed + FileAppend("Completed test: " . testName . "`n`n", logFile) + testResults.Push({ name: testName, status: "Completed" }) + } + + ; Output summary + summary := "Test Run Summary:`n" + for testResult in testResults { + summary .= testResult.name . ": " . testResult.status . "`n" + } + + FileAppend("`n" . summary, logFile) + ; MsgBox("All tests completed. Results saved to: " . logFile) +} + +; Helper function to get file name without extension +GetFileNameWithoutExtension(filePath) { + SplitPath(filePath, &fileName, ,) + return StrReplace(fileName, ".ahk", "") +} + +; Run the function +RunAllTests() \ No newline at end of file diff --git a/test_framework/tests/Boilerplate.ahk b/test_framework/tests/Boilerplate.ahk new file mode 100644 index 0000000000..1643cbc7b7 --- /dev/null +++ b/test_framework/tests/Boilerplate.ahk @@ -0,0 +1,26 @@ +; Copy/paste this layout for easy test creation from scratch + +#Requires AutoHotkey v2.0 +#Include ..\CMTestingFramework.ahk + +TestBoilerplate() { + ; Initialize testing framework + framework := CMTestingFramework() + + ; Start test + framework.StartTest("StartGame") + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + ; Write steps here + + ; End test and save results + framework.EndTest() +} + +; Run the test +TestBoilerplate() diff --git a/test_framework/tests/TestObliteratedStart.ahk b/test_framework/tests/TestObliteratedStart.ahk new file mode 100644 index 0000000000..e53508208a --- /dev/null +++ b/test_framework/tests/TestObliteratedStart.ahk @@ -0,0 +1,51 @@ +; This test case launches a new custom chapter with the obliterated start + +#Requires AutoHotkey v2.0 +#Include ..\CMTestingFramework.ahk + +TestObliteratedStart() { + ; Initialize testing framework + framework := CMTestingFramework() + + ; Start test + framework.StartTest("ObliteratedStart") + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + ; Wait for application to fully load + framework.Wait(7000) + .ClickElement("MainMenu.NewGame") ; Click on "New Game" in the main menu + .Wait(8500) + .ClickElement("Creation.CreateCustom") ; Click Create Custom Chapter + .Wait(3000) + .ClickElement("Creation.Homeworld") + .ClickElement("Creation.PurityUp") + .ClickElement("Creation.PurityUp") + .ClickElement("Creation.PurityUp") + .ClickElement("Creation.PurityUp") + .ClickElement("Creation.PurityUp") + .ClickElement("Creation.DisadvSlot1") + .ClickElement("Creation.Obliterated") + .ClickElement("Creation.NextArrow").WaitSeconds(2) + .ClickElement("Creation.NextArrow").WaitSeconds(2) + .ClickElement("Creation.NextArrow").WaitSeconds(2) + .ClickElement("Creation.NextArrow").WaitSeconds(2) + .ClickElement("Creation.NextArrow").WaitSeconds(3) + .ClickElement("GameScreen.EndTurn") + .ClickElement("GameScreen.EndTurn") + .ClickElement("GameScreen.EndTurn") + .ClickElement("GameScreen.EndTurn") + .ClickElement("GameScreen.EndTurn") + .ClickElement("GameScreen.EndTurn") + .WaitSeconds(1) + + ; End test and save results + framework.EndTest() +} + +; Run the test +TestObliteratedStart() diff --git a/test_framework/tests/TestReadSaveGame.ahk b/test_framework/tests/TestReadSaveGame.ahk new file mode 100644 index 0000000000..a577a8cc77 --- /dev/null +++ b/test_framework/tests/TestReadSaveGame.ahk @@ -0,0 +1,49 @@ +; This test case demonstrates how to use the CMTestingFramework + +#Requires AutoHotkey v2.0 +#Include ..\CMTestingFramework.ahk + +TestSaveGame() { + ; Initialize testing framework + framework := CMTestingFramework() + framework.StartTest("LamentersStartStats") + status := "Success" + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + framework.StartGameAs("Lamenters") + .DumpGameStats() + .Wait(1000) + + requisition := IniRead(framework.gameStatsIniPath, "Resources", "requisition") + + if (Integer(requisition) > 500) { + framework.LogStep("Step Failed: Requisition should be 500 for Lamenters") + status := "Failure" + } else { + framework.LogStep("Requisition matched expected value") + } + + coy_5_marines := IniRead(framework.gameStatsIniPath, "Marines.Coy5", "total") + + if (Integer(coy_5_marines) > 0) { + framework.LogStep("Step Failed: Lamenters should not have any marines in company 5") + status := "Failure" + } else { + framework.LogStep("Coy 5 marines matched expected value") + } + + if(status == "Failure"){ + framework.CopyGameStats() + } + + ; End test and save results + framework.EndTest(status) +} + +; Run the test +TestSaveGame() \ No newline at end of file diff --git a/test_framework/tests/TestStartGame.ahk b/test_framework/tests/TestStartGame.ahk new file mode 100644 index 0000000000..2d4ea3b379 --- /dev/null +++ b/test_framework/tests/TestStartGame.ahk @@ -0,0 +1,37 @@ +; This test case demonstrates how to use the CMTestingFramework + +#Requires AutoHotkey v2.0 +#Include ..\CMTestingFramework.ahk + +TestStartGame() { + ; Initialize testing framework + framework := CMTestingFramework() + + ; Start test + framework.StartTest("StartGame") + + ; Launch CM application + if (!framework.LaunchApp()) { + MsgBox("Failed to launch application") + return + } + + ; Wait for application to fully load + framework.StartGameAs("DarkAngels") + .ClickElement("GameScreen.ChapterManagement") + .Wait(1000) + .ClickElement("GameScreen.ChapterSettings") + .Wait(1000) + .ClickElement("GameScreen.Fleet") + .Wait(2000) + .ClickElement("GameScreen.Fleet") + .Wait(1000) + .ClickElement("GameScreen.EndTurn") + .Wait(2000) ; waiting at the end checks for crashing + + ; End test and save results + framework.EndTest() +} + +; Run the test +TestStartGame() diff --git a/test_framework/ui_elements.txt b/test_framework/ui_elements.txt new file mode 100644 index 0000000000..f12c634a35 --- /dev/null +++ b/test_framework/ui_elements.txt @@ -0,0 +1,73 @@ +this.MainMenu.NewGame := {x: 41, y: 168} +this.MainMenu.NewGame := X: 675, Y: 419 +this.MainMenu.NewGame := {x: 629, y: 422} +this.MainMenu.LoadGame := {x: -820, y: 187} +this.MainMenu.LoadGame := {x: 625, y: 452} +this.MainMenu.Options := {x: 634, y: 487} +this.MainMenu.Exit := {x: 634, y: 522} +this.Creation.DarkAngels := {x: 371, y: 155} +this.Creation.BlackTemplars := {x: 388, y: 248} +this.Creation.CustomSlot1 := {x: 373, y: 422} +this.Creation.CreateCustom := {x: 625, y: 519} +this.Creation.CreateRandom := {x: 679, y: 521} +this.Creation.Deathwatch := {x: 539, y: 517} +this.Creation.AngryMarines := {x: 373, y: 524} +this.Creation.NextArrow := {x: 760, y: 631} +this.Creation.BackArrow := {x: 523, y: 639} +this.Creation.SkipArrow := {x: 822, y: 635} +this.Creation.AdvSlot1 := {x: 369, y: 479} +this.Creation.DisadvSlot1 := {x: 680, y: 481} +this.Creation.Slide1Homeworld := {x: 430, y: 203} +this.Creation.Slide1FleetBased := {x: 630, y: 202} +this.Creation.Slide1Penitent := {x: 778, y: 201} +this.Creation.Slide6Leader := {x: 388, y: 581} +this.Creation.Slide6Champion := {x: 584, y: 583} +this.Creation.Slide6Psyker := {x: 775, y: 582} +this.GameScreen.ChapterManagement := {x: 95, y: 686} +this.GameScreen.ChapterSettings := {x: 197, y: 687} +this.GameScreen.Apothecarium := {x: 342, y: 688} +this.GameScreen.Reclusium := {x: 414, y: 690} +this.GameScreen.Librarium := {x: 518, y: 690} +this.GameScreen.Armamentarium := {x: 610, y: 693} +this.GameScreen.Recruitment := {x: 705, y: 689} +this.GameScreen.Fleet := {x: 792, y: 692} +this.GameScreen.Diplomacy := {x: 952, y: 691} +this.GameScreen.EventLog := {x: 1075, y: 683} +this.GameScreen.EndTurn := {x: 1190, y: 690} +this.GameScreen.Help := {x: 1139, y: 27} +this.GameScreen.Menu := {x: 1226, y: 25} +this.InGameMenu.Save := {x: 745, y: 231} +this.InGameMenu.Load := {x: 753, y: 298} +this.InGameMenu.Options := {x: 750, y: 363} +this.InGameMenu.Exit := {x: 749, y: 422} +this.InGameMenu.Return := {x: 746, y: 551} +this.ChapterManagement.Headquarters := {x: 632, y: 173} +this.ChapterManagement.Company1 := {x: 79, y: 419} +this.ChapterManagement.Company10 := {x: 1189, y: 362} +this.ChapterManagement.Company5 := {x: 575, y: 378} +this.ChapterManagement.SquadView := {x: 862, y: 137} +this.ChapterManagement.ShowProfile := {x: 979, y: 134} +this.ChapterManagement.SelectAll := {x: 870, y: 502} +this.Apothecarium.AddTestSlave := {x: 370, y: 642} +this.Armamentarium.EnterForge := {x: 495, y: 322} +this.Armamentarium.Equipment := {x: 816, y: 67} +this.Armamentarium.Armour := {x: 898, y: 64} +this.Armamentarium.Vehicles := {x: 1002, y: 72} +this.Armamentarium.Ships := {x: 1218, y: 70} +this.Fleet.ShipSlot1 := {x: 882, y: 93} +this.Diplomacy.ImperiumAudience := {x: 244, y: 275} +this.Diplomacy.MechanicusAudience := {x: 235, y: 385} +this.Diplomacy.MeetChaosEmmisary := {x: 691, y: 189} +this.Creation.StrengthUp := {x: 388, y: 273} +this.Creation.StrengthDown := {x: 363, y: 276} +this.Creation.CooperationUp := {x: 391, y: 321} +this.Creation.CooperationDown := {x: 359, y: 321} +this.Creation.PurityUp := {x: 390, y: 367} +this.Creation.PurityDown := {x: 356, y: 364} +this.Creation.StabilityUp := {x: 388, y: 405} +this.Creation.StabilityDown := {x: 367, y: 407} +this.Creation.Obliterated := {x: 384, y: 416} +this.SaveMenu.SaveSlot1 := {x: 1109, y: 362} +this.LoadMenu.LoadSlot1 := {x: 1109, y: 362} +this.LoadMenu.LoadAutosave := {x: 1127, y: 241} +this.Creation.Lamenters := {x: 546, y: 249}