From dfd27eaf269d8a16f001eb04efed384120ec755f Mon Sep 17 00:00:00 2001 From: turleypol Date: Mon, 10 Aug 2026 21:45:58 +0200 Subject: [PATCH] misc tests --- .../pol/testpkgs/misc/test_datastore.src | 111 +++++ testsuite/pol/testpkgs/misc/test_listing.src | 161 +++++++ testsuite/pol/testpkgs/misc/test_osopts.src | 144 +++++++ testsuite/pol/testpkgs/misc/test_realm.src | 132 ++++++ testsuite/pol/testpkgs/misc/test_resource.src | 104 +++++ testsuite/pol/testpkgs/misc/test_uomenu.src | 250 +++++++++++ testsuite/pol/testpkgs/misc/test_uoregion.src | 406 ++++++++++++++++++ testsuite/pol/testpkgs/misc/test_uowalk.src | 237 ++++++++++ .../testpkgs/misc/textcmd/test/dummycmd.txt | 1 + testsuite/pol/testpkgs/misc/uoattach.src | 17 + testsuite/pol/testpkgs/misc/uoconstraint.cfg | 34 ++ 11 files changed, 1597 insertions(+) create mode 100644 testsuite/pol/testpkgs/misc/test_datastore.src create mode 100644 testsuite/pol/testpkgs/misc/test_listing.src create mode 100644 testsuite/pol/testpkgs/misc/test_osopts.src create mode 100644 testsuite/pol/testpkgs/misc/test_realm.src create mode 100644 testsuite/pol/testpkgs/misc/test_resource.src create mode 100644 testsuite/pol/testpkgs/misc/test_uomenu.src create mode 100644 testsuite/pol/testpkgs/misc/test_uoregion.src create mode 100644 testsuite/pol/testpkgs/misc/test_uowalk.src create mode 100644 testsuite/pol/testpkgs/misc/textcmd/test/dummycmd.txt create mode 100644 testsuite/pol/testpkgs/misc/uoattach.src create mode 100644 testsuite/pol/testpkgs/misc/uoconstraint.cfg diff --git a/testsuite/pol/testpkgs/misc/test_datastore.src b/testsuite/pol/testpkgs/misc/test_datastore.src new file mode 100644 index 000000000..2f5484ab7 --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_datastore.src @@ -0,0 +1,111 @@ +use uo; +use os; +use datafile; + +include "testutil"; + +const DF_NAME := ":TestMisc:dstest"; + +program testdatastore() + return 1; +endprogram + +// The datafile and element handles are reference objects of their own, each with its own +// type name and integer. +exported function datafile_refs_report_types( unused resmngr ) + var df := CreateDataFile( DF_NAME, DF_KEYTYPE_STRING ); + if ( !df ) + return ret_error( $"failed to create datafile: {df}" ); + endif + + var elem := df.CreateElement( "a" ); + if ( !elem ) + return ret_error( $"failed to create element: {elem}" ); + endif + + var res := 1; + if ( TypeOf( df ) != "DataFileRef" ) + res := ret_error( $"unexpected datafile type name: {TypeOf( df )}" ); + elseif ( TypeOf( elem ) != "DataElemRef" ) + res := ret_error( $"unexpected element type name: {TypeOf( elem )}" ); + elseif ( TypeOfInt( df ) == TypeOfInt( elem ) ) + res := ret_error( "datafile and element share a type integer" ); + endif + + UnloadDataFile( DF_NAME ); + return res; +endfunction + +// A method neither handle recognises is looked up by name and answers falsy. +exported function datafile_unknown_methods( unused resmngr ) + var df := CreateDataFile( DF_NAME, DF_KEYTYPE_STRING ); + if ( !df ) + return ret_error( $"failed to create datafile: {df}" ); + endif + var elem := df.CreateElement( "a" ); + if ( !elem ) + return ret_error( $"failed to create element: {elem}" ); + endif + + var res := 1; + if ( df.no_such_method_at_all() ) + res := ret_error( "datafile answered an unknown method" ); + elseif ( elem.no_such_method_at_all() ) + res := ret_error( "element answered an unknown method" ); + endif + + UnloadDataFile( DF_NAME ); + return res; +endfunction + +// DeleteElement removes a key, and deleting one that was never there is harmless. +exported function datafile_delete_element( unused resmngr ) + var df := CreateDataFile( DF_NAME, DF_KEYTYPE_STRING ); + if ( !df ) + return ret_error( $"failed to create datafile: {df}" ); + endif + + df.CreateElement( "a" ); + df.CreateElement( "b" ); + + var res := 1; + if ( df.Keys().size() != 2 ) + res := ret_error( $"expected two keys, got {df.Keys()}" ); + else + df.DeleteElement( "a" ); + if ( df.Keys().size() != 1 ) + res := ret_error( $"expected one key after delete, got {df.Keys()}" ); + elseif ( df.FindElement( "a" ) ) + res := ret_error( "the deleted element is still findable" ); + else + df.DeleteElement( "no_such_key" ); + if ( df.Keys().size() != 1 ) + res := ret_error( "deleting a missing key changed the key set" ); + endif + endif + endif + + UnloadDataFile( DF_NAME ); + return res; +endfunction + +// ListDataFiles reports what is loaded, and UnloadDataFile takes it back out. +exported function datafile_list_and_unload( unused resmngr ) + var df := CreateDataFile( DF_NAME, DF_KEYTYPE_STRING ); + if ( !df ) + return ret_error( $"failed to create datafile: {df}" ); + endif + + var listed := ListDataFiles(); + if ( TypeOf( listed ) != "Array" ) + UnloadDataFile( DF_NAME ); + return ret_error( $"ListDataFiles did not answer an array: {TypeOf( listed )}" ); + endif + + var res := UnloadDataFile( DF_NAME ); + if ( !res ) + return ret_error( $"failed to unload datafile: {res}" ); + endif + + return 1; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_listing.src b/testsuite/pol/testpkgs/misc/test_listing.src new file mode 100644 index 000000000..2b4d5975f --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_listing.src @@ -0,0 +1,161 @@ +use uo; +use os; + +include "testutil"; + +// A patch of map reserved for this file so the mobiles it spawns cannot be picked up by +// another package's listing test. +const LX := 150; +const LY := 150; +const LZ := 0; + +program testlisting() + return 1; +endprogram + +function find_serial( list, serial ) + foreach entry in list + if ( entry.serial == serial ) + return 1; + endif + endforeach + return 0; +endfunction + +// ListMobilesNearLocation finds what is standing inside the range and nothing outside it. +exported function list_mobiles_near_location( resmngr ) + var npc := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", LX, LY, LZ, 0, + _DEFAULT_REALM, 1 ); + if ( !npc ) + return ret_error( $"failed to create npc: {npc}" ); + endif + + var near := ListMobilesNearLocation( LX, LY, LZ, 2 ); + if ( !find_serial( near, npc.serial ) ) + return ret_error( $"the npc was not listed within range: {near.size()} found" ); + endif + + // A location well away from the npc does not list it. + var far := ListMobilesNearLocation( LX + 40, LY + 40, LZ, 2 ); + if ( find_serial( far, npc.serial ) ) + return ret_error( "the npc was listed from a location out of range" ); + endif + + return 1; +endfunction + +// The Ex form takes two kinds of flag and needs one of each. The state flags -- NORMAL, +// HIDDEN, GHOST, CONCEALED -- decide which mobiles qualify at all, and the ONLY flags then +// narrow by kind. A mobile that matches no state flag is never listed, whatever else is asked +// for, so an ONLY flag on its own selects nothing at all. +exported function list_mobiles_near_ex( resmngr ) + var npc := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", LX, LY, LZ, 0, + _DEFAULT_REALM, 1 ); + if ( !npc ) + return ret_error( $"failed to create npc: {npc}" ); + endif + + var npcs := ListMobilesNearLocationEx( LX, LY, LZ, 2, + LISTEX_FLAG_NORMAL + LISTEX_FLAG_NPC_ONLY ); + if ( !find_serial( npcs, npc.serial ) ) + return ret_error( "the npc was not listed with NORMAL and NPC_ONLY" ); + endif + + var players := ListMobilesNearLocationEx( LX, LY, LZ, 2, + LISTEX_FLAG_NORMAL + LISTEX_FLAG_PLAYERS_ONLY ); + if ( find_serial( players, npc.serial ) ) + return ret_error( "the npc was listed with PLAYERS_ONLY" ); + endif + + // NPC_ONLY without a state flag qualifies nobody. + var neither := ListMobilesNearLocationEx( LX, LY, LZ, 2, LISTEX_FLAG_NPC_ONLY ); + if ( find_serial( neither, npc.serial ) ) + return ret_error( "an ONLY flag without a state flag should list nothing" ); + endif + + return 1; +endfunction + +// Line of sight is taken from an object rather than a bare location. +exported function list_mobiles_in_line_of_sight( resmngr ) + var seer := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", LX, LY, LZ, 0, + _DEFAULT_REALM, 1 ); + if ( !seer ) + return ret_error( $"failed to create npc: {seer}" ); + endif + var other := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", LX + 1, LY, LZ, 0, + _DEFAULT_REALM, 1 ); + if ( !other ) + return ret_error( $"failed to create second npc: {other}" ); + endif + + var seen := ListMobilesInLineOfSight( seer, 5 ); + if ( !find_serial( seen, other.serial ) ) + return ret_error( "the adjacent npc was not in line of sight" ); + endif + + return 1; +endfunction + +// A box query filters by POL class rather than by object type. +exported function list_objects_in_box_of_class( resmngr ) + var npc := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", LX, LY, LZ, 0, + _DEFAULT_REALM, 1 ); + if ( !npc ) + return ret_error( $"failed to create npc: {npc}" ); + endif + + var mobiles := ListObjectsInBoxOfClass( POLCLASS_NPC, LX - 2, LY - 2, -128, + LX + 2, LY + 2, 127 ); + if ( !find_serial( mobiles, npc.serial ) ) + return ret_error( "the npc was not found in the box query" ); + endif + + var items := ListObjectsInBoxOfClass( POLCLASS_ITEM, LX - 2, LY - 2, -128, + LX + 2, LY + 2, 127 ); + if ( find_serial( items, npc.serial ) ) + return ret_error( "the npc was listed as an item" ); + endif + + return 1; +endfunction + +// Nobody is logged in during the test run, so the online list is empty while the offline +// list is not. +exported function online_and_offline_lists( unused resmngr ) + var online := EnumerateOnlineCharacters(); + if ( TypeOf( online ) != "Array" ) + return ret_error( $"EnumerateOnlineCharacters did not answer an array: {TypeOf( online )}" ); + endif + if ( online.size() != 0 ) + return ret_error( $"expected nobody online, got {online.size()}" ); + endif + + var offline := ListOfflineMobilesInRealm( _DEFAULT_REALM ); + if ( TypeOf( offline ) != "Array" ) + return ret_error( $"ListOfflineMobilesInRealm did not answer an array: {TypeOf( offline )}" ); + endif + + return 1; +endfunction + +// A circle of radius 0 is a single point, and a larger one is symmetric about its centre. +exported function midpoint_circle_coords( unused resmngr ) + var single := GetMidpointCircleCoords( 100, 100, 0 ); + if ( TypeOf( single ) != "Array" ) + return ret_error( $"expected an array, got {TypeOf( single )}" ); + endif + + var ring := GetMidpointCircleCoords( 100, 100, 3 ); + if ( ring.size() < 1 ) + return ret_error( "a radius of 3 produced no coordinates" ); + endif + + foreach coord in ring + if ( coord.x < 97 || coord.x > 103 || coord.y < 97 || coord.y > 103 ) + return ret_error( $"coordinate outside the circle bounds: {coord.x},{coord.y}" ); + endif + endforeach + + return 1; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_osopts.src b/testsuite/pol/testpkgs/misc/test_osopts.src new file mode 100644 index 000000000..89adbf0d8 --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_osopts.src @@ -0,0 +1,144 @@ +use uo; +use os; + +include "testutil"; + +program testosopts() + return 1; +endprogram + +// Set_Script_Option answers the previous value of the option it changed, so each option can +// be set and put back. SCRIPTOPT_NO_INTERRUPT is the same flag Is_Critical() reports. +exported function script_option_round_trip( unused resmngr ) + if ( Is_Critical() ) + return ret_error( "the script should not start out critical" ); + endif + + var old := Set_Script_Option( SCRIPTOPT_NO_INTERRUPT, 1 ); + if ( old != 0 ) + return ret_error( $"expected the previous option value 0, got {old}" ); + endif + if ( !Is_Critical() ) + Set_Script_Option( SCRIPTOPT_NO_INTERRUPT, 0 ); + return ret_error( "the script should be critical after setting NO_INTERRUPT" ); + endif + + old := Set_Script_Option( SCRIPTOPT_NO_INTERRUPT, 0 ); + if ( old != 1 ) + return ret_error( $"expected the previous option value 1, got {old}" ); + endif + if ( Is_Critical() ) + return ret_error( "the script should no longer be critical" ); + endif + + return 1; +endfunction + +// These options each store the value given and report the one they replaced. +exported function script_options_each( unused resmngr ) + var options := { SCRIPTOPT_CAN_ACCESS_OFFLINE_MOBILES, SCRIPTOPT_AUXSVC_ASSUME_STRING, + SCRIPTOPT_SURVIVE_ATTACHED_DISCONNECT }; + + foreach opt in options + var first := Set_Script_Option( opt, 1 ); + if ( first.errortext ) + return ret_error( $"option {opt} was refused: {first}" ); + endif + + var second := Set_Script_Option( opt, 0 ); + if ( second != 1 ) + return ret_error( $"option {opt} did not report the value just set, got {second}" ); + endif + + // Put the option back where it started. + Set_Script_Option( opt, first ); + endforeach + + return 1; +endfunction + +// SCRIPTOPT_NO_RUNAWAY is the odd one out. The value it stores is the negation of the value +// passed, but the value it reports is the stored flag rather than the option, so setting it +// to 1 and reading it back answers 0 instead of 1. +exported function script_option_no_runaway( unused resmngr ) + var original := Set_Script_Option( SCRIPTOPT_NO_RUNAWAY, 1 ); + if ( original.errortext ) + return ret_error( $"NO_RUNAWAY was refused: {original}" ); + endif + + var readback := Set_Script_Option( SCRIPTOPT_NO_RUNAWAY, 0 ); + if ( readback != 0 ) + Set_Script_Option( SCRIPTOPT_NO_RUNAWAY, 0 ); + return ret_error( $"expected the inverted read-back 0, got {readback}" ); + endif + + // After passing 0 the flag is set, so the next read-back answers 1. + var second := Set_Script_Option( SCRIPTOPT_NO_RUNAWAY, 0 ); + if ( second != 1 ) + return ret_error( $"expected the flag to read back as 1, got {second}" ); + endif + + return 1; +endfunction + +// An option number outside the known set is refused. +exported function script_option_unknown( unused resmngr ) + var res := Set_Script_Option( 9999, 1 ); + if ( res || res.errortext != "Unknown Script Option" ) + return ret_error( $"expected Unknown Script Option, got {res}" ); + endif + + res := Set_Script_Option( "not_a_number", 1 ); + if ( res ) + return ret_error( $"expected a non-integer option number to be refused, got {res}" ); + endif + + return 1; +endfunction + +// Set_Priority answers the previous priority and holds the range 1..255. +exported function set_priority_round_trip( unused resmngr ) + var original := Set_Priority( 100 ); + if ( original.errortext ) + return ret_error( $"failed to set priority: {original}" ); + endif + + var previous := Set_Priority( 50 ); + if ( previous != 100 ) + return ret_error( $"expected the previous priority 100, got {previous}" ); + endif + + var res := Set_Priority( 0 ); + if ( res ) + return ret_error( $"a priority below 1 should be refused, got {res}" ); + endif + + res := Set_Priority( 256 ); + if ( res ) + return ret_error( $"a priority above 255 should be refused, got {res}" ); + endif + + res := Set_Priority( "high" ); + if ( res ) + return ret_error( $"a non-integer priority should be refused, got {res}" ); + endif + + Set_Priority( original ); + return 1; +endfunction + +// Unload_Scripts reports how many it unloaded. A name nobody has loaded unloads nothing. +// It is never called with an empty string here: that would unload every script on the shard. +exported function unload_scripts_by_name( unused resmngr ) + var res := Unload_Scripts( ":TestMisc:no_such_script_at_all" ); + if ( res != 0 ) + return ret_error( $"expected nothing to unload, got {res}" ); + endif + + res := Unload_Scripts( 1 ); + if ( res ) + return ret_error( $"a non-string script name should be refused, got {res}" ); + endif + + return 1; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_realm.src b/testsuite/pol/testpkgs/misc/test_realm.src new file mode 100644 index 000000000..4701cf7ff --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_realm.src @@ -0,0 +1,132 @@ +use uo; +use os; +use polsys; + +include "testutil"; + +const BASE_REALM := "britannia"; + +program testrealm() + return 1; +endprogram + +// AddRealm needs two strings, an existing base realm, and a name nobody is using yet. +exported function add_realm_rejects_bad_arguments( resources ) + var res := AddRealm( 1, BASE_REALM ); + if ( res || res.errortext != "Invalid parameter" ) + return ret_error( $"expected Invalid parameter for a non-string name, got {res}" ); + endif + + res := AddRealm( "shadow-bad-base", "no_such_realm" ); + if ( res || res.errortext != "BaseRealm not found." ) + return ret_error( $"expected BaseRealm not found., got {res}" ); + endif + + res := AddRealm( BASE_REALM, BASE_REALM ); + if ( res || res.errortext != "Realmname already defined." ) + return ret_error( $"expected Realmname already defined., got {res}" ); + endif + + // A shadow realm cannot itself be used as a base. + res := resources.AddRealm( "shadow-as-base", BASE_REALM ); + if ( !res ) + return ret_error( $"failed to create shadow realm: {res}" ); + endif + + res := AddRealm( "shadow-of-shadow", "shadow-as-base" ); + if ( res || res.errortext != "BaseRealm is a ShadowRealm." ) + return ret_error( $"expected BaseRealm is a ShadowRealm., got {res}" ); + endif + + return 1; +endfunction + +// DeleteRealm only removes an empty shadow realm. +exported function delete_realm_rejects_bad_args( unused resources ) + var res := DeleteRealm( 1 ); + if ( res || res.errortext != "Invalid parameter" ) + return ret_error( $"expected Invalid parameter for a non-string name, got {res}" ); + endif + + res := DeleteRealm( "no_such_realm" ); + if ( res || res.errortext != "Realm not found." ) + return ret_error( $"expected Realm not found., got {res}" ); + endif + + res := DeleteRealm( BASE_REALM ); + if ( res || res.errortext != "Realm is not a ShadowRealm." ) + return ret_error( $"expected Realm is not a ShadowRealm., got {res}" ); + endif + + return 1; +endfunction + +// A shadow realm holding a top-level item refuses to be deleted until the item is gone. +exported function delete_realm_refuses_occupied( unused resources ) + var realm_name := "shadow-occupied"; + var res := AddRealm( realm_name, BASE_REALM ); + if ( !res ) + return ret_error( $"failed to create shadow realm: {res}" ); + endif + + var item := CreateItemAtLocation( 100, 100, 0, 0x3e3f, 1, realm_name ); + if ( !item ) + DeleteRealm( realm_name ); + return ret_error( $"failed to create item in shadow realm: {item}" ); + endif + + res := DeleteRealm( realm_name ); + var failure; + if ( res || res.errortext != "Items in Realm." ) + failure := ret_error( $"expected Items in Realm., got {res}" ); + endif + + DestroyItem( item ); + + res := DeleteRealm( realm_name ); + if ( !failure && !res ) + failure := ret_error( $"an emptied shadow realm should delete, got {res}" ); + endif + + if ( failure ) + return failure; + endif + return 1; +endfunction + +// GetRealmDecay and SetRealmDecay share the same name lookup. +exported function realm_decay_round_trip( resources ) + var res := GetRealmDecay( 1 ); + if ( res || res.errortext != "Invalid parameter" ) + return ret_error( $"expected Invalid parameter, got {res}" ); + endif + + res := GetRealmDecay( "no_such_realm" ); + if ( res || res.errortext != "Realm not found." ) + return ret_error( $"expected Realm not found., got {res}" ); + endif + + var realm_name := "shadow-decay"; + res := resources.AddRealm( realm_name, BASE_REALM ); + if ( !res ) + return ret_error( $"failed to create shadow realm: {res}" ); + endif + + res := SetRealmDecay( realm_name, 0 ); + if ( !res ) + return ret_error( $"failed to turn decay off: {res}" ); + endif + if ( GetRealmDecay( realm_name ) ) + return ret_error( "decay should be off" ); + endif + + res := SetRealmDecay( realm_name, 1 ); + if ( !res ) + return ret_error( $"failed to turn decay on: {res}" ); + endif + if ( !GetRealmDecay( realm_name ) ) + return ret_error( "decay should be on" ); + endif + + return 1; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_resource.src b/testsuite/pol/testpkgs/misc/test_resource.src new file mode 100644 index 000000000..1a33d7f61 --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_resource.src @@ -0,0 +1,104 @@ +use uo; +use os; + +include "testutil"; + +// The shard defines one resource type, testres, over the whole 192x192 map with +// UnitsPerArea 50 (testsuite/pol/regions/resource.cfg and testres.cfg). +const RES := "testres"; + +// Coordinates used only by this file, so the depletion it causes cannot disturb other tests. +const FRESH_X := 180; +const FRESH_Y := 180; +const DRAIN_X := 182; +const DRAIN_Y := 182; + +program testresource() + return 1; +endprogram + +// Every resource entry point looks the type up by name first. +exported function resource_unknown_name( unused resmngr ) + var res := HarvestResource( "no_such_resource", FRESH_X, FRESH_Y, 1, 1 ); + if ( res || res.errortext != "No resource by that name" ) + return ret_error( $"HarvestResource: expected No resource by that name, got {res}" ); + endif + + res := GetHarvestDifficulty( "no_such_resource", FRESH_X, FRESH_Y, 0 ); + if ( res || res.errortext != "No resource by that name" ) + return ret_error( $"GetHarvestDifficulty: expected No resource by that name, got {res}" ); + endif + + res := GetRegionString( "no_such_resource", FRESH_X, FRESH_Y, "UnitsPerArea" ); + if ( res || res.errortext != "No resource by that name" ) + return ret_error( $"GetRegionString: expected No resource by that name, got {res}" ); + endif + + return 1; +endfunction + +// A harvest of b units, at most n times, answers b*a. With b=1 and n=1 that is a single unit. +exported function harvest_takes_one_unit( unused resmngr ) + var res := HarvestResource( RES, FRESH_X, FRESH_Y, 1, 1 ); + if ( res != 1 ) + return ret_error( $"expected one unit harvested, got {res}" ); + endif + + res := HarvestResource( RES, FRESH_X, FRESH_Y, -1, 1 ); + if ( res || res.errortext != "b must be >= 0" ) + return ret_error( $"expected b must be >= 0, got {res}" ); + endif + + return 1; +endfunction + +// An area holds UnitsPerArea units. Draining it answers everything that was left, and a +// further harvest answers nothing. +exported function harvest_drains_the_area( unused resmngr ) + var first := HarvestResource( RES, DRAIN_X, DRAIN_Y, 1, 1000 ); + if ( first < 1 ) + return ret_error( $"expected the area to yield its units, got {first}" ); + endif + + var second := HarvestResource( RES, DRAIN_X, DRAIN_Y, 1, 1000 ); + if ( second != 0 ) + return ret_error( $"a drained area should yield nothing, got {second}" ); + endif + + return 1; +endfunction + +// GetHarvestDifficulty additionally needs a resource-bearing landmark of the given tile type at +// the location. Grass is not one, so the middle of the map has none. +exported function difficulty_needs_a_landmark( unused resmngr ) + var res := GetHarvestDifficulty( RES, FRESH_X, FRESH_Y, 0 ); + if ( res || res.errortext != "No resource-bearing landmark there" ) + return ret_error( $"expected No resource-bearing landmark there, got {res}" ); + endif + + // A tile type the resource does not list at all is refused the same way, before the map is + // ever consulted. + res := GetHarvestDifficulty( RES, SAND_X, SAND_Y, 0x3 ); + if ( res || res.errortext != "No resource-bearing landmark there" ) + return ret_error( $"expected No resource-bearing landmark there for grass, got {res}" ); + endif + return 1; +endfunction + +// testres lists LandTile 0x17, and the generated map lays a sand transition column of exactly +// that tile down the inside of its water border, so the landmark lookup succeeds there. It +// first asks the static server for a static of that type and only then falls back to the +// landtile, which is what makes this the one case that reaches findstatic. +const SAND_X := 29; +const SAND_Y := 100; + +exported function difficulty_on_a_landmark( unused resmngr ) + var res := GetHarvestDifficulty( RES, SAND_X, SAND_Y, 0x17 ); + if ( res.errortext ) + return ret_error( $"expected a difficulty on the sand column, got {res}" ); + endif + if ( TypeOf( res ) != "Integer" ) + return ret_error( $"expected an integer difficulty, got {TypeOf( res )} {res}" ); + endif + return 1; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_uomenu.src b/testsuite/pol/testpkgs/misc/test_uomenu.src new file mode 100644 index 000000000..dfd09eaad --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_uomenu.src @@ -0,0 +1,250 @@ +use uo; +use os; +use cfgfile; + +include "testutil"; + +// The fixture read by the constraint cases. Its element keys are integers, which +// is what ApplyConstraint matches the objtypes it is handed against. +const CONSTRAINT_CFG := "uoconstraint"; +const CHEAP := 0x900001; // Amount 5 +const DEAR := 0x900002; // Amount 20 +const NO_AMOUNT := 0x900003; // has no Amount property at all +const AMOUNT_IS_TEXT := 0x900004; // Amount is a string, not a number + +const ATTACH_SCRIPT := ":testmisc:uoattach"; +const ATTACH_X := 148; +const ATTACH_Y := 148; + +program testuomenu() + return 1; +endprogram + +function list_holds( list, value ) + foreach entry in list + if ( entry == value ) + return 1; + endif + endforeach + return 0; +endfunction + +// A dynamic menu is built up item by item and then read back. Its objtypes come +// out in the order they went in, and a submenu-free menu contributes them all. +exported function dynamic_menu_objtypes( unused resmngr ) + var menu := CreateMenu( "test menu" ); + if ( TypeOf( menu ) != "MenuRef" ) + return ret_error( $"expected a menu, got {TypeOf( menu )}: {menu}" ); + endif + + if ( !AddMenuItem( menu, 0x0eed, "a gold coin", 0 ) ) + return ret_error( "could not add an item by objtype" ); + endif + // An objtype may also be given by the name itemdesc knows it under. + if ( !AddMenuItem( menu, "loadtest", "a container", 0x10 ) ) + return ret_error( "could not add an item by objtype name" ); + endif + + var objtypes := GetMenuObjTypes( menu ); + if ( TypeOf( objtypes ) != "Array" ) + return ret_error( $"expected an array of objtypes, got {objtypes}" ); + endif + if ( objtypes.size() != 2 ) + return ret_error( $"expected two objtypes, got {objtypes.size()}" ); + endif + if ( !list_holds( objtypes, 0x0eed ) || !list_holds( objtypes, 0x200001 ) ) + return ret_error( $"the menu holds the wrong objtypes: {objtypes}" ); + endif + + return 1; +endfunction + +exported function menu_rejects_bad_params( unused resmngr ) + var no_title := CreateMenu( 0 ); + if ( no_title ) + return ret_error( $"expected a falsy result for a non-string title, got {no_title}" ); + endif + + var not_a_menu := AddMenuItem( 0, 0x0eed, "a gold coin", 0 ); + if ( not_a_menu ) + return ret_error( $"expected a falsy result adding to a non-menu, got {not_a_menu}" ); + endif + + // The read-back form also accepts the name of a static menu, and no static + // menus are configured on this shard. + var unknown := GetMenuObjTypes( "nosuchmenu" ); + if ( unknown ) + return ret_error( $"expected a falsy result for an unknown menu, got {unknown}" ); + endif + + return 1; +endfunction + +// Copying an array copies each element, which is the only route to the menu +// object's own copy handler. The copy carries the menu's items with it. +exported function menu_copies_by_value( unused resmngr ) + var menu := CreateMenu( "copy me" ); + if ( !AddMenuItem( menu, 0x0eed, "a gold coin", 0 ) ) + return ret_error( "could not add an item" ); + endif + + var holder := { menu }; + var copied := holder; + + if ( TypeOf( copied[1] ) != "MenuRef" ) + return ret_error( $"copied element is not a menu: {TypeOf( copied[1] )}" ); + endif + if ( TypeOfInt( copied[1] ) != TypeOfInt( menu ) ) + return ret_error( "the copy has a different type integer" ); + endif + if ( TypeOfInt( menu ) == TypeOfInt( holder ) ) + return ret_error( "a menu and an array share a type integer" ); + endif + + var objtypes := GetMenuObjTypes( copied[1] ); + if ( objtypes.size() != 1 || objtypes[1] != 0x0eed ) + return ret_error( $"the copied menu lost its items: {objtypes}" ); + endif + + return 1; +endfunction + +// ApplyConstraint keeps the objtypes a config file says are affordable. An +// entry is dropped for any of four reasons: it is not an integer, the file has +// no element for it, that element has no such property, or the property is not +// a number. +exported function apply_constraint_filters( unused resmngr ) + var cfg := ReadConfigFile( CONSTRAINT_CFG ); + if ( !cfg ) + return ret_error( $"could not read {CONSTRAINT_CFG}.cfg: {cfg}" ); + endif + + var candidates := { CHEAP, DEAR, NO_AMOUNT, AMOUNT_IS_TEXT, 0x900099, "not an objtype" }; + + var affordable := ApplyConstraint( candidates, cfg, "Amount", 10 ); + if ( TypeOf( affordable ) != "Array" ) + return ret_error( $"expected an array, got {affordable}" ); + endif + if ( affordable.size() != 1 || affordable[1] != CHEAP ) + return ret_error( $"expected only the cheap objtype, got {affordable}" ); + endif + + var everything := ApplyConstraint( candidates, cfg, "Amount", 100 ); + if ( everything.size() != 2 ) + return ret_error( $"expected both priced objtypes, got {everything}" ); + endif + if ( !list_holds( everything, CHEAP ) || !list_holds( everything, DEAR ) ) + return ret_error( $"the wrong objtypes were affordable: {everything}" ); + endif + + var nothing := ApplyConstraint( candidates, cfg, "Amount", 4 ); + if ( nothing.size() ) + return ret_error( $"nothing should be affordable, got {nothing}" ); + endif + + // A property no element carries as a number matches nobody. + var by_name := ApplyConstraint( candidates, cfg, "Name", 100 ); + if ( by_name.size() ) + return ret_error( $"a non-numeric property should match nobody, got {by_name}" ); + endif + + return 1; +endfunction + +exported function apply_constraint_bad_params( unused resmngr ) + var cfg := ReadConfigFile( CONSTRAINT_CFG ); + if ( !cfg ) + return ret_error( $"could not read {CONSTRAINT_CFG}.cfg: {cfg}" ); + endif + + var not_a_list := ApplyConstraint( CHEAP, cfg, "Amount", 10 ); + if ( not_a_list ) + return ret_error( $"expected a falsy result for a non-array, got {not_a_list}" ); + endif + + var not_a_cfg := ApplyConstraint( { CHEAP }, 0, "Amount", 10 ); + if ( not_a_cfg ) + return ret_error( $"expected a falsy result for a non-config-file, got {not_a_cfg}" ); + endif + + var no_property := ApplyConstraint( { CHEAP }, cfg, 0, 10 ); + if ( no_property ) + return ret_error( $"expected a falsy result for a non-string property, got {no_property}" ); + endif + + return 1; +endfunction + +// A character can be attached to one script at a time, and a script can hold +// one character at a time. Both halves of that are refusals rather than +// silent replacements. +exported function attach_and_detach( resmngr ) + var npc := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", ATTACH_X, ATTACH_Y, 0, 0, + _DEFAULT_REALM, 1 ); + if ( !npc ) + return ret_error( $"failed to create npc: {npc}" ); + endif + var other := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", ATTACH_X + 1, ATTACH_Y, 0, 0, + _DEFAULT_REALM, 1 ); + if ( !other ) + return ret_error( $"failed to create second npc: {other}" ); + endif + + if ( Detach() ) + return ret_error( "detaching with nothing attached should answer 0" ); + endif + + var attached := Attach( npc ); + if ( !attached ) + return ret_error( $"could not attach the npc: {attached}" ); + endif + + var res := 1; + var second := Attach( other ); + if ( second ) + res := ret_error( $"attaching a second character should be refused, got {second}" ); + endif + + if ( res ) + var bad := Attach( 0 ); + if ( bad ) + res := ret_error( $"expected an error for a non-character, got {bad}" ); + endif + endif + + if ( !Detach() ) + res := ret_error( "detaching an attached character should answer 1" ); + endif + + return res; +endfunction + +// The other refusal needs a second script, because the character remembers +// which script holds it rather than merely that it is held. +exported function attach_refuses_other_script( resmngr ) + var npc := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", ATTACH_X + 2, ATTACH_Y, 0, 0, + _DEFAULT_REALM, 1 ); + if ( !npc ) + return ret_error( $"failed to create npc: {npc}" ); + endif + + var helper := resmngr.Start_Script( ATTACH_SCRIPT, struct{ script := GetProcess(), chr := npc } ); + if ( !helper ) + return ret_error( $"could not start {ATTACH_SCRIPT}: {helper}" ); + endif + + var ready := Wait_For_Event( 5 ); + if ( !ready ) + return ret_error( "the helper script never reported back" ); + endif + if ( !ready.attached ) + return ret_error( $"the helper script could not attach: {ready.attached}" ); + endif + + var refused := Attach( npc ); + if ( refused ) + return ret_error( $"attaching a held character should be refused, got {refused}" ); + endif + + return 1; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_uoregion.src b/testsuite/pol/testpkgs/misc/test_uoregion.src new file mode 100644 index 000000000..2ad65a02a --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_uoregion.src @@ -0,0 +1,406 @@ +use uo; +use os; + +include "testutil"; + +// The patch of map painted as the TestJustice region by +// testsuite/pol/regions/justice.cfg. It is grass at z 0 and nothing else on the +// shard stands here. +const JR_X := 134; +const JR_Y := 134; +const JR_NAME := "TestJustice"; + +// Well outside it, where the background region the core builds for itself +// applies. That one comes from an empty config element, so its name is the +// empty string rather than an error. +const BG_X := 90; +const BG_Y := 90; + +// The house multis uoconvert generates for the test shard. +const MULTIID_HOUSE := 0x6c; +const MULTIID_MISSING := 0xfff0; + +// The light regions from testsuite/pol/regions/light.cfg. TestLightDark covers +// 108..115 in both axes; the rest of the shard is TestLightBase. +const LIGHT_REGION := "TestLightDark"; +const LIGHT_X := 110; +const LIGHT_Y := 110; +const LIGHT_DARK := 25; +const LIGHT_BASE := 10; +const LIGHT_MAX := 30; + +// The weather region from testsuite/pol/regions/weather.cfg, and the patch of +// open water next to it that the assignment case moves about. Neither is +// readable back from a script -- nothing in uo.em reports a weather region's +// type or which region covers a location -- so these cases can only check the +// answers the two setters give. +const WEATHER_REGION := "TestWeatherStorm"; +const WEATHER_TYPE_NONE := 255; +const WEATHER_RECT_X := 184; +const WEATHER_RECT_Y := 184; + +program testuoregion() + return 1; +endprogram + +function region_pc() + var pc := createAccountWithChar( "testuoregion", "pass" ); + if ( !pc ) + return pc; + endif + var moved := MoveObjectToLocation( pc, JR_X, JR_Y + 1, 0, flags := MOVEOBJECT_FORCELOCATION ); + if ( !moved ) + return ret_error( $"could not move the region pc: {moved}" ); + endif + return pc; +endfunction + +// GetRegionName reaches the justice region three different ways depending on +// what it is handed, because UObject::isa is exact class equality: an item is +// resolved through its top level owner, a player character through its own +// position (or its client's cached region while logged in), and anything else -- +// an NPC is CLASS_NPC, a container is CLASS_CONTAINER -- straight from where it +// stands. +exported function region_name_of_objects( resmngr ) + var npc := resmngr.CreateNPCFromTemplate( ":TestNPC:probe_npc", JR_X, JR_Y, 0, 0, _DEFAULT_REALM, + 1 ); + if ( !npc ) + return ret_error( $"failed to create npc: {npc}" ); + endif + var npc_region := GetRegionName( npc ); + if ( npc_region != JR_NAME ) + return ret_error( $"npc region: expected {JR_NAME}, got {npc_region}" ); + endif + + // An item inside a container answers for the container, which is the top + // level owner. + var cont := resmngr.CreateItemAtLocation( JR_X + 1, JR_Y, 0, 0x200001 ); + if ( !cont ) + return ret_error( $"failed to create container: {cont}" ); + endif + var coin := resmngr.CreateItemInContainer( cont, 0x0eed, 1 ); + if ( !coin ) + return ret_error( $"failed to create coin: {coin}" ); + endif + var coin_region := GetRegionName( coin ); + if ( coin_region != JR_NAME ) + return ret_error( $"contained item region: expected {JR_NAME}, got {coin_region}" ); + endif + + var pc := region_pc(); + if ( !pc ) + return pc; + endif + var pc_region := GetRegionName( pc ); + if ( pc_region != JR_NAME ) + return ret_error( $"pc region: expected {JR_NAME}, got {pc_region}" ); + endif + + var bad := GetRegionName( 0 ); + if ( bad ) + return ret_error( $"expected an error for a non-object, got {bad}" ); + endif + + return 1; +endfunction + +// The by-location form needs no object at all, and answers the background +// region's empty name outside the painted range. +exported function region_name_at_location( unused resmngr ) + var inside := GetRegionNameAtLocation( JR_X, JR_Y ); + if ( inside != JR_NAME ) + return ret_error( $"inside the region: expected {JR_NAME}, got {inside}" ); + endif + + var outside := GetRegionNameAtLocation( BG_X, BG_Y ); + if ( TypeOf( outside ) != "String" ) + return ret_error( $"outside the region: expected a string, got {TypeOf( outside )}" ); + endif + if ( outside != "" ) + return ret_error( $"the background region should be unnamed, got \"{outside}\"" ); + endif + + var bad_coord := GetRegionNameAtLocation( "not a number", JR_Y ); + if ( bad_coord ) + return ret_error( $"expected an error for a non-numeric x, got {bad_coord}" ); + endif + + var bad_realm := GetRegionNameAtLocation( JR_X, JR_Y, "nosuchrealm" ); + if ( bad_realm ) + return ret_error( $"expected an error for an unknown realm, got {bad_realm}" ); + endif + + return 1; +endfunction + +// The light level comes from whichever light region covers the location, and +// the two on this shard hold different levels. +exported function region_light_level( unused resmngr ) + var dark := GetRegionLightLevelAtLocation( LIGHT_X, LIGHT_Y ); + if ( TypeOf( dark ) != "Integer" ) + return ret_error( $"expected an integer light level, got {TypeOf( dark )}" ); + endif + if ( dark != LIGHT_DARK ) + return ret_error( $"inside {LIGHT_REGION}: expected {LIGHT_DARK}, got {dark}" ); + endif + + var base := GetRegionLightLevelAtLocation( BG_X, BG_Y ); + if ( base != LIGHT_BASE ) + return ret_error( $"outside {LIGHT_REGION}: expected {LIGHT_BASE}, got {base}" ); + endif + + // The realm is consulted as well as the coordinates, and the other realm has + // no dark patch at all. + var other_realm := GetRegionLightLevelAtLocation( LIGHT_X, LIGHT_Y, "britannia2" ); + if ( other_realm != LIGHT_BASE ) + return ret_error( $"the same spot in britannia2: expected {LIGHT_BASE}, got {other_realm}" ); + endif + + var bad_realm := GetRegionLightLevelAtLocation( LIGHT_X, LIGHT_Y, "nosuchrealm" ); + if ( bad_realm ) + return ret_error( $"expected an error for an unknown realm, got {bad_realm}" ); + endif + + return 1; +endfunction + +// A light region is set by name rather than by location, and the change is +// visible to the by-location read straight away. Only the reserved dark patch +// is ever written to -- the base regions cover the whole shard, so moving one +// of those would change the light for every client on it. +exported function set_region_light_level( unused resmngr ) + var res := 1; + + if ( !SetRegionLightLevel( LIGHT_REGION, LIGHT_BASE ) ) + return ret_error( $"could not set the light level of {LIGHT_REGION}" ); + endif + var changed := GetRegionLightLevelAtLocation( LIGHT_X, LIGHT_Y ); + if ( changed != LIGHT_BASE ) + res := ret_error( $"after the change: expected {LIGHT_BASE}, got {changed}" ); + endif + + // Both ends of the accepted range are fine; one past the top is not, and a + // negative level is read as unsigned so it is out of range too. + if ( res ) + if ( !SetRegionLightLevel( LIGHT_REGION, 0 ) ) + res := ret_error( "a light level of 0 should be accepted" ); + elseif ( !SetRegionLightLevel( LIGHT_REGION, LIGHT_MAX ) ) + res := ret_error( $"a light level of {LIGHT_MAX} should be accepted" ); + endif + endif + + if ( res ) + var too_bright := SetRegionLightLevel( LIGHT_REGION, LIGHT_MAX + 1 ); + if ( too_bright ) + res := ret_error( $"a light level past {LIGHT_MAX} should be refused, got {too_bright}" ); + endif + endif + + if ( res ) + var negative := SetRegionLightLevel( LIGHT_REGION, -1 ); + if ( negative ) + res := ret_error( $"a negative light level should be refused, got {negative}" ); + endif + endif + + if ( res ) + var unknown := SetRegionLightLevel( "nosuchlightregion", LIGHT_BASE ); + if ( unknown ) + res := ret_error( $"expected an error for an unknown region, got {unknown}" ); + endif + endif + + if ( res ) + var bad := SetRegionLightLevel( 0, LIGHT_BASE ); + if ( bad ) + res := ret_error( $"expected an error for a non-string region name, got {bad}" ); + endif + endif + + var restored := SetRegionLightLevel( LIGHT_REGION, LIGHT_DARK ); + if ( !restored ) + return ret_error( $"could not restore the light level of {LIGHT_REGION}: {restored}" ); + endif + + return res; +endfunction + +// Multi dimensions come from the multi definition rather than from any placed +// multi, so no house has to exist to ask for them. +exported function multi_dimensions( unused resmngr ) + var dims := GetMultiDimensions( MULTIID_HOUSE ); + if ( TypeOf( dims ) != "Struct" ) + return ret_error( $"expected a struct, got {TypeOf( dims )}: {dims}" ); + endif + if ( dims.xmin > dims.xmax ) + return ret_error( $"xmin {dims.xmin} is past xmax {dims.xmax}" ); + endif + if ( dims.ymin > dims.ymax ) + return ret_error( $"ymin {dims.ymin} is past ymax {dims.ymax}" ); + endif + + var missing := GetMultiDimensions( MULTIID_MISSING ); + if ( missing ) + return ret_error( $"expected an error for an undefined multi, got {missing}" ); + endif + + var bad := GetMultiDimensions( "0x6c" ); + if ( bad ) + return ret_error( $"expected an error for a non-integer multi id, got {bad}" ); + endif + + return 1; +endfunction + +// Command help is a file lookup down the character's own command level and +// every level below it, so the same command answers differently as the level +// changes. The shard has two levels, Player and Test, and only the Test one has +// a search directory. +exported function command_help( unused resmngr ) + var pc := region_pc(); + if ( !pc ) + return pc; + endif + + var res := 1; + pc.cmdlevel := 0; + var unprivileged := GetCommandHelp( pc, "dummycmd" ); + if ( unprivileged ) + res := ret_error( $"a player level character should find no help, got {unprivileged}" ); + endif + + if ( res ) + pc.cmdlevel := 1; + var help := GetCommandHelp( pc, "dummycmd" ); + if ( TypeOf( help ) != "String" ) + res := ret_error( $"expected the help text, got {TypeOf( help )}: {help}" ); + elseif ( !help.find( "dummycmd" ) ) + res := ret_error( $"the help text is not the fixture's: \"{help}\"" ); + endif + endif + + if ( res ) + // Reserved device names are refused before any directory is searched. + var device := GetCommandHelp( pc, "aux" ); + if ( device ) + res := ret_error( $"expected an error for a reserved device name, got {device}" ); + endif + endif + + if ( res ) + var missing := GetCommandHelp( pc, "nosuchcommand" ); + if ( missing ) + res := ret_error( $"expected an error for an unknown command, got {missing}" ); + endif + endif + + if ( res ) + var bad := GetCommandHelp( 0, "dummycmd" ); + if ( bad ) + res := ret_error( $"expected an error for a non-character, got {bad}" ); + endif + endif + + pc.cmdlevel := 0; + return res; +endfunction + +// A weather region is set by name, and the four levels it carries are written +// as one. Nothing reads them back from a script, so what is checked here is +// which calls are accepted and which are refused; showing the weather actually +// reaching somebody needs a logged-in client. +// +// The region is put back to "no weather" at the end. TestWeatherStorm is out in +// open water where nobody stands, but a client inside a weather region is sent +// a weather packet whenever the region changes underneath it. +exported function set_region_weather_level( unused resmngr ) + var res := 1; + + // aux and lightoverride have defaults, so both arities are worth exercising. + if ( !SetRegionWeatherLevel( WEATHER_REGION, 2, 30 ) ) + return ret_error( $"could not set the weather level of {WEATHER_REGION}" ); + endif + if ( !SetRegionWeatherLevel( WEATHER_REGION, 2, 30, 5, LIGHT_BASE ) ) + res := ret_error( "the five parameter form should be accepted" ); + endif + + if ( res ) + var unknown := SetRegionWeatherLevel( "nosuchweatherregion", 2, 30 ); + if ( unknown ) + res := ret_error( $"expected an error for an unknown region, got {unknown}" ); + endif + endif + + if ( res ) + var bad_name := SetRegionWeatherLevel( 0, 2, 30 ); + if ( bad_name ) + res := ret_error( $"expected an error for a non-string region name, got {bad_name}" ); + endif + endif + + if ( res ) + var bad_type := SetRegionWeatherLevel( WEATHER_REGION, "rain", 30 ); + if ( bad_type ) + res := ret_error( $"expected an error for a non-numeric type, got {bad_type}" ); + endif + endif + + var restored := SetRegionWeatherLevel( WEATHER_REGION, WEATHER_TYPE_NONE, 0, 0, -1 ); + if ( !restored ) + return ret_error( $"could not restore {WEATHER_REGION}: {restored}" ); + endif + + return res; +endfunction + +// Weather regions are the one region group whose painted zones can be moved at +// runtime. An empty region name is not an error but the undo: it puts the rect +// back to whichever region covered it when the shard loaded. +exported function assign_rect_to_weather( unused resmngr ) + var assigned := AssignRectToWeatherRegion( WEATHER_REGION, WEATHER_RECT_X, WEATHER_RECT_Y, + WEATHER_RECT_X + 3, WEATHER_RECT_Y + 3 ); + if ( !assigned ) + return ret_error( $"could not assign the rect to {WEATHER_REGION}: {assigned}" ); + endif + + var res := 1; + var unknown := AssignRectToWeatherRegion( "nosuchweatherregion", WEATHER_RECT_X, WEATHER_RECT_Y, + WEATHER_RECT_X + 3, WEATHER_RECT_Y + 3 ); + if ( unknown ) + res := ret_error( $"expected an error for an unknown region, got {unknown}" ); + endif + + if ( res ) + var bad_name := AssignRectToWeatherRegion( 0, WEATHER_RECT_X, WEATHER_RECT_Y, WEATHER_RECT_X + 3, + WEATHER_RECT_Y + 3 ); + if ( bad_name ) + res := ret_error( $"expected an error for a non-string region name, got {bad_name}" ); + endif + endif + + if ( res ) + var off_map := AssignRectToWeatherRegion( WEATHER_REGION, WEATHER_RECT_X, WEATHER_RECT_Y, 500, + 500 ); + if ( off_map ) + res := ret_error( $"expected an error for a rect off the map, got {off_map}" ); + endif + endif + + if ( res ) + var bad_realm := AssignRectToWeatherRegion( WEATHER_REGION, WEATHER_RECT_X, WEATHER_RECT_Y, + WEATHER_RECT_X + 3, WEATHER_RECT_Y + 3, + "nosuchrealm" ); + if ( bad_realm ) + res := ret_error( $"expected an error for an unknown realm, got {bad_realm}" ); + endif + endif + + // The empty name is the documented way back to the load time layout. + var restored := AssignRectToWeatherRegion( "", WEATHER_RECT_X, WEATHER_RECT_Y, WEATHER_RECT_X + 3, + WEATHER_RECT_Y + 3 ); + if ( !restored ) + return ret_error( $"could not put the rect back: {restored}" ); + endif + + return res; +endfunction diff --git a/testsuite/pol/testpkgs/misc/test_uowalk.src b/testsuite/pol/testpkgs/misc/test_uowalk.src new file mode 100644 index 000000000..6541dbdd6 --- /dev/null +++ b/testsuite/pol/testpkgs/misc/test_uowalk.src @@ -0,0 +1,237 @@ +use uo; +use os; +use vitals; + +include "testutil"; + +// The generated test map is the whole terrain the shard has: grass at z 0 +// everywhere inside 28..163, water at z -5 outside it. Grass is MoveLand only +// and water is MoveSea only, so a movemode decides which of the two a query can +// stand on. See FileGenerator::modifyMap in pol-core/poltool/testfiles.cpp. +const LAND_X := 140; +const LAND_Y := 140; +const LAND_Z := 0; + +const SEA_X := 10; +const SEA_Y := 100; +const SEA_Z := -5; + +// uo.em declares no names for the eight facings, so these are spelled out here. +// An odd one is diagonal, which CanWalk treats differently. +const DIR_NORTH := 0; +const DIR_NORTHEAST := 1; + +// Off the edge of the 192x192 realm. +const OFF_X := 500; +const OFF_Y := 500; + +program testuowalk() + return 1; +endprogram + +// A direction is a single step from the starting point, and the answer is the z +// the walker would end up at rather than a plain yes. That z is 0 on this map, +// which is falsy, so the result has to be told apart from an error by its type. +exported function can_walk_on_land( unused resmngr ) + var north := CanWalk( "L", LAND_X, LAND_Y, LAND_Z, DIR_NORTH ); + if ( TypeOf( north ) != "Integer" ) + return ret_error( $"walking north: expected an integer z, got {north}" ); + endif + if ( north != LAND_Z ) + return ret_error( $"walking north: expected z {LAND_Z}, got {north}" ); + endif + + // An odd facing is diagonal, which is only allowed if at least one of the two + // orthogonals beside it is walkable too. + var northeast := CanWalk( "L", LAND_X, LAND_Y, LAND_Z, DIR_NORTHEAST ); + if ( TypeOf( northeast ) != "Integer" ) + return ret_error( $"walking northeast: expected an integer z, got {northeast}" ); + endif + + return 1; +endfunction + +// Given a second pair of coordinates instead of a facing, the direction is +// worked out from the two positions -- and that pair has to be on the map. +exported function can_walk_to_coordinates( unused resmngr ) + var east := CanWalk( "L", LAND_X, LAND_Y, LAND_Z, LAND_X + 1, LAND_Y ); + if ( TypeOf( east ) != "Integer" ) + return ret_error( $"walking east: expected an integer z, got {east}" ); + endif + if ( east != LAND_Z ) + return ret_error( $"walking east: expected z {LAND_Z}, got {east}" ); + endif + + var off_map := CanWalk( "L", LAND_X, LAND_Y, LAND_Z, OFF_X, OFF_Y ); + if ( off_map ) + return ret_error( $"expected an error for a target off the map, got {off_map}" ); + endif + + return 1; +endfunction + +// Land and sea are exclusive on this map, so each movemode walks on exactly one +// of the two. +exported function can_walk_movemode_matters( unused resmngr ) + var sea_on_land := CanWalk( "S", LAND_X, LAND_Y, LAND_Z, DIR_NORTH ); + if ( sea_on_land ) + return ret_error( $"sea movemode should not walk on grass, got {sea_on_land}" ); + endif + + var sea_on_water := CanWalk( "S", SEA_X, SEA_Y, SEA_Z, DIR_NORTH ); + if ( TypeOf( sea_on_water ) != "Integer" ) + return ret_error( $"sea movemode on water: expected an integer z, got {sea_on_water}" ); + endif + if ( sea_on_water != SEA_Z ) + return ret_error( $"sea movemode on water: expected z {SEA_Z}, got {sea_on_water}" ); + endif + + var land_on_water := CanWalk( "L", SEA_X, SEA_Y, SEA_Z, DIR_NORTH ); + if ( land_on_water ) + return ret_error( $"land movemode should not walk on water, got {land_on_water}" ); + endif + + var bad := CanWalk( 5, LAND_X, LAND_Y, LAND_Z, DIR_NORTH ); + if ( bad ) + return ret_error( $"expected an error for a non-string movemode, got {bad}" ); + endif + + return 1; +endfunction + +// A path is the steps after the starting point, so it ends on the goal. +exported function find_path_across_land( unused resmngr ) + var path := FindPath( LAND_X, LAND_Y, LAND_Z, LAND_X + 4, LAND_Y + 4, LAND_Z ); + if ( TypeOf( path ) != "Array" ) + return ret_error( $"expected an array of steps, got {path}" ); + endif + if ( !path.size() ) + return ret_error( "the path across open grass is empty" ); + endif + + var last := path[path.size()]; + if ( last.x != LAND_X + 4 || last.y != LAND_Y + 4 ) + return ret_error( $"the path ends at {last.x},{last.y}, not at the goal" ); + endif + + // A negative search skirt is clamped to nothing rather than refused, and + // ignoring doors takes the other branch of the blocker setup. + var skirtless := FindPath( LAND_X, LAND_Y, LAND_Z, LAND_X + 2, LAND_Y + 2, LAND_Z, _DEFAULT_REALM, + FP_IGNORE_DOORS, -1 ); + if ( TypeOf( skirtless ) != "Array" ) + return ret_error( $"expected an array with a negative skirt, got {skirtless}" ); + endif + + return 1; +endfunction + +exported function find_path_errors( unused resmngr ) + // The range limit is checked before the coordinates are, so it wins even for + // two perfectly good locations. + var too_far := FindPath( 40, 40, 0, 120, 120, 0 ); + if ( too_far ) + return ret_error( $"expected a range error, got {too_far}" ); + endif + + var no_movemode := FindPath( LAND_X, LAND_Y, LAND_Z, LAND_X + 2, LAND_Y, LAND_Z, _DEFAULT_REALM, + FP_IGNORE_MOBILES, 5, "" ); + if ( no_movemode ) + return ret_error( $"expected a movemode error, got {no_movemode}" ); + endif + + var bad_start := FindPath( OFF_X, 40, 0, OFF_X + 5, 45, 0 ); + if ( bad_start ) + return ret_error( $"expected a start coordinate error, got {bad_start}" ); + endif + + var bad_end := FindPath( 188, 188, 0, 193, 191, 0 ); + if ( bad_end ) + return ret_error( $"expected an end coordinate error, got {bad_end}" ); + endif + + // Grass and water are not connected for a land walker, so this one gets past + // every precondition and the search itself runs to exhaustion. The two ends + // are close enough together to stay inside the range limit. + var unreachable := FindPath( SEA_X + 20, SEA_Y, 0, SEA_X + 10, SEA_Y, SEA_Z ); + if ( unreachable ) + return ret_error( $"expected no path from grass to water, got {unreachable}" ); + endif + + var bad_param := FindPath( "here", 40, 0, 45, 45, 0 ); + if ( bad_param ) + return ret_error( $"expected an error for a non-numeric coordinate, got {bad_param}" ); + endif + + return 1; +endfunction + +function ghost_listed( list, serial ) + foreach entry in list + if ( entry.serial == serial ) + return 1; + endif + endforeach + return 0; +endfunction + +// A ghost is a dead player character, and this listing walks the world zones to +// find one. A player character is only ever filed in a zone when it logs in -- +// create.cpp and pol.cpp register one on PlayerEnter, and loading it from +// pcs.txt only bumps a counter (uimport.cpp) -- so an offline character is in no +// zone at all and no location query can reach it, alive or dead. That is why +// ListOfflineMobilesInRealm exists as a separate scan of the object hash. +// +// The listing therefore cannot be shown finding anybody without a logged-in +// client, which belongs to the client tests. What is checked here is the shape +// of the answer and that an offline character stays out of it even once it is +// dead: this shard works a hit point maximum out through a hook that only runs +// for a mobile that is online, so an offline one has a maximum of zero and a +// single point of raw damage takes it all the way to dead. +exported function list_ghosts_near_location( unused resmngr ) + var pc := createAccountWithChar( "testuowalk", "pass" ); + if ( !pc ) + return pc; + endif + + var alive := ListGhostsNearLocation( pc.x, pc.y, pc.z, 2 ); + if ( TypeOf( alive ) != "Array" ) + return ret_error( $"expected an array, got {alive}" ); + endif + if ( ghost_listed( alive, pc.serial ) ) + return ret_error( "a living character was listed as a ghost" ); + endif + + var damaged := ApplyRawDamage( pc, 1, DAMAGE_NO_REPSYS, DAMAGE_NO_SHOW ); + if ( damaged == error ) + return ret_error( $"could not damage the ghost pc: {damaged}" ); + endif + if ( !pc.dead ) + return ret_error( "one point of raw damage did not kill an offline character" ); + endif + + var res := 1; + var ghosts := ListGhostsNearLocation( pc.x, pc.y, pc.z, 2 ); + if ( TypeOf( ghosts ) != "Array" ) + res := ret_error( $"expected an array, got {ghosts}" ); + elseif ( ghost_listed( ghosts, pc.serial ) ) + res := ret_error( "an offline character is in no zone and cannot be listed" ); + endif + + if ( res ) + var bad := ListGhostsNearLocation( "not a number", pc.y, pc.z, 2 ); + if ( bad ) + res := ret_error( $"expected an error for a non-numeric x, got {bad}" ); + endif + endif + + var revived := Resurrect( pc, RESURRECT_FORCELOCATION ); + if ( !revived ) + return ret_error( $"could not resurrect the ghost pc: {revived}" ); + endif + var corpse := pc.getcorpse(); + if ( corpse ) + DestroyItem( corpse ); + endif + + return res; +endfunction diff --git a/testsuite/pol/testpkgs/misc/textcmd/test/dummycmd.txt b/testsuite/pol/testpkgs/misc/textcmd/test/dummycmd.txt new file mode 100644 index 000000000..20935b6b8 --- /dev/null +++ b/testsuite/pol/testpkgs/misc/textcmd/test/dummycmd.txt @@ -0,0 +1 @@ +dummycmd: the help text GetCommandHelp reads back. diff --git a/testsuite/pol/testpkgs/misc/uoattach.src b/testsuite/pol/testpkgs/misc/uoattach.src new file mode 100644 index 000000000..fc03287ed --- /dev/null +++ b/testsuite/pol/testpkgs/misc/uoattach.src @@ -0,0 +1,17 @@ +use os; +use uo; + +// Holds a character attached on behalf of test_uomenu.src, so that the caller +// can watch Attach refuse a character another script already owns. Attach is +// per script, so that refusal can only be reached from a second one. +// +// The caller's resource manager kills this script at the end of the case; the +// timeout is only there so a failing run cannot leave it behind. +program uoattach( args ) + var { script, chr } := args; + + script.sendevent( struct{ attached := Attach( chr ) } ); + + Wait_For_Event( 30 ); + return 1; +endprogram diff --git a/testsuite/pol/testpkgs/misc/uoconstraint.cfg b/testsuite/pol/testpkgs/misc/uoconstraint.cfg new file mode 100644 index 000000000..f001ab6cd --- /dev/null +++ b/testsuite/pol/testpkgs/misc/uoconstraint.cfg @@ -0,0 +1,34 @@ +############################################################################# +## ApplyConstraint fixture +############################################################################# +# +# Read by testpkgs/misc/test_uomenu.src. ApplyConstraint looks each objtype in +# the list it is given up as an *integer* key in a config file, so the element +# names here are numbers; the leading 0x is honoured by strtoul. +# +# The objtypes are made up. ApplyConstraint never consults itemdesc -- it only +# matches the numbers it is handed against the keys below -- so nothing here +# has to be a real item. +# + +Ingredient 0x900001 +{ + Amount 5 +} + +Ingredient 0x900002 +{ + Amount 20 +} + +// No Amount at all: skipped whatever the caller asks for. +Ingredient 0x900003 +{ + Name nothing +} + +// Amount is a string, not an integer, so the value comparison never happens. +Ingredient 0x900004 +{ + Amount plenty +}