diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..5f8e60f --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,18 @@ +name: QuakeC Unit Tests +on: [pull_request] +jobs: + Unit-Tests: + name: Run Unit Tests + runs-on: ubuntu-latest + container: + image: ubuntu:24.10 + options: --shm-size=8192m + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Wait for GitHub to keep up.. + run: sleep 2s + shell: bash + - name: Run Unit Test Script + run: | + bash testing/run_unit_tests.sh diff --git a/progs/ssqc.src b/progs/ssqc.src index ace6989..1cef5bf 100644 --- a/progs/ssqc.src +++ b/progs/ssqc.src @@ -77,4 +77,13 @@ ai/zombie_core.qc ai/crawler_core.qc ai/dog_core.qc utilities/map_compatibility.qc +#ifdef QUAKEC_TEST +tests/test_math.qc +tests/test_perksacola.qc +tests/test_weapons.qc +tests/test_misc_model.qc +tests/test_score.qc +tests/test_ai.qc +tests/test_module.qc +#endif #endlist diff --git a/source/server/defs/custom.qc b/source/server/defs/custom.qc index a3f58e0..4ae8edf 100644 --- a/source/server/defs/custom.qc +++ b/source/server/defs/custom.qc @@ -423,7 +423,7 @@ void(entity who) makeCrawler; void() spawnAllZombEnts; void() set_z_health; float() spawn_a_zombieA; -float gotdog; +float map_has_hellhounds; float dogRound; float dogWave; float dog_round_count; diff --git a/source/server/entities/mystery_box.qc b/source/server/entities/mystery_box.qc index b0d656b..1299330 100644 --- a/source/server/entities/mystery_box.qc +++ b/source/server/entities/mystery_box.qc @@ -31,6 +31,8 @@ void() MBOX_Touch; #define MBOX_SPAWNFLAG_NOTHERE 1 #define MBOX_SPAWNFLAG_NOLIGHT 2 +float mystery_box_file_parsed; + // // MBOX_UpdateGlowFrame() // Updates the Glow model frame to match @@ -925,6 +927,9 @@ void(float mbox_file) MBOX_ParseMB2File = // void() MBOX_LoadData = { + if (mystery_box_file_parsed == true) + return; + float mbox_file; string file_path; @@ -935,6 +940,7 @@ void() MBOX_LoadData = if (mbox_file != -1) { MBOX_ParseMB2File(mbox_file); fclose(mbox_file); + mystery_box_file_parsed = true; return; } @@ -945,6 +951,7 @@ void() MBOX_LoadData = if (mbox_file != -1) { MBOX_ParseMB1File(mbox_file); fclose(mbox_file); + mystery_box_file_parsed = true; return; } @@ -960,6 +967,7 @@ void() MBOX_LoadData = // Close the file pointer just in case. fclose(mbox_file); + mystery_box_file_parsed = true; } void() mystery_box = diff --git a/source/server/entities/perk_a_cola.qc b/source/server/entities/perk_a_cola.qc index 57ce2a9..733ded2 100644 --- a/source/server/entities/perk_a_cola.qc +++ b/source/server/entities/perk_a_cola.qc @@ -575,7 +575,7 @@ void() perk_double = // Deadshot Daiquiri void() perk_deadshot = { - Perk_InitMachine("perk_deadshot", PERK_DEADSHOT_DEFAULT_MODEL, 2000, 2000, + Perk_InitMachine("perk_deadshot", PERK_DEADSHOT_DEFAULT_MODEL, 1500, 1500, PERK_SPAWNFLAG_YELLOWLIGHT, 1000, 1000, true, true, 7, P_DEAD); }; // naievil -- older maps compatability diff --git a/source/server/main.qc b/source/server/main.qc index 00b1f2a..c778c1e 100644 --- a/source/server/main.qc +++ b/source/server/main.qc @@ -135,7 +135,7 @@ void() StartFrame = entity hellhound = find(world, classname, "spawn_dog"); if (hellhound != world) - gotdog = true; + map_has_hellhounds = true; Compat_Init(); updateDogRound(); diff --git a/source/server/player/player_core.qc b/source/server/player/player_core.qc index 54b8c98..8aad03a 100644 --- a/source/server/player/player_core.qc +++ b/source/server/player/player_core.qc @@ -32,6 +32,12 @@ void() Spawns_Init; void() SUB_UseTargets; void(entity client) LastStand_UnlinkRevivee; +#ifdef QUAKEC_TEST + +void() Test_RunAllTests; + +#endif // QUAKEC_TEST + #define PLAYER_START_HEALTH 100 #define PLAYER_CROUCH_DIFFERENCE_HL 25 @@ -275,6 +281,9 @@ float(float dir) checkMovement = // void(entity who, float value, float impacted_by_2x_points) Player_ChangeScore = { + if (who.classname != "player") + return; + // We shouldn't ever allow a player in Last Stand to have // their score modified. if (who.downed) @@ -815,6 +824,13 @@ void() PlayerPostThink = player_trace_time = time + 0.05; } + +#ifdef QUAKEC_TEST + + if (time_before_gamestart != 0) + Test_RunAllTests(); + +#endif // QUAKEC_TEST }; void() ClientKill = {}; diff --git a/source/server/rounds.qc b/source/server/rounds.qc index 898c494..c307db1 100644 --- a/source/server/rounds.qc +++ b/source/server/rounds.qc @@ -138,7 +138,7 @@ void() EndRound = rounds_change = 4; SetUpdate(self, UT_ROUNDS_CHANGE, rounds_change, 0, 0); - if (gotdog && rounds == dogRound) { + if (map_has_hellhounds && rounds == dogRound) { Rounds_PlayTransition("sounds/rounds/droundend.wav"); dogWave = false; } else { @@ -190,7 +190,7 @@ void() NewRound = } if (rounds != 0) { - if (gotdog && rounds == (dogRound - 1)) { + if (map_has_hellhounds && rounds == (dogRound - 1)) { Rounds_PlayTransition("sounds/rounds/droundstart.wav"); dogWave = true; dog_round_count++; @@ -203,7 +203,7 @@ void() NewRound = } // if we just had a dog round, set the next - if (gotdog && rounds == dogRound) { + if (map_has_hellhounds && rounds == dogRound) { updateDogRound(); } @@ -252,7 +252,7 @@ void() NewRound = spawn_delay = 0; totalpowerups = 0; - if (rounds == dogRound && gotdog) { + if (rounds == dogRound && map_has_hellhounds) { roundtype = 2; localcmd("fog 200 525 54 55 60\n"); } else { diff --git a/source/server/tests/test_ai.qc b/source/server/tests/test_ai.qc new file mode 100644 index 0000000..ab4881b --- /dev/null +++ b/source/server/tests/test_ai.qc @@ -0,0 +1,48 @@ +/* + server/tests/test_ai.qc + + Unit tests for various AI behaviors + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +float(float condition, string message) Test_Assert; +void(string message) Test_Skip; + +// +// Test_AI_HellhoundsDetected() +// Validates that if a hellhound spawner is in the +// world that we have detected we can have Hellhound +// rounds. +// (https://github.com/nzp-team/nzportable/issues/623) +// +void() Test_AI_HellhoundsDetected = +{ + entity hound = find(world, classname, "spawn_dog"); + + if (hound != world) { + Test_Assert((map_has_hellhounds == true), "Found Hellhound entities but map_has_hellhounds is false!"); + Test_Assert((dogRound >= 0), "Found Hellhound entities but dogRound is zero!"); + } else { + Test_Assert((map_has_hellhounds == false), "No Hellhound entities found but map_has_hellhounds is true!"); + Test_Assert((dogRound == 0), "No Hellhound entities found but dogRound is not zero!"); + } +}; \ No newline at end of file diff --git a/source/server/tests/test_math.qc b/source/server/tests/test_math.qc new file mode 100644 index 0000000..f070a00 --- /dev/null +++ b/source/server/tests/test_math.qc @@ -0,0 +1,50 @@ +/* + server/tests/test_math.qc + + Unit test list for math functions. Mostly serves for testing the test infrastructure. + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +float(float condition, string message) Test_Assert; +void(string message) Test_Skip; + +// +// Test_Math_ClampReturnsMin() +// Asserts when sending a value less than min to clamp, +// min is returned. +// +void() Test_Math_ClampReturnsMin = +{ + float clamp_value = clamp(0, 1, 100); + Test_Assert((clamp_value == 1), "Clamp returned incorrect value!"); +}; + +// +// Test_Math_ClampReturnsMax() +// Asserts when sending a value greater than max to clamp, +// max is returned. +// +void() Test_Math_ClampReturnsMax = +{ + float clamp_value = clamp(900, 1, 100); + Test_Assert((clamp_value == 100), "Clamp returned incorrect value!"); +}; \ No newline at end of file diff --git a/source/server/tests/test_misc_model.qc b/source/server/tests/test_misc_model.qc new file mode 100644 index 0000000..c03e943 --- /dev/null +++ b/source/server/tests/test_misc_model.qc @@ -0,0 +1,56 @@ +/* + server/tests/test_misc_model.qc + + Unit tests for misc_model entity + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +float(float condition, string message) Test_Assert; +void(string message) Test_Skip; + +// +// Test_misc_model_ModelNotHiddenByDefault() +// Spawns a misc_model with properties and validates +// the model does not start hidden. +// (https://github.com/nzp-team/nzportable/issues/996) +// +void() Test_misc_model_ModelNotHiddenByDefault = +{ + entity miscmodel = spawn(); + entity old_self = self; + self = miscmodel; + + self.last_frame = 154; + self.first_frame = 1; + self.frame = 0; + self.spawnflags = 6; + self.speed = 0.1; + self.model = "models/ai/zfull.mdl"; + self.classname = "misc_model"; + + misc_model(); + + Test_Assert((self.model != ""), "Model property started hidden!"); + + self = old_self; + remove(miscmodel); +}; \ No newline at end of file diff --git a/source/server/tests/test_module.qc b/source/server/tests/test_module.qc new file mode 100644 index 0000000..eba57cb --- /dev/null +++ b/source/server/tests/test_module.qc @@ -0,0 +1,155 @@ +/* + server/tests/test_main.qc + + Test running and validation infrastructure for QuakeC unit tests. + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +#ifdef FTE +#ifdef QUAKEC_TEST + +float total_tests_ran; +float total_tests_passed; +float total_tests_failed; +float total_tests_skipped; +float tests_starttime; +float tests_endtime; + +float test_skipped_notify; +string skipped_message_buffer; + +float test_failed_an_assert; +string failed_message_buffer; + +float(float condition, string message) Test_Assert = +{ + if (condition == false) { + failed_message_buffer = sprintf("%s[ERROR]: %s\n", failed_message_buffer, message); + test_failed_an_assert = true; + return false; + } + + return true; +}; + +void(string message) Test_Skip = +{ + test_skipped_notify = true; + skipped_message_buffer = sprintf("[SKIP]: %s\n", message); +} + +void(void() test_func, string test_name) Test_Run = +{ + print(sprintf("[INFO]: Running test [%s]..", test_name)); + test_func(); + + if (test_skipped_notify == true) { + print("SKIP!\n"); + total_tests_skipped++; + print(sprintf("%s", skipped_message_buffer)); + skipped_message_buffer = ""; + test_skipped_notify = false; + total_tests_ran--; + } else if (test_failed_an_assert == false) { + print("PASS!\n"); + total_tests_passed++; + } else { + print("FAIL!\n"); + total_tests_failed++; + print(sprintf("%s", failed_message_buffer)); + failed_message_buffer = ""; + test_failed_an_assert = false; + } + + total_tests_ran++; + + print("\n"); +}; + +void() Test_PrintSummary = +{ + print("====================== TEST SUMMARY ======================\n"); + print(sprintf("* Passed: %d\n* Failed: %d\n* Skipped: %d\n* Total Ran: %d\n", total_tests_passed, total_tests_failed, total_tests_skipped, total_tests_ran)); + print(sprintf("* %d%% Passing Grade\n", (total_tests_passed/total_tests_ran) * 100)); + print(sprintf("* %fs Total Runtime\n", tests_endtime)); + print("==================== END TEST SUMMARY ====================\n"); + localcmd("quit\n"); +}; + +// +// Test Table +// Add unit tests to this table for them to be +// executed during Pull Request testing. +// +var struct { + void() test_func; + string test_name; +} qc_unit_tests[] = { + { Test_Math_ClampReturnsMin, "Test_Math_ClampReturnsMin" }, + { Test_Math_ClampReturnsMax, "Test_Math_ClampReturnsMax" }, + { Test_Perks_QuickRevive_ValidateFields, "Test_Perks_QuickRevive_ValidateFields" }, + { Test_Perks_JuggerNog_ValidateFields, "Test_Perks_JuggerNog_ValidateFields" }, + { Test_Perks_SpeedCola_ValidateFields, "Test_Perks_SpeedCola_ValidateFields" }, + { Test_Perks_DoubleTap_ValidateFields, "Test_Perks_DoubleTap_ValidateFields" }, + { Test_Perks_StaminUp_ValidateFields, "Test_Perks_StaminUp_ValidateFields" }, + { Test_Perks_PhDFlopper_ValidateFields, "Test_Perks_PhDFlopper_ValidateFields" }, + { Test_Perks_DeadshotDaiquiri_ValidateFields, "Test_Perks_DeadshotDaiquiri_ValidateFields" }, + { Test_Perks_Random_LegacyFields, "Test_Perks_Random_LegacyFields" }, + { Test_Perks_MuleKick_ValidateFields, "Test_Perks_MuleKick_ValidateFields" }, + { Test_Weapons_Gewehr_MagazineSize, "Test_Weapons_Gewehr_MagazineSize" }, + { Test_Weapons_BowieKnife_RemovedAfterRespawn, "Test_Weapons_BowieKnife_RemovedAfterRespawn" }, + { Test_Weapons_HoldTwoWeapons, "Test_Weapons_HoldTwoWeapons" }, + { Test_Weapons_HoldThreeWeapons, "Test_Weapons_HoldThreeWeapons" }, + { Test_Weapons_UpgradedSound, "Test_Weapons_UpgradedSound" }, + { Test_Weapons_ShotgunPenetration, "Test_Weapons_ShotgunPenetration" }, + { Test_Weapons_WunderWaffe_UpgradedReserve, "Test_Weapons_WunderWaffe_UpgradedReserve" }, + { Test_Weapons_WunderWaffe_FireAtBarrel, "Test_Weapons_WunderWaffe_FireAtBarrel" }, + { Test_WallWeapons_Message, "Test_WallWeapons_Message" }, + { Test_WallWeapons_ActivatesAllTargets, "Test_WallWeapons_ActivatesAllTargets" }, + { Test_Weapons_DualWield_ReloadLowReserveRightLeft, "Test_Weapons_DualWield_ReloadLowReserveRightLeft" }, + { Test_Weapons_DualWield_ReloadLowReserveLeftRight, "Test_Weapons_DualWield_ReloadLowReserveLeftRight" }, + { Test_misc_model_ModelNotHiddenByDefault, "Test_misc_model_ModelNotHiddenByDefault" }, + { Test_AddScore_NonClient, "Test_AddScore_NonClient" }, + { Test_AddScore_DamageTypes, "Test_AddScore_DamageTypes" }, + { Test_AddScore_MysteryBoxLeave, "Test_AddScore_MysteryBoxLeave" }, + { Test_AI_HellhoundsDetected, "Test_AI_HellhoundsDetected" } +}; + +void() Test_RunAllTests = +{ + if (tests_starttime != 0) + return; + + total_tests_ran = 0; + total_tests_passed = 0; + total_tests_failed = 0; + + tests_starttime = gettime(); + for(int i = 0; i < qc_unit_tests.length; i++) { + Test_Run(qc_unit_tests[i].test_func, qc_unit_tests[i].test_name); + } + tests_endtime = gettime(); + Test_PrintSummary(); +}; + +#endif // QUAKEC_TEST +#endif // FTE \ No newline at end of file diff --git a/source/server/tests/test_perksacola.qc b/source/server/tests/test_perksacola.qc new file mode 100644 index 0000000..f05a513 --- /dev/null +++ b/source/server/tests/test_perksacola.qc @@ -0,0 +1,215 @@ +/* + server/tests/test_perksacola.qc + + Perk-A-Cola unit tests. + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +float(float condition, string message) Test_Assert; +void(string message) Test_Skip; + +// +// Test_Perks_QuickRevive_ValidatePrice() +// Spawns a Quick Revive machine and validates its +// default fields. +// +void() Test_Perks_QuickRevive_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_revive(); + + Test_Assert((self.model == PERK_QUICKREVIVE_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 500), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 1500), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_JuggerNog_ValidatePrice() +// Spawns a Jugger-Nog machine and validates its +// default fields. +// +void() Test_Perks_JuggerNog_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_juggernog(); + + Test_Assert((self.model == PERK_JUGGERNOG_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 2500), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 2500), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_SpeedCola_ValidatePrice() +// Spawns a Speed Cola machine and validates its +// default fields. +// +void() Test_Perks_SpeedCola_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_speed(); + + Test_Assert((self.model == PERK_SPEEDCOLA_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 3000), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 3000), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_DoubleTap_ValidateFields() +// Spawns a Double Tap machine and validates its +// default fields. +// +void() Test_Perks_DoubleTap_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_double(); + + Test_Assert((self.model == PERK_DOUBLETAP_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 2000), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 2000), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_StaminUp_ValidateFields() +// Spawns a Stamin-Up machine and validates its +// default fields. +// +void() Test_Perks_StaminUp_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_staminup(); + + Test_Assert((self.model == PERK_STAMINUP_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 2000), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 2000), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_PhDFlopper_ValidateFields() +// Spawns a PhD Flopper machine and validates its +// default fields. +// +void() Test_Perks_PhDFlopper_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_flopper(); + + Test_Assert((self.model == PERK_PHDFLOPPER_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 2000), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 2000), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_DeadshotDaiquiri_ValidateFields() +// Spawns a Deadshot Daiquiri machine and validates its +// default fields. +// +void() Test_Perks_DeadshotDaiquiri_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_deadshot(); + + Test_Assert((self.model == PERK_DEADSHOT_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 1500), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 1500), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_MuleKick_ValidateFields() +// Spawns a Deadshot Daiquiri machine and validates its +// default fields. +// +void() Test_Perks_MuleKick_ValidateFields = +{ + entity machine = spawn(); + entity old_self = self; + self = machine; + + perk_mule(); + + Test_Assert((self.model == PERK_MULEKICK_DEFAULT_MODEL), "Perk has incorrect model!"); + Test_Assert((self.cost == 4000), "Perk has incorrect Solo cost!"); + Test_Assert((self.cost2 == 4000), "Perk has incorrect Co-Op cost!"); + + self = old_self; + remove(machine); +}; + +// +// Test_Perks_Random_LegacyFields() +// Attempts to spawn a random Perk-A-Cola +// with the perk_random fields using +// legacy data. +// (https://github.com/nzp-team/nzportable/issues/664) +// +void() Test_Perks_Random_LegacyFields = +{ + entity random = spawn(); + entity old_self = self; + self = random; + self.spawnflags = 240; + perk_random(); + self = old_self; + + remove(random); +}; \ No newline at end of file diff --git a/source/server/tests/test_score.qc b/source/server/tests/test_score.qc new file mode 100644 index 0000000..05d7c29 --- /dev/null +++ b/source/server/tests/test_score.qc @@ -0,0 +1,113 @@ +/* + server/tests/test_score.qc + + Unit tests for score system + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +float(float condition, string message) Test_Assert; +void(string message) Test_Skip; + +// +// Test_AddScore_NonClient() +// Attempts to give points to a non-client. +// (https://github.com/nzp-team/nzportable/issues/783) +// +void() Test_AddScore_NonClient = +{ + entity some_ent = spawn(); + Player_AddScore(some_ent, 10, false); + remove(some_ent); +}; + +// +// Test_AddScore_DamageTypes() +// Asserts we are giving correct amount +// of score for every AI damage type. +// (https://github.com/nzp-team/nzportable/issues/776) +// +void() Test_AddScore_DamageTypes = +{ + float styles[] = { + DMG_TYPE_HEADSHOT, + DMG_TYPE_MELEE, + DMG_TYPE_TESLA, + DMG_TYPE_FLAMETHROWER, + DMG_TYPE_GRENADE, + DMG_TYPE_EXPLOSIVE, + DMG_TYPE_LOWERTORSO, + DMG_TYPE_UPPERTORSO + }; + + float scores[] = { + DMG_SCORE_HEADSHOT, + DMG_SCORE_MELEE, + DMG_SCORE_GRENADE, + DMG_SCORE_GRENADE, + DMG_SCORE_GRENADE, + DMG_SCORE_EXPLOSIVE, + DMG_SCORE_LOWERTORSO, + DMG_SCORE_UPPERTORSO + }; + + for (float i = 0; i < styles.length; i++) { + float score_pre = self.points; + + DieHandler(self, styles[i]); + + float score_post = self.points; + float score_delta = score_post - score_pre; + + Test_Assert((score_delta == scores[i]), sprintf("Unexpected score earned, expected [%d] but got [%d]!", scores[i], score_delta)); + } +}; + +// +// Test_AddScore_MysteryBoxLeave() +// Asserts we get exact amount of points +// back when the Mystery Box attempts +// to leave. +// (https://github.com/nzp-team/nzportable/issues/775) +// +void() Test_AddScore_MysteryBoxLeave = +{ + precache_sound("sounds/misc/giggle.wav"); + + float score_pre = self.points; + + entity mystery_box_ent = spawn(); + entity owner_ent = spawn(); + entity old_self = self; + + self = mystery_box_ent; + mystery_box(); + self.owner = owner_ent; + self.owner.owner = old_self; + MBOX_PresentTeddy(); + self = old_self; + + float score_post = self.points; + Test_Assert((score_post - score_pre == mystery_box_cost), "Unexpected points earned!"); + + remove(mystery_box_ent); + remove(owner_ent); +}; \ No newline at end of file diff --git a/source/server/tests/test_weapons.qc b/source/server/tests/test_weapons.qc new file mode 100644 index 0000000..5d4f8c2 --- /dev/null +++ b/source/server/tests/test_weapons.qc @@ -0,0 +1,295 @@ +/* + server/tests/test_weapons.qc + + Weapon related unit tests. + + Copyright (C) 2021-2025 NZ:P Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + + See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to: + + Free Software Foundation, Inc. + 59 Temple Place - Suite 330 + Boston, MA 02111-1307, USA + +*/ +float(float condition, string message) Test_Assert; +void(string message) Test_Skip; + +// +// +// Weapon System +// +// + +// +// Test_Weapons_Gewehr_MagazineSize() +// Asserts the Gewehr and it's Pack-A-Punched +// form have the correct magazine sizes. +// (https://github.com/nzp-team/nzportable/issues/1102) +// +void() Test_Weapons_Gewehr_MagazineSize = +{ + Test_Assert((getWeaponMag(W_GEWEHR) == 10), "Gewehr has incorrect magazine size!"); + Test_Assert((getWeaponMag(W_COMPRESSOR) == 12), "Upgraded Gewehr has incorrect magazine size!"); +}; + +// +// Test_Weapons_DualWield_ReloadLowReserveRightLeft() +// Asserts if we are attempting to reload a Dual Wield +// weapon while the reserve is too small to fill both +// mags, that we do not give out more ammo than intended. +// This triggers the Right side first, then Left. +// (https://github.com/nzp-team/nzportable/issues/1096) +// +void() Test_Weapons_DualWield_ReloadLowReserveRightLeft = +{ + Weapon_RemoveWeapon(0); + Weapon_GiveWeapon(W_BIATCH, 0, 0); + + self.weapons[0].weapon_magazine = 3; + self.weapons[0].weapon_magazine_left = 1; + self.weapons[0].weapon_reserve = 7; + + W_Give_Ammo(S_RIGHT); + W_Give_Ammo(S_LEFT); + + Test_Assert((self.weapons[0].weapon_magazine == 6), sprintf("Expected [6] for right magazine size, got [%d]!", self.weapons[0].weapon_magazine)); + Test_Assert((self.weapons[0].weapon_magazine_left == 5), sprintf("Expected [5] for left magazine size, got [%d]!", self.weapons[0].weapon_magazine_left)); + Test_Assert((self.weapons[0].weapon_reserve == 0), sprintf("Expected [0] for reserve size, got [%d]!", self.weapons[0].weapon_reserve)); +}; + +// +// Test_Weapons_DualWield_ReloadLowReserveLeftRight() +// Asserts if we are attempting to reload a Dual Wield +// weapon while the reserve is too small to fill both +// mags, that we do not give out more ammo than intended. +// This triggers the Right side first, then Left. +// (https://github.com/nzp-team/nzportable/issues/1096) +// +void() Test_Weapons_DualWield_ReloadLowReserveLeftRight = +{ + Weapon_RemoveWeapon(0); + Weapon_GiveWeapon(W_BIATCH, 0, 0); + + self.weapons[0].weapon_magazine = 3; + self.weapons[0].weapon_magazine_left = 1; + self.weapons[0].weapon_reserve = 7; + + W_Give_Ammo(S_LEFT); + W_Give_Ammo(S_RIGHT); + + Test_Assert((self.weapons[0].weapon_magazine == 5), sprintf("Expected [5] for right magazine size, got [%d]!", self.weapons[0].weapon_magazine)); + Test_Assert((self.weapons[0].weapon_magazine_left == 6), sprintf("Expected [6] for left magazine size, got [%d]!", self.weapons[0].weapon_magazine_left)); + Test_Assert((self.weapons[0].weapon_reserve == 0), sprintf("Expected [0] for reserve size, got [%d]!", self.weapons[0].weapon_reserve)); +}; + +// +// Test_Weapons_BowieKnife_RemovedAfterRespawn() +// Asserts that upon respawning a client loses the +// Bowie Knife. +// (https://github.com/nzp-team/nzportable/issues/922) +// +void() Test_Weapons_BowieKnife_RemovedAfterRespawn = +{ + self.has_bowie_knife = true; + PlayerSpawn(); + Test_Assert((self.has_bowie_knife == false), "Client still has Bowie Knife!"); +}; + +// +// Test_Weapons_HoldTwoWeapons() +// Asserts we can hold more than just a single +// weapon. +// (https://github.com/nzp-team/nzportable/issues/786) +// +void() Test_Weapons_HoldTwoWeapons = +{ + Weapon_GiveWeapon(W_COLT, 0, 0); + Weapon_GiveWeapon(W_GEWEHR, 0, 0); + + Test_Assert((self.weapons[0].weapon_id != 0), "No weapon in slot zero!"); + Test_Assert((self.weapons[1].weapon_id != 0), "No weapon in slot one!"); + + Weapon_RemoveWeapon(1); +}; + +// +// Test_Weapons_HoldThreeWeapons() +// Gives the client Mule Kick and asserts we can +// hold three weapons. +// +void() Test_Weapons_HoldThreeWeapons = +{ + GivePerk(P_MULE); + + Weapon_GiveWeapon(W_COLT, 0, 0); + Weapon_GiveWeapon(W_GEWEHR, 0, 0); + Weapon_GiveWeapon(W_KAR, 0, 0); + + Test_Assert((self.weapons[0].weapon_id != 0), "No weapon in slot zero!"); + Test_Assert((self.weapons[1].weapon_id != 0), "No weapon in slot one!"); + Test_Assert((self.weapons[2].weapon_id != 0), "No weapon in slot two!"); + + Weapon_RemoveWeapon(1); + Weapon_RemoveWeapon(2); + self.perks = 0; +}; + +// +// Test_Weapons_UpgradedSound() +// Validates certain weapons are set to not +// play an upgraded sound. +// +void() Test_Weapons_UpgradedSound = +{ + float weapon_list[] = { W_DG3, W_PORTER, W_FIW }; + + for (float i = 0; i < weapon_list.length; i++) { + Test_Assert((WepDef_DoesNotPlayUpgradedSound(weapon_list[i]) == true), sprintf("Weapon [%s] plays an upgraded sound!", GetWeaponName(weapon_list[i]))); + } + + return false; +}; + +// +// Test_Weapons_ShotgunPenetration() +// Validates shotgun penetration is set +// correctly. +// (https://github.com/nzp-team/nzportable/issues/652) +// +void() Test_Weapons_ShotgunPenetration = +{ + float weapon_list[] = { W_DB, W_BORE, W_SAWNOFF, W_SNUFF, W_TRENCH, W_GUT }; + + for (float i = 0; i < weapon_list.length; i++) { + Test_Assert((getWeaponPenetration(weapon_list[i], 2) != 0), sprintf("Weapon [%s] not correctly penetrating!", GetWeaponName(weapon_list[i]))); + } +}; + +// +// Test_Weapons_WunderWaffe_UpgradedReserve() +// Validates correct reserve ammo count for DG-3. +// (https://github.com/nzp-team/nzportable/issues/651) +// +void() Test_Weapons_WunderWaffe_UpgradedReserve = +{ + Test_Assert(getWeaponAmmo(W_DG3) == 30, "Incorrect reserve ammo!"); +}; + +// +// Test_Weapons_WunderWaffe_FireAtBarrel() +// Simulates firing the DG-2 at an explosive barrel +// and validates it does not count as a kill and +// does not award points. +// (https://github.com/nzp-team/nzportable/issues/584) +// +void() Test_Weapons_WunderWaffe_FireAtBarrel = +{ + Test_Skip("Skipped while facing odd traceline behavior"); +#if 0 + float kills_pre = self.kills; + float score_pre = self.points; + + makevectors(self.angles); + + entity barrel = spawn(); + entity old_self = self; + self = barrel; + setorigin(self, old_self.origin + (v_forward * 50) + '0 0 16'); + explosive_barrel(); + self = old_self; + + Weapon_GiveWeapon(W_TESLA, 0, 0); + W_FireTesla(); + + float kills_post = self.kills; + float score_post = self.points; + + Test_Assert((kills_pre == kills_post), "Explosive barrel counted as kill!"); + Test_Assert((score_pre == score_post), "Explosive barrel earned score!"); +#endif +}; + +// +// +// Wall Weapons +// +// + +// +// Test_WallWeapons_Message() +// Asserts a written message field gets removed +// from a buy_weapon() +// +void() Test_WallWeapons_Message = +{ + entity weapon = spawn(); + entity old_self = self; + self = weapon; + + self.message = "test"; + + buy_weapon(); + + Test_Assert((self.message == ""), "Message field was not removed!"); + + self = old_self; + remove(weapon); +}; + +// +// Test_WallWeapons_ActivatesAllTargets() +// Validates that when a buy_weapon entity +// uses its targets, all 8 are activated. +// (https://github.com/nzp-team/nzportable/issues/961) +// +void() Test_WallWeapons_ActivatesAllTargets = +{ + float i; + + entity weapon = spawn(); + entity old_self = self; + entity targets[8]; + + for (i = 0; i < 8; i++) { + targets[i] = spawn(); + targets[i].health = 2; + targets[i].targetname = sprintf("test_target_%d", i); + + self = targets[i]; + game_counter(); + } + + self = weapon; + self.target = "test_target_0"; + self.target2 = "test_target_1"; + self.target3 = "test_target_2"; + self.target4 = "test_target_3"; + self.target5 = "test_target_4"; + self.target6 = "test_target_5"; + self.target7 = "test_target_6"; + self.target8 = "test_target_7"; + buy_weapon(); + SUB_UseTargets(); + + for(i = 0; i < 8; i++) { + Test_Assert((targets[i].frags == 1), sprintf("Target #%d did not increment!", i)); + remove(targets[i]); + } + + self = old_self; + remove(weapon); +}; + diff --git a/testing/run_unit_tests.sh b/testing/run_unit_tests.sh new file mode 100644 index 0000000..342209b --- /dev/null +++ b/testing/run_unit_tests.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# +# Nazi Zombies: Portable +# QuakeC Unit test runner. +# ---- +# This is intended to be used via a Docker +# container running ubuntu:24.10. +# +set -o errexit + +# tzdata will try to display an interactive install prompt by +# default, so make sure we define our system as non-interactive. +export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true + +WORKING_DIRECTORY="/working" +OUTPUT_LOG="${WORKING_DIRECTORY}/run.log" +REPO_PWD=$(pwd) + +function setup_container() +{ + echo "[INFO]: Installing dependancies.." + apt update -y + apt install libsdl2-dev wget zip python3 python3-pip -y + wget https://raw.githubusercontent.com/nzp-team/QCHashTableGenerator/main/requirements.txt + pip install -r requirements.txt --break-system-packages + rm requirements.txt + mkdir -p "${WORKING_DIRECTORY}" +} + +function download_nzp() +{ + echo "[INFO]: Obtaining latest Nazi Zombies: Portable Linux x86_64 release.." + cd "${WORKING_DIRECTORY}" + wget https://github.com/nzp-team/nzportable/releases/download/nightly/nzportable-linux64.zip + mkdir nzportable-linux64 + unzip nzportable-linux64.zip -d nzportable-linux64/ + chmod +x nzportable-linux64/nzportable64-sdl +} + +function build_quakec() +{ + echo "[INFO]: Building QuakeC.." + cd "${REPO_PWD}/tools" + local cmd="./qc-compiler-gnu.sh --test-mode" + ${cmd} + + echo "[INFO]: Moving QuakeC to game download.." + cp "${REPO_PWD}/build/fte/qwprogs.dat" "${WORKING_DIRECTORY}/nzportable-linux64/nzp/" +} + +function run_test() +{ + local game_crashed="0" + touch "${OUTPUT_LOG}" + + echo "[INFO]: Running unit tests.." + cd "${WORKING_DIRECTORY}/nzportable-linux64/" + local cmd="./nzportable64-sdl +map nzp_warehouse +vid_renderer headless" + ${cmd} | tee "${OUTPUT_LOG}" + + failed_count=$( cat "${OUTPUT_LOG}" | grep "* Failed: " | cut -c 11- || game_crashed="1" ) + + if [[ "${game_crashed}" -ne "0" ]]; then + echo "[ERROR]: Game crashed, no condump generated! Bailing!" + exit 1 + fi + + if [[ "${failed_count}" -ne "0" ]]; then + echo "[ERROR]: [${failed_count}] failures occurred while running unit tests!" + exit 1 + fi + + echo "[INFO]: UNIT TEST PASSED." +} + +function main() +{ + setup_container; + download_nzp; + build_quakec; + run_test; +} + +main; \ No newline at end of file diff --git a/tools/qc-compiler-gnu.sh b/tools/qc-compiler-gnu.sh index c1f6667..422eec6 100755 --- a/tools/qc-compiler-gnu.sh +++ b/tools/qc-compiler-gnu.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -e errexit +set -o errexit SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) QUAKEC_ROOT=$(dirname "${SCRIPT_DIR}") @@ -13,6 +13,14 @@ if [[ "${OSTYPE}" == "darwin"* ]]; then FTEQCC="fteqcc-cli-mac" fi +# Arguments +while true; do + case "$1" in + -t | --test-mode ) TEST_FLAG="-DQUAKEC_TEST"; shift 1 ;; + * ) break ;; + esac +done + function setup() { cd "${QUAKEC_ROOT}" @@ -53,7 +61,7 @@ function main() { setup; compile_progs "csqc" "FTE CSQC" "-DFTE -Wall" - compile_progs "ssqc" "FTE SSQC" "-O3 -DFTE -Wall" + compile_progs "ssqc" "FTE SSQC" "-O3 -DFTE ${TEST_FLAG} -Wall" compile_progs "menu" "FTE MenuQC" "-O3 -DFTE -Wall" compile_progs "ssqc" "Vril SSQC" "-O3 -Wall" exit ${RC}