polserver/testsuite/pol/testpkgs/attack/test_attackable_item.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

586 lines
18 KiB
Text

use uo;
use os;
use vitals;
include "testutil";
include "sysevent";
program npc_ai()
return 1;
endprogram
// checks on item listhostile, engaged-, disengage-event
exported function attack_check_listhostiles( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
npc1.process.sendevent( struct{ type := "enable_events" } );
// npc1 attacks item
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
// Collect attack and engaged event
if ( !( evs := collect_events( 2 ) ) )
return evs;
endif
var hit_ev := evs.filter( @( ev ) { return ev.type == "HIT"; } )[1];
var engaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1];
if ( !hit_ev )
return ret_error( $"no hit event: {evs}" );
endif
if ( !engaged_ev )
return ret_error( $"no engaged event: {evs}" );
endif
if ( hit_ev.defender != item || hit_ev.attacker != npc1 )
return ret_error( $"wrong hit event: {hit_ev}" );
endif
if ( engaged_ev.source != npc1 )
return ret_error( $"wrong engaged event: {engaged_ev}" );
endif
var hostile := ListHostiles( item );
if ( hostile.size() != 1 )
return ret_error( $"Hostile list != 1: {hostile.size()}" );
endif
if ( hostile[1] != npc1 )
return ret_error( $"wrong opponent set: {hostile[1]}" );
endif
// stop attacking
npc1.process.sendevent( struct{ type := SYSEVENT_DISENGAGED, source := item,
sleep := 0, unhide := false } );
if ( !( evs := collect_events( 1 ) ) )
return evs;
endif
var disengaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_DISENGAGED; } )[1];
if ( !disengaged_ev )
return ret_error( $"no disengaged event: {evs}" );
endif
if ( disengaged_ev.source != npc1 )
return ret_error( $"wrong disengaged event: {disengaged_ev}" );
endif
hostile := ListHostiles( item );
if ( hostile.size() )
return ret_error( $"Hostile list != 0: {hostile}" );
endif
return 1;
endfunction
// checks on item damage event + ApplyDamage
exported function attack_check_damage( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
SetObjProperty( npc1, "#Damage", 10 );
npc1.process.sendevent( struct{ type := "enable_events" } );
// npc1 attacks item
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
// Collect attack, engaged and damage event
if ( !( evs := collect_events( 3 ) ) )
return evs;
endif
var hit_ev := evs.filter( @( ev ) { return ev.type == "HIT"; } )[1];
var engaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1];
var damaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_DAMAGED; } )[1];
if ( !hit_ev )
return ret_error( $"no hit event: {evs}" );
endif
if ( !engaged_ev )
return ret_error( $"no engaged event: {evs}" );
endif
if ( !damaged_ev )
return ret_error( $"no damaged event: {evs}" );
endif
if ( hit_ev.defender != item || hit_ev.attacker != npc1 )
return ret_error( $"wrong hit event: {hit_ev}" );
endif
if ( engaged_ev.source != npc1 )
return ret_error( $"wrong engaged event: {engaged_ev}" );
endif
if ( damaged_ev.source != npc1 || damaged_ev.damage != 10 )
return ret_error( $"wrong damaged event: {damaged_ev}" );
endif
if ( item.hp != item.maxhp - 10 )
return ret_error( $"item not damaged: {item.hp}/{item.maxhp}" );
endif
return 1;
endfunction
// checks on item opponent move event
exported function attack_check_moved( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
npc1.process.sendevent( struct{ type := "enable_events" } );
MoveObjectToLocation( npc1, npc1.x - 2, npc1.y, npc1.z );
// npc1 attacks item
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
// Collect engaged
if ( !( evs := collect_events( 1, "no event after engaged" ) ) )
return evs;
endif
var engaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1];
if ( !engaged_ev )
return ret_error( $"no engaged event: {evs}" );
endif
if ( engaged_ev.source != npc1 )
return ret_error( $"wrong engaged event: {engaged_ev}" );
endif
MoveObjectToLocation( npc1, npc1.x - 2, npc1.y, npc1.z );
if ( !( evs := collect_events( 1, "no event after moveobject" ) ) )
return evs;
endif
var moved_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_OPPONENT_MOVED; } )[1];
if ( !moved_ev )
return ret_error( $"no moved event: {evs}" );
endif
if ( moved_ev.source != npc1 )
return ret_error( $"wrong moved event: {moved_ev}" );
endif
return 1;
endfunction
// checks on item left/enter events
exported function check_area_events( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
MoveObjectToLocation( npc1, item.x - 22, npc1.y, npc1.z );
sleepms( 10 );
clear_event_queue();
var success := false;
for i := 0 to 5
npc1.process.sendevent( struct{ type := "move_to", source := item } );
if ( !( evs := collect_events( 1, "" ) ) )
continue;
endif
var enter_ev := evs.filter( @( ev ) {
return ev.type == SYSEVENT_ENTEREDAREA;
} )[1];
if ( !enter_ev )
continue;
endif
if ( enter_ev.source != npc1 )
return ret_error( $"wrong enterarea event: {enter_ev}" );
endif
if ( Distance( item, npc1 ) != 20 )
return ret_error( $"wrong enter distance: {Distance( item, npc1 )}" );
endif
success := true;
break;
endfor
if ( !success )
return ret_error( "no entered area event" );
endif
// npc1 attacks to send both oppmoved and leffarea
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
if ( !( evs := collect_events( 1, "timeout waiting for engaged" ) ) )
return evs;
endif
var engaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1];
if ( !engaged_ev )
return ret_error( $"no engaged event: {evs}" );
endif
if ( engaged_ev.source != npc1 )
return ret_error( $"wrong engaged event: {engaged_ev}" );
endif
success := false;
for i := 0 to 5
npc1.process.sendevent( struct{ type := "move_away", source := item } );
if ( !( evs := collect_events( 1, "timeout while waiting for left area" ) ) )
continue;
endif
var enter_ev := evs.filter( @( ev ) {
return ev.type == SYSEVENT_LEFTAREA;
} )[1];
if ( !enter_ev )
continue;
endif
if ( enter_ev.source != npc1 )
return ret_error( $"wrong leftarea event: {enter_ev}" );
endif
if ( Distance( item, npc1 ) != 21 )
return ret_error( $"wrong left distance: {Distance( item, npc1 )}" );
endif
success := true;
break;
endfor
if ( !success )
return ret_error( "no left area event" );
endif
return 1;
endfunction
// picking an item up ends the fight for everyone attacking it
exported function attack_check_pickup( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item, "MovableAttackableItem" );
if ( !res )
return res;
endif
npc1.process.sendevent( struct{ type := "enable_events" } );
// npc1 attacks item
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
// Collect attack and engaged event
if ( !( evs := collect_events( 2 ) ) )
return evs;
endif
if ( !evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1] )
return ret_error( $"no engaged event: {evs}" );
endif
if ( ListHostiles( item ).size() != 1 )
return ret_error( "npc1 did not engage the item" );
endif
if ( npc1.opponent != item )
return ret_error( $"wrong opponent selected: {npc1.opponent}" );
endif
var backpack := resmgn.CreateItemAtLocation( 51, 100, 0, 0xE75 );
if ( !backpack )
return ret_error( "Could not create container: " + backpack );
endif
if ( !( res := MoveItemToContainer( item, backpack ) ) )
return ret_error( "Could not pick the item up: " + res );
endif
if ( !( evs := collect_events( 1, "no event after pickup" ) ) )
return evs;
endif
var disengaged_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_DISENGAGED; } )[1];
if ( !disengaged_ev )
return ret_error( $"no disengaged event: {evs}" );
endif
if ( disengaged_ev.source != npc1 )
return ret_error( $"wrong disengaged event: {disengaged_ev}" );
endif
if ( npc1.opponent )
return ret_error( $"npc1 still attacks the picked up item: {npc1.opponent}" );
endif
// dropping it again must not resurrect the old opponents
if ( !( res := MoveObjectToLocation( item, 51, 100, 0 ) ) )
return ret_error( "Could not drop the item: " + res );
endif
var hostile := ListHostiles( item );
if ( hostile.size() )
return ret_error( $"Hostile list != 0: {hostile}" );
endif
return 1;
endfunction
// a picked up item is no combat target at all
exported function attack_check_contained_inert( resmgn )
var npc1;
var item;
var res := reset( resmgn, npc1, item, "MovableAttackableItem" );
if ( !res )
return res;
endif
var backpack := resmgn.CreateItemAtLocation( 51, 100, 0, 0xE75 );
if ( !backpack )
return ret_error( "Could not create container: " + backpack );
endif
if ( !( res := MoveItemToContainer( item, backpack ) ) )
return ret_error( "Could not pick the item up: " + res );
endif
var hp := item.hp;
var dmg := ApplyDamage( item, 10, 0, 0 );
if ( dmg )
return ret_error( $"contained item took damage: {dmg}" );
endif
if ( item.hp != hp )
return ret_error( $"contained item was damaged: {item.hp}/{hp}" );
endif
if ( ( res := ListHostiles( item ) ) )
return ret_error( $"contained item is a combat target: {res}" );
endif
// and it becomes a valid target again once it is back in the world
if ( !( res := MoveObjectToLocation( item, 51, 100, 0 ) ) )
return ret_error( "Could not drop the item: " + res );
endif
if ( !( dmg := ApplyDamage( item, 10, 0, 0 ) ) )
return ret_error( $"item in the world took no damage: {dmg}" );
endif
if ( item.hp != hp - 10 )
return ret_error( $"item not damaged: {item.hp}/{hp}" );
endif
return 1;
endfunction
// EVMASK_ONLY_PC suppresses the area events of an npc, but the opponent move
// event has to be reported regardless of the mask
exported function attack_check_area_mask( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
var events := SYSEVENT_ENGAGED | SYSEVENT_DISENGAGED | SYSEVENT_OPPONENT_MOVED
| SYSEVENT_ENTEREDAREA | SYSEVENT_LEFTAREA;
if ( !( res := configure_control( item, events, 5, EVMASK_ONLY_PC ) ) )
return res;
endif
// out of the area of the item, and out of the weapon range so that no swings
// interfere with the collected events
MoveObjectToLocation( npc1, item.x + 8, item.y, item.z );
clear_event_queue();
// npc1 attacks the item
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
if ( !( evs := collect_events( 1, "no event after engaged" ) ) )
return evs;
endif
if ( !evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1] )
return ret_error( $"no engaged event: {evs}" );
endif
// entering the area of the item is suppressed, the opponent move is not
MoveObjectToLocation( npc1, item.x + 3, item.y, item.z );
if ( !( evs := collect_events( 1, "no event after entering the area" ) ) )
return evs;
endif
if ( evs.filter( @( ev ) { return ev.type == SYSEVENT_ENTEREDAREA; } )[1] )
return ret_error( $"entered area event despite EVMASK_ONLY_PC: {evs}" );
endif
var moved_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_OPPONENT_MOVED; } )[1];
if ( !moved_ev )
return ret_error( $"no opponent moved event: {evs}" );
endif
if ( moved_ev.source != npc1 )
return ret_error( $"wrong opponent moved event: {moved_ev}" );
endif
// without the mask the area event is sent instead of the opponent move
if ( !( res := configure_control( item, events, 5, EVMASK_ALL ) ) )
return res;
endif
clear_event_queue();
MoveObjectToLocation( npc1, item.x + 8, item.y, item.z );
if ( !( evs := collect_events( 1, "no event after leaving the area" ) ) )
return evs;
endif
var left_ev := evs.filter( @( ev ) { return ev.type == SYSEVENT_LEFTAREA; } )[1];
if ( !left_ev )
return ret_error( $"no left area event: {evs}" );
endif
if ( left_ev.source != npc1 )
return ret_error( $"wrong left area event: {left_ev}" );
endif
if ( ( evs := collect_events( 1, "", 1 ) ) )
return ret_error( $"the area event did not replace the opponent move: {evs}" );
endif
return 1;
endfunction
// an item can be attacked by more than one mobile, and destroying it has to
// release all of them
exported function attack_check_two_attackers( resmgn )
var npc1;
var npc2;
var item;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
npc2 := resmgn.CreateNPCFromTemplate( ":testattack:test_attack", 52, 100, 0 );
if ( !npc2 )
return ret_error( "Could not create NPC: " + npc2 );
endif
npc2.hitchance_mod := 1000;
SetObjProperty( npc2, "#TestPid", GetPid() );
SetName( npc2, "npc2" );
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
npc2.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
// both fights are running, so the event queue keeps filling up with hits:
// poll the hostile list instead of waiting for events
if ( !( res := wait_for_hostiles( item, 2 ) ) )
return res;
endif
if ( npc1.opponent != item || npc2.opponent != item )
return ret_error( $"wrong opponents: {npc1.opponent}, {npc2.opponent}" );
endif
if ( !( res := DestroyItem( item ) ) )
return ret_error( "Could not destroy the item: " + res );
endif
if ( npc1.opponent )
return ret_error( $"npc1 still attacks the destroyed item: {npc1.opponent}" );
endif
if ( npc2.opponent )
return ret_error( $"npc2 still attacks the destroyed item: {npc2.opponent}" );
endif
return 1;
endfunction
// selecting the same item twice must not repeat the engaged event
exported function attack_check_reengage_same( resmgn )
var npc1;
var item;
var evs;
var res := reset( resmgn, npc1, item );
if ( !res )
return res;
endif
// out of the weapon range, so that no swings interfere
MoveObjectToLocation( npc1, item.x + 3, item.y, item.z );
clear_event_queue();
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
if ( !( evs := collect_events( 1, "no event after engaged" ) ) )
return evs;
endif
if ( !evs.filter( @( ev ) { return ev.type == SYSEVENT_ENGAGED; } )[1] )
return ret_error( $"no engaged event: {evs}" );
endif
clear_event_queue();
npc1.process.sendevent( struct{ type := SYSEVENT_ENGAGED, source := item,
sleep := 0, unhide := false } );
if ( ( evs := collect_events( 1, "", 2 ) ) )
return ret_error( $"reselecting the same opponent sent an event: {evs}" );
endif
if ( npc1.opponent != item )
return ret_error( $"wrong opponent selected: {npc1.opponent}" );
endif
if ( ListHostiles( item ).size() != 1 )
return ret_error( $"Hostile list != 1: {ListHostiles( item )}" );
endif
return 1;
endfunction
function reset( resmgn, byref npc1, byref item, itemtype := "AttackableItem" )
npc1 := resmgn.CreateNPCFromTemplate( ":testattack:test_attack", 50, 100, 0 );
if ( !npc1 )
return ret_error( "Could not create NPC: " + npc1 );
endif
npc1.hitchance_mod := 1000;
SetObjProperty( npc1, "#TestPid", GetPid() );
SetName( npc1, "npc1" );
item := resmgn.CreateItemAtLocation( 51, 100, 0, itemtype );
if ( !item )
return ret_error( "Could not create item: " + item );
endif
SetObjProperty( item, "#TestPid", GetPid() );
clear_event_queue();
return 1;
endfunction
// Replaces the event registration of the control script of an item
function configure_control( item, events, range, evmask )
item.process.sendevent( struct{ type := "configure", events := events, range := range,
evmask := evmask } );
var deadline := ReadMillisecondClock() + 5000;
while ( ReadMillisecondClock() < deadline )
var ev := wait_for_event( 1 );
if ( ev && ev.type == "configured" )
return 1;
endif
endwhile
return ret_error( "control script did not apply the configuration" );
endfunction
// Waits until the given number of mobiles attacks the item. Deadline based,
// since a running fight keeps sending events that would have to be skipped.
function wait_for_hostiles( item, count, timeout := 5 )
var deadline := ReadMillisecondClock() + timeout * 1000;
var hostiles;
while ( ReadMillisecondClock() < deadline )
hostiles := ListHostiles( item );
if ( hostiles.size() == count )
return 1;
endif
sleepms( 100 );
endwhile
return ret_error( $"Hostile list != {count}: {hostiles}" );
endfunction
// Collect a number of events
function collect_events( count, errorMessage := "No event from NPC script received.", timeout := 5 )
var evs := array{};
while ( true )
var ev := wait_for_event( timeout );
if ( ev )
evs.append( ev );
if ( evs.size() >= count )
break;
endif
continue;
endif
return ret_error( errorMessage );
endwhile
return evs;
endfunction