polserver/testsuite/pol/testpkgs/attack/test_attackable_api.src
turleypol e6abc8fcfe
Attackable item (#884)
* introduced Attackable wrapper class for opponents to be able to also
attack items

* changed set_opponent to use Attackable
moved first logic into Attackable

* added dynamic property which returns a pointer of the object instead of
a copy like the current imp.
needed to be able to eg store a vector

* send engage/disengage events
send hp status bar

* send attackable item flag in world item pkt

* apply damage for items + evid_damaged

* react to statrequests

* adapted is_attackable and do attack_effects

* fixed clear_opponent_of

* fixed clear_opponent_of

* allow attack pkt

* apply damage

* items need to keep track of the opponents to be able to cleanup on
destroy

* updated dynproperties

* updated dynprops a bit more.
made the structs trivial copyable
updated concept to not repeat the types

* run hitscript, cleanup

* listhostiles and attack_once accept now Attackable

* set opponent when dblclicked

* dont let hp underflow

* only attack if you are in warmode

* evid_opponent_moved for attackable items

* if its the same opponent dont send engaged events again
added test for listhostiles with two mobs

* allow attackable item for setopponent

* some tests npc vs item

* missing files

* fixed clang tidy complain

* test for enter/left area

* do not send hp updates while char gets created and not yet added to the
  world

* testclient fight packets

* client event constants

* added client tests for attacking npc and item

* increase damage

* removed leftover comment

* when an attackable item was destroyed the opponents where not correctly cleared

* orphan check for is_attackable

* moved statmsg for items

* init member

* moved send_hightlight back into if statement
check before dereferencing

* use range version of statmsg

* adapted em params

* comparison operator
made explicit constructors
for symmetry return damage in Item::apply_damage

* get rid of bools

* split attack method a bit

* use constants for F3 flag

* destroy item on hit if hp reaches 0 if no controlscript exists

* only toplevel items are attackable
small refactoring
more tests

* more tests

* even more tests

* docs
2026-07-30 20:27:36 +02:00

589 lines
19 KiB
Text

// script level api of attackable items: the module functions that were widened
// from "character" to "attackable", and the item flag semantics that come with
// the Attackable itemdesc entry.
use uo;
use os;
use polsys;
use vitals;
include "testutil";
include "sysevent";
program npc_ai()
return 1;
endprogram
// attack_once accepts an item, damages it and reports the new error cases
exported function api_attack_once( resmgn )
var npc := make_npc( resmgn );
if ( !npc )
return npc;
endif
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
// nothing is selected and nobody attacks the npc
var res := npc.attack_once();
if ( res != error )
return ret_error( $"attack_once without an opponent: {res}" );
elseif ( res.errortext != "No opponent" )
return ret_error( $"wrong error without an opponent: {res.errortext}" );
endif
SetObjProperty( npc, "#Damage", 10 );
clear_event_queue();
if ( ( res := npc.attack_once( item ) ) != 1 )
return ret_error( $"attack_once( item ) failed: {res}" );
endif
// the swing runs the attack hit script with the item as defender
var ev := wait_for_type( "HIT", "no hit event after attack_once" );
if ( !ev )
return ev;
elseif ( ev.attacker != npc || ev.defender != item )
return ret_error( $"wrong hit event: {ev}" );
endif
if ( item.hp != 90 )
return ret_error( $"item not damaged: {item.hp}" );
endif
// a single swing does not select an opponent
if ( npc.opponent )
return ret_error( $"attack_once selected an opponent: {npc.opponent}" );
endif
// an item without the flag is no target
var plain := resmgn.CreateItemAtLocation( 52, 100, 0, "NonAttackableItem" );
if ( !plain )
return ret_error( $"Could not create item: {plain}" );
endif
res := npc.attack_once( plain );
if ( res != error )
return ret_error( $"attack_once( plain item ): {res}" );
elseif ( res.errortext != "Opponent is not attackable" )
return ret_error( $"wrong error for a plain item: {res.errortext}" );
endif
// and neither is an attackable one inside a container
var backpack := resmgn.CreateItemAtLocation( 52, 100, 0, 0xE75 );
if ( !backpack )
return ret_error( $"Could not create container: {backpack}" );
endif
var contained := resmgn.CreateItemInContainer( backpack, "AttackableNoScript" );
if ( !contained )
return ret_error( $"Could not create item: {contained}" );
endif
res := npc.attack_once( contained );
if ( res != error )
return ret_error( $"attack_once( contained item ): {res}" );
elseif ( res.errortext != "Opponent is not attackable" )
return ret_error( $"wrong error for a contained item: {res.errortext}" );
endif
// the exact text is not checked here: a failing getUObjectParam leaves its
// own error behind, which overrides the "Invalid parameter type" of the
// method itself
res := npc.attack_once( 5 );
if ( res != error )
return ret_error( $"attack_once( 5 ): {res}" );
endif
return 1;
endfunction
// SetOpponent takes attackable items and rejects everything else
exported function api_setopponent( resmgn )
var npc := make_npc( resmgn );
if ( !npc )
return npc;
endif
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
var plain := resmgn.CreateItemAtLocation( 52, 100, 0, "NonAttackableItem" );
if ( !plain )
return ret_error( $"Could not create item: {plain}" );
endif
var res := set_opponent( npc, plain );
if ( !res )
return res;
elseif ( res.result != 0 )
return ret_error( $"SetOpponent( plain item ) succeeded: {res.result}" );
elseif ( res.errortext != "Invalid parameter" )
return ret_error( $"wrong error for a plain item: '{res.errortext}'" );
endif
if ( npc.opponent )
return ret_error( $"a rejected object was selected: {npc.opponent}" );
endif
res := set_opponent( npc, item );
if ( !res )
return res;
elseif ( res.result != 1 )
return ret_error( $"SetOpponent( item ) failed: {res.errortext}" );
endif
if ( npc.opponent != item )
return ret_error( $"wrong opponent selected: {npc.opponent}" );
endif
// SetOpponent( 0 ) clears the opponent. Only the effect is checked: the
// failing getUObjectParam leaves its error behind, so the documented return
// value of 0 never reaches the script.
res := set_opponent( npc, 0 );
if ( !res )
return res;
endif
if ( npc.opponent )
return ret_error( $"opponent was not cleared: {npc.opponent}" );
endif
return 1;
endfunction
// only attackable toplevel items can be damaged
exported function api_applydamage_errors( resmgn )
var plain := resmgn.CreateItemAtLocation( 51, 100, 0, "NonAttackableItem" );
if ( !plain )
return ret_error( $"Could not create item: {plain}" );
endif
var res := ApplyDamage( plain, 10 );
if ( res != error )
return ret_error( $"ApplyDamage( plain item ): {res}" );
elseif ( res.errortext != "Object is not damageable" )
return ret_error( $"wrong error for a plain item: {res.errortext}" );
endif
if ( plain.hp != 100 )
return ret_error( $"plain item was damaged: {plain.hp}" );
endif
res := ApplyRawDamage( plain, 10 );
if ( res != error )
return ret_error( $"ApplyRawDamage( plain item ): {res}" );
elseif ( res.errortext != "Object is not damageable" )
return ret_error( $"wrong raw error for a plain item: {res.errortext}" );
endif
// an attackable item inside a container is no target either
var backpack := resmgn.CreateItemAtLocation( 51, 100, 0, 0xE75 );
if ( !backpack )
return ret_error( $"Could not create container: {backpack}" );
endif
var contained := resmgn.CreateItemInContainer( backpack, "AttackableNoScript" );
if ( !contained )
return ret_error( $"Could not create item: {contained}" );
endif
res := ApplyDamage( contained, 10 );
if ( res != error )
return ret_error( $"ApplyDamage( contained item ): {res}" );
elseif ( res.errortext != "Object is not damageable" )
return ret_error( $"wrong error for a contained item: {res.errortext}" );
endif
if ( contained.hp != 100 )
return ret_error( $"contained item was damaged: {contained.hp}" );
endif
// the damage range check still applies to items
var item := resmgn.CreateItemAtLocation( 52, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
res := ApplyDamage( item, 30001 );
if ( res != error )
return ret_error( $"ApplyDamage( item, 30001 ): {res}" );
elseif ( res.errortext != "Damage is out of range" )
return ret_error( $"wrong error for too much damage: {res.errortext}" );
endif
if ( item.hp != 100 )
return ret_error( $"item was damaged: {item.hp}" );
endif
return 1;
endfunction
// ApplyRawDamage subtracts the hitpoints of an item one to one, it is not
// scaled by 100 the way the hundredths of a mobile are
exported function api_applyrawdamage( resmgn )
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
var res := ApplyRawDamage( item, 10 );
if ( res != 1 )
return ret_error( $"ApplyRawDamage failed: {res}" );
endif
if ( item.hp != 90 )
return ret_error( $"item not damaged: {item.hp}" );
endif
return 1;
endfunction
// more damage than hitpoints destroys an item that has no control script
exported function api_applydamage_overkill( resmgn )
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
// the requested damage is reported, not the amount of hitpoints that were left
var res := ApplyDamage( item, 500 );
if ( res == error )
return ret_error( $"ApplyDamage failed: {res.errortext}" );
elseif ( res != 500 )
return ret_error( $"unexpected damage reported: {res}" );
endif
if ( item )
return ret_error( $"item survived with {item.hp} hp" );
endif
return 1;
endfunction
// ListHostiles on items: rejections, range and the hidden/los flags
exported function api_listhostiles( resmgn )
var npc := make_npc( resmgn );
if ( !npc )
return npc;
endif
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
var plain := resmgn.CreateItemAtLocation( 52, 100, 0, "NonAttackableItem" );
if ( !plain )
return ret_error( $"Could not create item: {plain}" );
endif
var res := ListHostiles( plain );
if ( res != error )
return ret_error( $"ListHostiles( plain item ): {res}" );
elseif ( res.errortext != "Invalid parameter" )
return ret_error( $"wrong error for a plain item: {res.errortext}" );
endif
// an attackable item nobody attacks has an empty, but valid, list
res := ListHostiles( item );
if ( res == error )
return ret_error( $"ListHostiles( item ) failed: {res.errortext}" );
elseif ( res.size() )
return ret_error( $"unexpected hostiles: {res}" );
endif
if ( !( res := set_opponent( npc, item ) ) )
return res;
elseif ( res.result != 1 )
return ret_error( $"SetOpponent( item ) failed: {res.errortext}" );
endif
if ( ( res := ListHostiles( item ) ).size() != 1 )
return ret_error( $"Hostile list != 1: {res}" );
elseif ( res[1] != npc )
return ret_error( $"wrong hostile: {res[1]}" );
endif
// the npc stands one tile away, so it drops out of a range of 0
if ( ( res := ListHostiles( item, 0 ) ).size() )
return ret_error( $"Hostile list of range 0 != 0: {res}" );
endif
// both stand in the open, so the los filter keeps the npc
if ( ( res := ListHostiles( item, 20, LH_FLAG_LOS ) ).size() != 1 )
return ret_error( $"Hostile list with LH_FLAG_LOS != 1: {res}" );
endif
npc.hidden := 1;
if ( ( res := ListHostiles( item ) ).size() )
return ret_error( $"hidden hostile was listed: {res}" );
endif
if ( ( res := ListHostiles( item, 20, LH_FLAG_INCLUDE_HIDDEN ) ).size() != 1 )
return ret_error( $"Hostile list with LH_FLAG_INCLUDE_HIDDEN != 1: {res}" );
endif
npc.hidden := 0;
return 1;
endfunction
// the flag shows up in the itemdesc struct
exported function api_itemdesc_attackable()
var desc := GetItemDescriptor( "AttackableItem" );
if ( !desc )
return ret_error( $"Could not get itemdesc: {desc}" );
elseif ( desc.Attackable != 1 )
return ret_error( $"Attackable != 1: {desc.Attackable}" );
endif
desc := GetItemDescriptor( "NonAttackableItem" );
if ( !desc )
return ret_error( $"Could not get itemdesc: {desc}" );
elseif ( desc.Attackable != 0 )
return ret_error( $"Attackable != 0: {desc.Attackable}" );
endif
return 1;
endfunction
// attackable items never stack, even if their graphic says otherwise
exported function api_not_stackable( resmgn )
var backpack := resmgn.CreateItemAtLocation( 51, 100, 0, 0xE75 );
if ( !backpack )
return ret_error( $"Could not create container: {backpack}" );
endif
var attackable1 := resmgn.CreateItemInContainer( backpack, "AttackableStackable" );
var attackable2 := resmgn.CreateItemInContainer( backpack, "AttackableStackable" );
if ( !attackable1 || !attackable2 )
return ret_error( $"Could not create items: {attackable1}, {attackable2}" );
endif
var res := IsStackable( attackable1, attackable2 );
if ( res != error )
return ret_error( $"attackable items are stackable: {res}" );
elseif ( res.errortext != "That item type is not stackable." )
return ret_error( $"wrong error: {res.errortext}" );
endif
// the same graphic without the flag stacks as usual
var normal1 := resmgn.CreateItemInContainer( backpack, "NormalStackable" );
var normal2 := resmgn.CreateItemInContainer( backpack, "NormalStackable" );
if ( !normal1 || !normal2 )
return ret_error( $"Could not create items: {normal1}, {normal2}" );
endif
res := IsStackable( normal1, normal2 );
if ( res != 1 )
return ret_error( $"the same graphic without the flag does not stack: {res}" );
endif
return 1;
endfunction
// a copy of an attackable item stays attackable
exported function api_clone_keeps_flag( resmgn )
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
var copy := CreateItemCopyAtLocation( 52, 100, 0, item );
if ( !copy )
return ret_error( $"Could not copy item: {copy}" );
endif
var res := check_copy( copy );
DestroyItem( copy );
return res;
endfunction
// an equipped item is no combat target, but becomes one again once dropped
exported function api_equipped_inert( resmgn )
var npc := make_npc( resmgn );
if ( !npc )
return npc;
endif
var shirt := resmgn.CreateItemAtLocation( 50, 100, 0, "AttackableShirt" );
if ( !shirt )
return ret_error( $"Could not create item: {shirt}" );
endif
// on the ground it is a valid target
var res := ApplyDamage( shirt, 10 );
if ( res == error )
return ret_error( $"item on the ground took no damage: {res.errortext}" );
elseif ( shirt.hp != 90 )
return ret_error( $"item not damaged: {shirt.hp}" );
endif
if ( !( res := EquipItem( npc, shirt ) ) )
return ret_error( $"Could not equip the item: {res}" );
endif
res := ApplyDamage( shirt, 10 );
if ( res != error )
return ret_error( $"equipped item took damage: {res}" );
elseif ( res.errortext != "Object is not damageable" )
return ret_error( $"wrong error for an equipped item: {res.errortext}" );
endif
if ( shirt.hp != 90 )
return ret_error( $"equipped item was damaged: {shirt.hp}" );
endif
res := ListHostiles( shirt );
if ( res != error )
return ret_error( $"equipped item is a combat target: {res}" );
endif
// and back on the ground it is attackable again
if ( !( res := MoveObjectToLocation( shirt, 50, 100, 0 ) ) )
return ret_error( $"Could not drop the item: {res}" );
endif
res := ApplyDamage( shirt, 10 );
if ( res == error )
return ret_error( $"dropped item took no damage: {res.errortext}" );
elseif ( shirt.hp != 80 )
return ret_error( $"dropped item not damaged: {shirt.hp}" );
endif
return 1;
endfunction
// moving an item into another realm ends every fight it was part of
exported function api_realm_change_clears_opps( resmgn )
var npc := make_npc( resmgn );
if ( !npc )
return npc;
endif
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
var res := set_opponent( npc, item );
if ( !res )
return res;
elseif ( res.result != 1 )
return ret_error( $"SetOpponent( item ) failed: {res.errortext}" );
endif
if ( ListHostiles( item ).size() != 1 )
return ret_error( "npc did not engage the item" );
endif
if ( !( res := MoveObjectToLocation( item, 10, 11, -5, "britannia2",
MOVEOBJECT_FORCELOCATION ) ) )
return ret_error( $"Could not move the item: {res}" );
endif
if ( ( res := ListHostiles( item ) ).size() )
return ret_error( $"Hostile list != 0 after the realm change: {res}" );
endif
if ( npc.opponent )
return ret_error( $"npc still attacks the item in another realm: {npc.opponent}" );
endif
return 1;
endfunction
// a refusing destroy script keeps an item alive, both when its hitpoints run
// out and when DestroyItem is called
exported function api_destroy_script_refusal( resmgn )
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableGuardedItem" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
SetObjProperty( item, "#NoDestroy", 1 );
var res := ApplyDamage( item, 200 );
if ( res == error )
return ret_error( $"ApplyDamage failed: {res.errortext}" );
endif
if ( !item )
return ret_error( "item was destroyed although its destroy script refused" );
elseif ( item.hp != 0 )
return ret_error( $"item hp != 0: {item.hp}" );
endif
res := DestroyItem( item );
if ( res == error )
return ret_error( $"DestroyItem returned an error instead of 0: {res.errortext}" );
elseif ( res != 0 )
return ret_error( $"DestroyItem did not report the refusal: {res}" );
endif
if ( !item )
return ret_error( "item was destroyed against the refusal" );
endif
// without the guard it can be destroyed again
EraseObjProperty( item, "#NoDestroy" );
res := DestroyItem( item );
if ( res != 1 )
return ret_error( $"DestroyItem failed: {res}" );
endif
if ( item )
return ret_error( "item still exists" );
endif
return 1;
endfunction
// assigning hitpoints clamps into the storage range and never destroys the item
exported function api_hp_member( resmgn )
var item := resmgn.CreateItemAtLocation( 51, 100, 0, "AttackableNoScript" );
if ( !item )
return ret_error( $"Could not create item: {item}" );
endif
item.hp := -5;
if ( item.hp != 0 )
return ret_error( $"negative hp not clamped: {item.hp}" );
endif
if ( !item )
return ret_error( "assigning 0 hp destroyed the item" );
endif
item.hp := 100000;
if ( item.hp != 65535 )
return ret_error( $"hp not clamped to the storage range: {item.hp}" );
endif
item.hp := 100;
if ( item.hp != 100 )
return ret_error( $"hp not restored: {item.hp}" );
endif
return 1;
endfunction
/**
* Helper functions
*/
function make_npc( resmgn )
var npc := resmgn.CreateNPCFromTemplate( ":testattack:test_attack", 50, 100, 0 );
if ( !npc )
return ret_error( $"Could not create NPC: {npc}" );
endif
npc.hitchance_mod := 1000;
SetObjProperty( npc, "#TestPid", GetPid() );
SetName( npc, "npc1" );
clear_event_queue();
return npc;
endfunction
// lets the ai script call SetOpponent and returns its result
function set_opponent( npc, opponent )
npc.process.sendevent( struct{ type := "set_opponent", source := opponent } );
return wait_for_type( "set_opponent_result", "no result from SetOpponent" );
endfunction
// waits for an event of the given type. deadline based, since a running fight
// keeps sending hit events that have to be skipped.
function wait_for_type( evtype, errormessage, timeout := 5 )
var deadline := ReadMillisecondClock() + timeout * 1000;
while ( ReadMillisecondClock() < deadline )
var ev := wait_for_event( 1 );
if ( ev && ev.type == evtype )
return ev;
endif
endwhile
return ret_error( errormessage );
endfunction
function check_copy( copy )
var res := ApplyDamage( copy, 10 );
if ( res == error )
return ret_error( $"the copy is not attackable: {res.errortext}" );
elseif ( copy.hp != 90 )
return ret_error( $"the copy was not damaged: {copy.hp}" );
endif
return 1;
endfunction