diff --git a/docs/NPCConversation.md b/docs/NPCConversation.md new file mode 100644 index 0000000000..9b41f5b5d0 --- /dev/null +++ b/docs/NPCConversation.md @@ -0,0 +1,227 @@ +# NPC Conversation + +`NPC::Conversation` is the central runtime state holder for NPC dialog in OpenKore. + +The old `%talk` and `$ai_v{'npc_talk'}` globals still exist only as compatibility mirrors inside this module. Production code should not read or write them directly anymore. + +## State Model + +The conversation module tracks one normalized conversation snapshot with: + +- whether a conversation is active +- the current prompt state +- packed `npc_id` and numeric `name_id` +- NPC name +- full dialog text and per-line text +- current response list +- image name, if present +- last update time +- sequence id +- local waiting-for-server flag +- last error string + +Primary prompt states: + +- `CLOSED` +- `OPENING` +- `TEXT` +- `NEXT` +- `RESPONSES` +- `NUMBER_INPUT` +- `TEXT_INPUT` +- `WAITING_SERVER` +- `CLOSING` +- `BUY_OR_SELL` +- `STORE` +- `SELL` +- `CASH` +- `ERROR` + +`current_state()` returns `WAITING_SERVER` when the local state is waiting for the next server packet. `prompt_state()` returns the raw prompt state without that overlay. + +## Public API + +Read helpers: + +- `NPC::Conversation::is_open()` +- `NPC::Conversation::is_closed()` +- `NPC::Conversation::current_state()` +- `NPC::Conversation::prompt_state()` +- `NPC::Conversation::current_npc_id()` +- `NPC::Conversation::current_name_id()` +- `NPC::Conversation::current_npc_name()` +- `NPC::Conversation::text()` +- `NPC::Conversation::text_lines()` +- `NPC::Conversation::last_text()` +- `NPC::Conversation::responses()` +- `NPC::Conversation::response_count()` +- `NPC::Conversation::response_at($index)` +- `NPC::Conversation::find_response($text)` +- `NPC::Conversation::find_response_regex($pattern, $flags)` +- `NPC::Conversation::expects_continue()` +- `NPC::Conversation::expects_response()` +- `NPC::Conversation::expects_number()` +- `NPC::Conversation::expects_text()` +- `NPC::Conversation::can_close()` +- `NPC::Conversation::last_update_time()` +- `NPC::Conversation::sequence_id()` +- `NPC::Conversation::snapshot()` +- `NPC::Conversation::debug_string()` + +Packet-side transitions: + +- `NPC::Conversation::on_text_packet(...)` +- `NPC::Conversation::on_continue_packet(...)` +- `NPC::Conversation::on_responses_packet(...)` +- `NPC::Conversation::on_number_input_packet(...)` +- `NPC::Conversation::on_text_input_packet(...)` +- `NPC::Conversation::on_close_packet(...)` +- `NPC::Conversation::on_clear_packet(...)` +- `NPC::Conversation::on_shop_begin(...)` +- `NPC::Conversation::on_store_list(...)` +- `NPC::Conversation::on_sell_list(...)` +- `NPC::Conversation::on_cash_dealer(...)` +- `NPC::Conversation::on_image_packet(...)` +- `NPC::Conversation::clear_image()` +- `NPC::Conversation::on_error($message)` + +Send-side helpers: + +- `NPC::Conversation::start($npc_id, %info)` +- `NPC::Conversation::continue()` +- `NPC::Conversation::select_response($index)` +- `NPC::Conversation::select_response_text($text)` +- `NPC::Conversation::select_response_regex($pattern, $flags)` +- `NPC::Conversation::send_number($number)` +- `NPC::Conversation::send_text($text)` +- `NPC::Conversation::choose_buy_or_sell('buy'|'sell')` +- `NPC::Conversation::close()` +- `NPC::Conversation::cancel()` +- `NPC::Conversation::reset(%opts)` + +## Packet Handler Usage + +Receive handlers should stay thin. Parse packet payloads, then hand the state transition to `NPC::Conversation`. + +Example: + +```perl +NPC::Conversation::on_responses_packet( + npc_id => $ID, + name_id => $nameID, + npc_name => getNPCName($ID), + responses => \@responses, +); +``` + +Do not append to `%talk`, set `$ai_v{'npc_talk'}`, or interpret prompt state outside the module. + +## Command Usage + +Console commands and task logic should interact through the module: + +```perl +NPC::Conversation::continue(); +NPC::Conversation::select_response($index); +NPC::Conversation::send_number($number); +NPC::Conversation::send_text($text); +NPC::Conversation::cancel(); +NPC::Conversation::close(); +``` + +New direct commands: + +- `npcTalkContinue` +- `npcTalkSelect ` +- `npcTalkSelectRegex ` +- `npcTalkNumber ` +- `npcTalkText ` +- `npcTalkClose` +- `npcTalkCancel` +- `npcTalkReset` +- `npcTalkDebug` + +## eventMacro Usage + +State conditions now available: + +- `npcTalkActive` +- `npcTalkState` +- `npcTalkText` +- `npcTalkTextRegex` +- `npcTalkHasResponses` +- `npcTalkResponseCount` +- `npcTalkResponse` +- `npcTalkResponseRegex` +- `npcTalkExpectsContinue` +- `npcTalkExpectsResponse` +- `npcTalkExpectsNumber` +- `npcTalkExpectsText` +- `npcTalkNpcName` +- `npcTalkNpcId` + +Example: + +```text +automacro npc_can_continue { + npcTalkExpectsContinue 1 + call { + npcTalkContinue + } +} +``` + +```text +automacro npc_choose_city { + npcTalkActive 1 + npcTalkTextRegex /Choose your destination/ + npcTalkResponse "Prontera" + call { + npcTalkSelect Prontera + } +} +``` + +Existing `NpcMsg*` conditions remain available and still work from the legacy `npc_talk` hook flow. + +## Hooks + +New hooks emitted by the module: + +- `npc_talk_opened` +- `npc_talk_text` +- `npc_talk_responses` +- `npc_talk_number_input` +- `npc_talk_text_input` +- `npc_talk_state_changed` +- `npc_talk_closed` +- `npc_talk_error` + +The legacy `npc_talk` and `npc_talk_done` hooks are still emitted by receive handling for compatibility. + +## Debugging + +Use `npcTalkDebug` to print a sanitized snapshot of the current conversation state. + +Typical output includes: + +- active flag +- prompt state and current state +- NPC id and name +- text lines +- response list +- waiting state +- sequence id +- last error + +## Migration Notes + +For plugin authors: + +- stop reading `%talk` directly +- stop reading `$ai_v{'npc_talk'}` directly +- call `NPC::Conversation` for state reads +- call `NPC::Conversation` for response sends +- prefer new `npc_talk_state_changed` style hooks if you need stateful behavior + +The compatibility mirror is temporary and should be treated as deprecated. diff --git a/docs/rathena-npc-conversation-test.txt b/docs/rathena-npc-conversation-test.txt new file mode 100644 index 0000000000..df1a714798 --- /dev/null +++ b/docs/rathena-npc-conversation-test.txt @@ -0,0 +1,96 @@ +// rAthena NPC conversation coverage script for OpenKore NPC::Conversation testing. +// Add to a test NPC map and talk to the NPCs below from OpenKore. + +prontera,150,180,4 script OK_NPC_MES_CLOSE 4_F_KAFRA1,{ + mes "[MES_CLOSE]"; + mes "Simple message then close."; + close; +} + +prontera,152,180,4 script OK_NPC_NEXT 4_F_KAFRA1,{ + mes "[NEXT]"; + mes "First page."; + next; + mes "Second page."; + close; +} + +prontera,154,180,4 script OK_NPC_SELECT 4_F_KAFRA1,{ + mes "[SELECT]"; + switch(select("Prontera","Payon","Alberta")) { + case 1: + mes "Prontera selected."; + break; + case 2: + mes "Payon selected."; + break; + case 3: + mes "Alberta selected."; + break; + } + close; +} + +prontera,156,180,4 script OK_NPC_MENU_REPEAT 4_F_KAFRA1,{ + mes "[MENU_REPEAT]"; + switch(select("Same Label","Same Label","Different Label")) { + case 1: + mes "First repeated label."; + break; + case 2: + mes "Second repeated label."; + break; + case 3: + mes "Different label."; + break; + } + close; +} + +prontera,158,180,4 script OK_NPC_NUMBER 4_F_KAFRA1,{ + mes "[NUMBER]"; + input .@value; + mes "You entered "+.@value+"."; + close; +} + +prontera,160,180,4 script OK_NPC_TEXT 4_F_KAFRA1,{ + mes "[TEXT]"; + input .@name$; + mes "Hello "+.@name$+"."; + close; +} + +prontera,162,180,4 script OK_NPC_COLOR 4_F_KAFRA1,{ + mes "[COLOR]"; + mes "^FF0000Red text^000000 and punctuation: ! ? , ."; + next; + mes "Second line after colored text."; + close; +} + +prontera,164,180,4 script OK_NPC_UNEXPECTED_CLOSE 4_F_KAFRA1,{ + mes "[UNEXPECTED_CLOSE]"; + mes "This closes abruptly."; + close2; + dispbottom "Server-side close2 reached."; + end; +} + +prontera,166,180,4 script OK_NPC_SHOP 4_F_KAFRA1,{ + callshop "OK_NPC_TESTSHOP",1; + end; +} + +- shop OK_NPC_TESTSHOP -1,501:2,502:5,503:10 + +// Suggested manual checks: +// 1. mes + close +// 2. next flow +// 3. menu/select with unique labels +// 4. menu/select with repeated labels +// 5. numeric input +// 6. text input +// 7. color codes and punctuation +// 8. close2 / unexpected close +// 9. shop open path diff --git a/plugins/eventMacro/eventMacro/Condition/Base/NpcTalkState.pm b/plugins/eventMacro/eventMacro/Condition/Base/NpcTalkState.pm new file mode 100644 index 0000000000..44a8550092 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/Base/NpcTalkState.pm @@ -0,0 +1,21 @@ +package eventMacro::Condition::Base::NpcTalkState; + +use strict; + +sub _hooks { + return ['in_game', 'npc_talk_state_changed']; +} + +sub parse_wanted_state { + my ($self, $condition_code) = @_; + + if (defined $condition_code && $condition_code =~ /^(0|1)$/) { + $self->{wanted_state} = $1; + return 1; + } + + $self->{error} = "Value '$condition_code' Should be '0' or '1'"; + return 0; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkActive.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkActive.pm new file mode 100644 index 0000000000..39fed30404 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkActive.pm @@ -0,0 +1,24 @@ +package eventMacro::Condition::npcTalkActive; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + eventMacro::Condition::Base::NpcTalkState::parse_wanted_state($self, $condition_code); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + return $self->SUPER::validate_condition((NPC::Conversation::is_open() ? 1 : 0) == $self->{wanted_state} ? 1 : 0); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsContinue.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsContinue.pm new file mode 100644 index 0000000000..f01e8e7655 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsContinue.pm @@ -0,0 +1,24 @@ +package eventMacro::Condition::npcTalkExpectsContinue; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + eventMacro::Condition::Base::NpcTalkState::parse_wanted_state($self, $condition_code); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + return $self->SUPER::validate_condition((NPC::Conversation::expects_continue() ? 1 : 0) == $self->{wanted_state} ? 1 : 0); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsNumber.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsNumber.pm new file mode 100644 index 0000000000..8e4edcf66a --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsNumber.pm @@ -0,0 +1,24 @@ +package eventMacro::Condition::npcTalkExpectsNumber; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + eventMacro::Condition::Base::NpcTalkState::parse_wanted_state($self, $condition_code); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + return $self->SUPER::validate_condition((NPC::Conversation::expects_number() ? 1 : 0) == $self->{wanted_state} ? 1 : 0); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsResponse.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsResponse.pm new file mode 100644 index 0000000000..5995e323f4 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsResponse.pm @@ -0,0 +1,24 @@ +package eventMacro::Condition::npcTalkExpectsResponse; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + eventMacro::Condition::Base::NpcTalkState::parse_wanted_state($self, $condition_code); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + return $self->SUPER::validate_condition((NPC::Conversation::expects_response() ? 1 : 0) == $self->{wanted_state} ? 1 : 0); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsText.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsText.pm new file mode 100644 index 0000000000..518a156f2d --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkExpectsText.pm @@ -0,0 +1,24 @@ +package eventMacro::Condition::npcTalkExpectsText; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + eventMacro::Condition::Base::NpcTalkState::parse_wanted_state($self, $condition_code); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + return $self->SUPER::validate_condition((NPC::Conversation::expects_text() ? 1 : 0) == $self->{wanted_state} ? 1 : 0); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkHasResponses.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkHasResponses.pm new file mode 100644 index 0000000000..76a396b652 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkHasResponses.pm @@ -0,0 +1,24 @@ +package eventMacro::Condition::npcTalkHasResponses; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + eventMacro::Condition::Base::NpcTalkState::parse_wanted_state($self, $condition_code); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + return $self->SUPER::validate_condition((NPC::Conversation::has_responses() ? 1 : 0) == $self->{wanted_state} ? 1 : 0); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkNpcId.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkNpcId.pm new file mode 100644 index 0000000000..060d336850 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkNpcId.pm @@ -0,0 +1,38 @@ +package eventMacro::Condition::npcTalkNpcId; + +use strict; + +use base 'eventMacro::Conditiontypes::NumericConditionState'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _get_val { + my $npc_id = NPC::Conversation::current_npc_id(); + return defined $npc_id ? unpack('V', $npc_id) : 0; +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + if ($callback_type eq 'variable') { + $self->update_validator_var($callback_name, $args); + } + + $self->{last_npc_id} = _get_val(); + return $self->SUPER::validate_condition($self->validator_check); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_npc_id}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkNpcName.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkNpcName.pm new file mode 100644 index 0000000000..574e60b2b0 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkNpcName.pm @@ -0,0 +1,38 @@ +package eventMacro::Condition::npcTalkNpcName; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + $self->{wanted_name} = $condition_code; +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + $self->{last_name} = NPC::Conversation::current_npc_name(); + return $self->SUPER::validate_condition( + defined $self->{wanted_name} + && defined $self->{last_name} + && $self->{last_name} eq $self->{wanted_name} ? 1 : 0 + ); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_name}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkResponse.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkResponse.pm new file mode 100644 index 0000000000..a3c475375e --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkResponse.pm @@ -0,0 +1,41 @@ +package eventMacro::Condition::npcTalkResponse; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + $self->{wanted_response} = $condition_code; +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + $self->{last_response} = undef; + foreach my $response (@{NPC::Conversation::responses()}) { + if (defined $self->{wanted_response} && $response eq $self->{wanted_response}) { + $self->{last_response} = $response; + return $self->SUPER::validate_condition(1); + } + } + + return $self->SUPER::validate_condition(0); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_response}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkResponseCount.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkResponseCount.pm new file mode 100644 index 0000000000..db4060a2f4 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkResponseCount.pm @@ -0,0 +1,37 @@ +package eventMacro::Condition::npcTalkResponseCount; + +use strict; + +use base 'eventMacro::Conditiontypes::NumericConditionState'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _get_val { + NPC::Conversation::response_count(); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + if ($callback_type eq 'variable') { + $self->update_validator_var($callback_name, $args); + } + + $self->{last_count} = NPC::Conversation::response_count(); + return $self->SUPER::validate_condition($self->validator_check); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_count}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkResponseRegex.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkResponseRegex.pm new file mode 100644 index 0000000000..026ac1f94c --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkResponseRegex.pm @@ -0,0 +1,39 @@ +package eventMacro::Condition::npcTalkResponseRegex; + +use strict; + +use base 'eventMacro::Conditiontypes::RegexConditionState'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + if ($callback_type eq 'variable') { + $self->update_validator_var($callback_name, $args); + } + + $self->{last_response} = undef; + foreach my $response (@{NPC::Conversation::responses()}) { + next unless $self->validator_check($response); + $self->{last_response} = $response; + return $self->SUPER::validate_condition(1); + } + + return $self->SUPER::validate_condition(0); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_response}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkState.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkState.pm new file mode 100644 index 0000000000..961c7e5257 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkState.pm @@ -0,0 +1,33 @@ +package eventMacro::Condition::npcTalkState; + +use strict; + +use base 'eventMacro::Conditiontypes::ListConditionState'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + if ($callback_type eq 'variable') { + $self->update_validator_var($callback_name, $args); + } + + $self->{last_state} = NPC::Conversation::current_state(); + return $self->SUPER::validate_condition($self->validator_check($self->{last_state})); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_state}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkText.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkText.pm new file mode 100644 index 0000000000..8d731a97dd --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkText.pm @@ -0,0 +1,36 @@ +package eventMacro::Condition::npcTalkText; + +use strict; + +use base 'eventMacro::Condition'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub _parse_syntax { + my ($self, $condition_code) = @_; + $self->{wanted_text} = $condition_code; +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + $self->{last_text} = NPC::Conversation::text(); + return $self->SUPER::validate_condition( + defined $self->{wanted_text} && $self->{last_text} eq $self->{wanted_text} ? 1 : 0 + ); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_text}; + return $new_variables; +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/npcTalkTextRegex.pm b/plugins/eventMacro/eventMacro/Condition/npcTalkTextRegex.pm new file mode 100644 index 0000000000..33c7cf6fc9 --- /dev/null +++ b/plugins/eventMacro/eventMacro/Condition/npcTalkTextRegex.pm @@ -0,0 +1,33 @@ +package eventMacro::Condition::npcTalkTextRegex; + +use strict; + +use base 'eventMacro::Conditiontypes::RegexConditionState'; + +use NPC::Conversation; +use eventMacro::Condition::Base::NpcTalkState; + +sub _hooks { + eventMacro::Condition::Base::NpcTalkState::_hooks(); +} + +sub validate_condition { + my ($self, $callback_type, $callback_name, $args) = @_; + + if ($callback_type eq 'variable') { + $self->update_validator_var($callback_name, $args); + } + + $self->{last_text} = NPC::Conversation::text(); + return $self->SUPER::validate_condition($self->validator_check($self->{last_text})); +} + +sub get_new_variable_list { + my ($self) = @_; + my $new_variables; + + $new_variables->{'.'.$self->{name}.'Last'} = $self->{last_text}; + return $new_variables; +} + +1; diff --git a/src/AI/CoreLogic.pm b/src/AI/CoreLogic.pm index 72daa10893..3d922cee68 100644 --- a/src/AI/CoreLogic.pm +++ b/src/AI/CoreLogic.pm @@ -28,6 +28,7 @@ use Globals; use Log qw(message warning error debug); use Misc; use Network::Send (); +use NPC::Conversation; use Settings; use AI; use AI::SlaveManager; @@ -1814,7 +1815,7 @@ sub processAutoSell { return; - } elsif (!defined $ai_v{'npc_talk'} || $ai_v{'npc_talk'}{'talk'} ne 'sell') { + } elsif (NPC::Conversation::prompt_state() ne 'SELL') { if (timeOut($args->{'sentNpcTalk_time'}, $timeout{ai_sellAuto_wait_giveup_npc}{timeout})) { $args->{'error'} = 'Npc did not respond'; $args->{'done'} = 1; @@ -2050,7 +2051,7 @@ sub processAutoBuy { return; - } elsif (!defined $ai_v{'npc_talk'} || $ai_v{'npc_talk'}{'talk'} ne 'store') { + } elsif (NPC::Conversation::prompt_state() ne 'STORE') { if (timeOut($args->{'sentNpcTalk_time'}, $timeout{ai_buyAuto_wait_giveup_npc}{timeout})) { $args->{'error'} = 'Npc did not respond'; $args->{'done'} = 1; diff --git a/src/Actor/NPC.pm b/src/Actor/NPC.pm index 69ce3127fa..607da0e8e6 100644 --- a/src/Actor/NPC.pm +++ b/src/Actor/NPC.pm @@ -25,7 +25,7 @@ use strict; use base qw(Actor); -use Globals qw($messageSender); +use NPC::Conversation; use Translation qw(T); sub new { @@ -36,7 +36,7 @@ sub new { sub sendTalk { my ($self) = @_; - $messageSender->sendTalk($self->{ID}); + NPC::Conversation::start($self->{ID}, name_id => $self->{nameID}, npc_name => $self->{name}); } 1; diff --git a/src/Commands.pm b/src/Commands.pm index 2cf4ef3a8f..0508f2e088 100644 --- a/src/Commands.pm +++ b/src/Commands.pm @@ -32,6 +32,7 @@ use Field; use Misc; use Network; use Network::Send (); +use NPC::Conversation; use Settings; use Plugins; use Skill; @@ -682,6 +683,24 @@ sub initHandlers { T("Send a sequence of responses to an NPC."), [T(" "), T("talk to the NPC standing at and use ")] ], \&cmdTalkNPC], + ['npcTalkContinue', T("Continue the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalkcontinue', T("Continue the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkSelect', T("Select an option in the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalkselect', T("Select an option in the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkSelectRegex', T("Select an option by regex in the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalkselectregex', T("Select an option by regex in the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkNumber', T("Send a number to the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalknumber', T("Send a number to the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkText', T("Send text to the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalktext', T("Send text to the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkClose', T("Close the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalkclose', T("Close the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkCancel', T("Cancel the current NPC conversation."), \&cmdNpcTalkConversation], + ['npctalkcancel', T("Cancel the current NPC conversation."), \&cmdNpcTalkConversation], + ['npcTalkReset', T("Reset local NPC conversation state."), \&cmdNpcTalkConversation], + ['npctalkreset', T("Reset local NPC conversation state."), \&cmdNpcTalkConversation], + ['npcTalkDebug', T("Show the current NPC conversation snapshot."), \&cmdNpcTalkConversation], + ['npctalkdebug', T("Show the current NPC conversation snapshot."), \&cmdNpcTalkConversation], ['tank', [ T("Tank for a player."), [T(""), T("starts tank mode with player as tankModeTarget")], @@ -5200,8 +5219,8 @@ sub cmdSell { } my @args = parseArgs($_[1]); - if ($args[0] eq "" && defined $ai_v{'npc_talk'} && exists $ai_v{'npc_talk'}{'talk'} && $ai_v{'npc_talk'}{'talk'} eq 'buy_or_sell') { - $messageSender->sendNPCBuySellList($talk{ID}, 1); + if ($args[0] eq "" && NPC::Conversation::prompt_state() eq 'BUY_OR_SELL') { + NPC::Conversation::choose_buy_or_sell('sell'); } elsif ($args[0] eq "list") { if (@sellList == 0) { @@ -5784,7 +5803,7 @@ sub cmdStore { my ($arg1) = $args =~ /^(\w+)/; my ($arg2) = $args =~ /^\w+ (\d+)/; - if ($arg1 eq "" && $ai_v{'npc_talk'}{'talk'} ne 'buy_or_sell') { + if ($arg1 eq "" && NPC::Conversation::prompt_state() ne 'BUY_OR_SELL') { my $msg = center(TF(" Store List (%s) ", $storeList->{npcName}), 68, '-') ."\n". T("# Name Type Price Amount\n"); foreach my $item (@$storeList) { @@ -5796,9 +5815,9 @@ sub cmdStore { $msg .= ('-'x68) . "\n"; message $msg, "list"; - } elsif ($arg1 eq "" && defined $ai_v{'npc_talk'} && exists $ai_v{'npc_talk'}{'talk'} && $ai_v{'npc_talk'}{'talk'} eq 'buy_or_sell' + } elsif ($arg1 eq "" && NPC::Conversation::prompt_state() eq 'BUY_OR_SELL' && ($net && $net->getState() == Network::IN_GAME)) { - $messageSender->sendNPCBuySellList($talk{'ID'}, 0); + NPC::Conversation::choose_buy_or_sell('buy'); } elsif ($arg1 eq "desc" && $arg2 =~ /\d+/ && !$storeList->get($arg2)) { error TF("Error in function 'store desc' (Store Item Description)\n" . @@ -5862,18 +5881,19 @@ sub cmdTalk { my (undef, $args) = @_; if ($args =~ /^resp$/) { - if (!$talk{'responses'}) { + my $responses = NPC::Conversation::legacy_responses(); + if (!@{$responses}) { error T("Error in function 'talk resp' (Respond to NPC)\n" . "No NPC response list available.\n"); return; } else { - my $msg = center(T(" Responses (").getNPCName($talk{ID}).") ", 40, '-') ."\n" . + my $msg = center(T(" Responses (").getNPCName(NPC::Conversation::current_npc_id()).") ", 40, '-') ."\n" . TF("# Response\n"); - for (my $i = 0; $i < @{$talk{'responses'}}; $i++) { + for (my $i = 0; $i < @{$responses}; $i++) { $msg .= swrite( "@< @*", - [$i, $talk{responses}[$i]]); + [$i, $responses->[$i]]); } $msg .= ('-'x40) . "\n"; message $msg, "list"; @@ -8025,13 +8045,91 @@ sub cmdCancelTransaction { return; } - if (defined $ai_v{'npc_talk'} && exists $ai_v{'npc_talk'}{'talk'} && ($ai_v{'npc_talk'}{'talk'} eq 'buy_or_sell' || $ai_v{'npc_talk'}{'talk'} eq 'store')) { + if (NPC::Conversation::prompt_state() eq 'BUY_OR_SELL' || NPC::Conversation::prompt_state() eq 'STORE') { cancelNpcBuySell(); } else { error T("You are not on a sell or store npc interaction.\n"); } } +sub cmdNpcTalkConversation { + my ($switch, $args) = @_; + + if (!$net || $net->getState() != Network::IN_GAME) { + error TF("You must be logged in the game to use this command '%s'\n", $switch); + return; + } + + if ($switch =~ /^npcTalkContinue$/i) { + NPC::Conversation::continue(); + return; + } + + if ($switch =~ /^npcTalkSelect$/i) { + if (!defined $args || $args eq '') { + error T("Syntax Error in function 'npcTalkSelect'\nUsage: npcTalkSelect \n"); + return; + } + if ($args =~ /^\d+$/) { + NPC::Conversation::select_response($args); + } else { + NPC::Conversation::select_response_text($args); + } + return; + } + + if ($switch =~ /^npcTalkSelectRegex$/i) { + if (!defined $args || $args eq '') { + error T("Syntax Error in function 'npcTalkSelectRegex'\nUsage: npcTalkSelectRegex \n"); + return; + } + if ($args =~ m{^/(.*)/([imsx]*)$}) { + NPC::Conversation::select_response_regex($1, $2); + } else { + NPC::Conversation::select_response_regex($args); + } + return; + } + + if ($switch =~ /^npcTalkNumber$/i) { + if (!defined $args || $args eq '') { + error T("Syntax Error in function 'npcTalkNumber'\nUsage: npcTalkNumber \n"); + return; + } + NPC::Conversation::send_number($args); + return; + } + + if ($switch =~ /^npcTalkText$/i) { + if (!defined $args) { + error T("Syntax Error in function 'npcTalkText'\nUsage: npcTalkText \n"); + return; + } + NPC::Conversation::send_text($args); + return; + } + + if ($switch =~ /^npcTalkClose$/i) { + NPC::Conversation::close(); + return; + } + + if ($switch =~ /^npcTalkCancel$/i) { + NPC::Conversation::cancel(); + return; + } + + if ($switch =~ /^npcTalkReset$/i) { + NPC::Conversation::reset(reason => 'manual_reset'); + return; + } + + if ($switch =~ /^npcTalkDebug$/i) { + message NPC::Conversation::debug_string(), 'list'; + return; + } +} + ## # 'cm' for Change Material (Genetic) # 'analysis' for Four Spirit Analysis (Sorcerer) [Untested yet] diff --git a/src/Misc.pm b/src/Misc.pm index 445dbf0ec4..25deca715b 100644 --- a/src/Misc.pm +++ b/src/Misc.pm @@ -41,6 +41,7 @@ use Skill; use Field; use Network; use Network::Send (); +use NPC::Conversation; use AI; use Actor; use Actor::You; @@ -6112,8 +6113,7 @@ sub setCharDeleteDate { } sub cancelNpcBuySell { - undef %talk; - delete $ai_v{'npc_talk'} if (exists $ai_v{'npc_talk'}); + NPC::Conversation::reset(reason => 'cancel_buy_sell'); if ($in_market) { $messageSender->sendMarketClose; @@ -6131,8 +6131,7 @@ sub completeNpcSell { $messageSender->sendSellBulk($items); } - undef %talk; - delete $ai_v{'npc_talk'} if (exists $ai_v{'npc_talk'}); + NPC::Conversation::reset(reason => 'complete_sell'); if ($messageSender->{send_sell_buy_complete}) { $messageSender->sendSellBuyComplete; @@ -6151,8 +6150,7 @@ sub completeNpcBuy { } } - undef %talk; - delete $ai_v{'npc_talk'} if (exists $ai_v{'npc_talk'}); + NPC::Conversation::reset(reason => 'complete_buy'); if ($in_market) { $messageSender->sendMarketClose; diff --git a/src/NPC/Conversation.pm b/src/NPC/Conversation.pm new file mode 100644 index 0000000000..7532d1726f --- /dev/null +++ b/src/NPC/Conversation.pm @@ -0,0 +1,706 @@ +package NPC::Conversation; + +use strict; +use warnings; + +use Time::HiRes qw(time); + +use Globals qw(%ai_v %talk $messageSender); +use Log qw(debug warning error); +use Plugins; +use Translation qw(T TF); + +use constant { + STATE_CLOSED => 'CLOSED', + STATE_OPENING => 'OPENING', + STATE_TEXT => 'TEXT', + STATE_NEXT => 'NEXT', + STATE_RESPONSES => 'RESPONSES', + STATE_NUMBER_INPUT => 'NUMBER_INPUT', + STATE_TEXT_INPUT => 'TEXT_INPUT', + STATE_CLOSING => 'CLOSING', + STATE_BUY_OR_SELL => 'BUY_OR_SELL', + STATE_STORE => 'STORE', + STATE_SELL => 'SELL', + STATE_CASH => 'CASH', + STATE_ERROR => 'ERROR', +}; + +my %state = _default_state(); + +sub _default_state { + return ( + active => 0, + state => STATE_CLOSED, + npc_id => undef, + name_id => undef, + npc_name => undef, + text_lines => [], + last_text => undef, + responses => [], + image => undef, + last_packet => undef, + last_update => 0, + opened_at => undef, + sequence_id => 0, + waiting_server => 0, + error => undef, + legacy_talk => undef, + scheduled_time => undef, + can_close => 0, + ); +} + +sub _clone_array { + my ($items) = @_; + return [] if !defined $items; + return [@{$items}]; +} + +sub _emit_hook { + my ($hook_name, $extra) = @_; + my %payload = ( + snapshot => snapshot(), + state => current_state(), + prompt_state => prompt_state(), + npc_id => current_npc_id(), + name_id => current_name_id(), + npc_name => current_npc_name(), + text => text(), + text_lines => text_lines(), + last_text => last_text(), + responses => responses(), + legacy_responses => legacy_responses(), + waiting_server => is_waiting_server(), + sequence_id => sequence_id(), + ); + if ($extra && ref $extra eq 'HASH') { + @payload{keys %{$extra}} = values %{$extra}; + } + Plugins::callHook($hook_name, \%payload); +} + +sub _emit_state_changed { + my ($previous_state, $reason) = @_; + return if !defined $previous_state && !defined $reason; + _emit_hook('npc_talk_state_changed', { + previous_state => $previous_state, + reason => $reason, + }); +} + +sub _sync_legacy_globals { + undef %talk; + delete $ai_v{'npc_talk'}; + + return unless $state{active}; + + $talk{ID} = $state{npc_id} if defined $state{npc_id}; + $talk{nameID} = $state{name_id} if defined $state{name_id}; + + my $message = join("\n", @{$state{text_lines}}); + $talk{msg} = $message if length $message; + $talk{image} = $state{image} if defined $state{image}; + + if (@{$state{responses}}) { + $talk{responses} = legacy_responses(); + } + + $ai_v{'npc_talk'} = {}; + $ai_v{'npc_talk'}{'ID'} = $state{npc_id} if defined $state{npc_id}; + $ai_v{'npc_talk'}{'talk'} = $state{legacy_talk} if defined $state{legacy_talk}; + $ai_v{'npc_talk'}{'time'} = $state{scheduled_time} if defined $state{scheduled_time}; +} + +sub _legacy_state_for { + my ($prompt_state) = @_; + return undef if !defined $prompt_state || $prompt_state eq STATE_CLOSED; + return 'initiated' if $prompt_state eq STATE_OPENING || $prompt_state eq STATE_TEXT; + return 'next' if $prompt_state eq STATE_NEXT; + return 'select' if $prompt_state eq STATE_RESPONSES; + return 'number' if $prompt_state eq STATE_NUMBER_INPUT; + return 'text' if $prompt_state eq STATE_TEXT_INPUT; + return 'close' if $prompt_state eq STATE_CLOSING; + return 'buy_or_sell' if $prompt_state eq STATE_BUY_OR_SELL; + return 'store' if $prompt_state eq STATE_STORE; + return 'sell' if $prompt_state eq STATE_SELL; + return 'cash' if $prompt_state eq STATE_CASH; + return undef; +} + +sub _set_prompt_state { + my ($prompt_state, %opts) = @_; + my $previous_state = $state{state}; + + $state{state} = $prompt_state; + $state{legacy_talk} = _legacy_state_for($prompt_state); + $state{waiting_server} = $opts{waiting_server} ? 1 : 0; + $state{can_close} = $opts{can_close} ? 1 : 0; + $state{last_update} = time; + + _sync_legacy_globals(); + _emit_state_changed($previous_state, $opts{reason}) if (!defined $opts{emit_state_changed} || $opts{emit_state_changed}); +} + +sub _begin_conversation { + my (%args) = @_; + my $previous_state = $state{state}; + my $is_new = !$state{active} + || !defined $state{npc_id} + || !defined $args{npc_id} + || $state{npc_id} ne $args{npc_id}; + + if ($is_new) { + $state{sequence_id}++; + $state{opened_at} = time; + $state{text_lines} = []; + $state{last_text} = undef; + $state{responses} = []; + $state{image} = undef; + $state{error} = undef; + $state{waiting_server} = 0; + $state{can_close} = 0; + } + + $state{active} = 1; + $state{npc_id} = $args{npc_id} if exists $args{npc_id}; + $state{name_id} = $args{name_id} if exists $args{name_id}; + $state{npc_name} = $args{npc_name} if exists $args{npc_name}; + $state{scheduled_time} = $args{scheduled_time} if exists $args{scheduled_time}; + $state{last_packet} = $args{last_packet} if exists $args{last_packet}; + $state{last_update} = time; + + _sync_legacy_globals(); + + if ($is_new) { + debug TF("[NPC] Opened conversation with npc_id=%s\n", defined $state{npc_id} ? unpack('V', $state{npc_id}) : 'undef'), 'npc'; + _emit_hook('npc_talk_opened', { + previous_state => $previous_state, + }); + } + + return $is_new; +} + +sub _mark_waiting_server { + my (%args) = @_; + $state{waiting_server} = 1; + $state{scheduled_time} = exists $args{scheduled_time} ? $args{scheduled_time} : time; + $state{last_update} = time; + _sync_legacy_globals(); +} + +sub _require_sender { + return 1 if $messageSender; + error "[NPC] Cannot send NPC interaction packet: message sender is not available.\n"; + return; +} + +sub reset { + my (%args) = @_; + my $previous_state = $state{state}; + my $sequence_id = $state{sequence_id}; + + %state = _default_state(); + $state{sequence_id} = $sequence_id; + $state{last_update} = time; + + _sync_legacy_globals(); + debug "[NPC] Conversation reset.\n", 'npc'; + _emit_state_changed($previous_state, $args{reason} || 'reset'); +} + +sub snapshot { + return { + active => $state{active} ? 1 : 0, + state => $state{state}, + current_state => current_state(), + npc_id => $state{npc_id}, + name_id => $state{name_id}, + npc_name => $state{npc_name}, + text => text(), + text_lines => text_lines(), + last_text => $state{last_text}, + responses => responses(), + legacy_responses => legacy_responses(), + image => $state{image}, + last_packet => $state{last_packet}, + last_update => $state{last_update}, + opened_at => $state{opened_at}, + sequence_id => $state{sequence_id}, + waiting_server => $state{waiting_server} ? 1 : 0, + error => $state{error}, + can_close => $state{can_close} ? 1 : 0, + legacy_talk => $state{legacy_talk}, + scheduled_time => $state{scheduled_time}, + }; +} + +sub is_open { return $state{active} ? 1 : 0; } +sub is_closed { return $state{active} ? 0 : 1; } +sub current_state { return $state{waiting_server} && $state{state} ne STATE_CLOSED ? 'WAITING_SERVER' : $state{state}; } +sub prompt_state { return $state{state}; } +sub current_npc_id { return $state{npc_id}; } +sub current_name_id { return $state{name_id}; } +sub current_npc_name { return $state{npc_name}; } +sub has_text { return scalar @{$state{text_lines}} ? 1 : 0; } +sub text { return join("\n", @{$state{text_lines}}); } +sub text_lines { return _clone_array($state{text_lines}); } +sub last_text { return $state{last_text}; } +sub has_responses { return scalar @{$state{responses}} ? 1 : 0; } +sub responses { return _clone_array($state{responses}); } +sub response_count { return scalar @{$state{responses}}; } +sub has_image { return defined $state{image} ? 1 : 0; } +sub image { return $state{image}; } +sub expects_continue { return !$state{waiting_server} && $state{state} eq STATE_NEXT; } +sub expects_response { return !$state{waiting_server} && $state{state} eq STATE_RESPONSES; } +sub expects_number { return !$state{waiting_server} && $state{state} eq STATE_NUMBER_INPUT; } +sub expects_text { return !$state{waiting_server} && $state{state} eq STATE_TEXT_INPUT; } +sub can_close { + return 1 if $state{can_close}; + return 1 if $state{state} =~ /^(?:BUY_OR_SELL|STORE|SELL|CASH)$/; + return 0; +} +sub last_update_time { return $state{last_update}; } +sub sequence_id { return $state{sequence_id}; } +sub is_waiting_server { return $state{waiting_server} ? 1 : 0; } +sub legacy_talk_state { return $state{legacy_talk}; } +sub scheduled_time { return $state{scheduled_time}; } +sub error_message { return $state{error}; } + +sub set_scheduled_time { + my ($value) = @_; + $state{scheduled_time} = $value; + $state{last_update} = time; + _sync_legacy_globals(); + return $state{scheduled_time}; +} + +sub clear_scheduled_time { + undef $state{scheduled_time}; + $state{last_update} = time; + _sync_legacy_globals(); +} + +sub set_waiting_server { + my ($value) = @_; + $state{waiting_server} = $value ? 1 : 0; + $state{last_update} = time; + _sync_legacy_globals(); + return $state{waiting_server}; +} + +sub response_at { + my ($index) = @_; + return undef if !defined $index || $index < 0 || $index >= @{$state{responses}}; + return $state{responses}[$index]; +} + +sub legacy_responses { + my @responses = @{$state{responses}}; + push @responses, T('Cancel Chat') if @responses; + return \@responses; +} + +sub cancel_response_index { + return undef if !@{$state{responses}}; + return scalar @{$state{responses}}; +} + +sub find_response { + my ($wanted_text) = @_; + return undef if !defined $wanted_text; + for my $index (0 .. $#{$state{responses}}) { + return $index if $state{responses}[$index] eq $wanted_text; + } + return undef; +} + +sub find_response_regex { + my ($pattern, $flags) = @_; + return undef if !defined $pattern; + + my $regex = eval { + return $flags && $flags =~ /i/ ? qr/$pattern/i : qr/$pattern/; + }; + if ($@) { + warning TF("[NPC] Invalid response regex '%s': %s\n", $pattern, $@), 'npc'; + return undef; + } + + for my $index (0 .. $#{$state{responses}}) { + return $index if $state{responses}[$index] =~ $regex; + } + return undef; +} + +sub on_text_packet { + my (%args) = @_; + my $opened = _begin_conversation(%args, last_packet => 'npc_talk'); + my $line = defined $args{text} ? $args{text} : ''; + $state{last_text} = $line; + push @{$state{text_lines}}, $line; + $state{responses} = []; + _set_prompt_state(STATE_TEXT, reason => $opened ? 'open_text' : 'text'); + _emit_hook('npc_talk_text', { + line => $line, + }); +} + +sub on_continue_packet { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_talk_continue'); + _set_prompt_state(STATE_NEXT, reason => 'continue'); +} + +sub on_responses_packet { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_talk_responses'); + $state{responses} = _clone_array($args{responses}); + _set_prompt_state(STATE_RESPONSES, reason => 'responses'); + debug TF("[NPC] Received %d responses.\n", scalar @{$state{responses}}), 'npc'; + _emit_hook('npc_talk_responses', { + responses => responses(), + legacy_responses => legacy_responses(), + }); +} + +sub on_number_input_packet { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_talk_number'); + _set_prompt_state(STATE_NUMBER_INPUT, reason => 'number_input'); + _emit_hook('npc_talk_number_input', {}); +} + +sub on_text_input_packet { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_talk_text'); + _set_prompt_state(STATE_TEXT_INPUT, reason => 'text_input'); + _emit_hook('npc_talk_text_input', {}); +} + +sub on_close_packet { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_talk_close'); + $state{responses} = []; + _set_prompt_state(STATE_CLOSING, can_close => 1, reason => 'close'); + debug "[NPC] Conversation moved to closing state.\n", 'npc'; + _emit_hook('npc_talk_closed', { + pending_cancel => 1, + }); +} + +sub on_clear_packet { + my (%args) = @_; + my $had_conversation = is_open(); + my $npc_id = current_npc_id(); + reset(reason => 'clear_packet'); + _emit_hook('npc_talk_closed', { + pending_cancel => 0, + npc_id => $npc_id, + }) if $had_conversation; +} + +sub on_shop_begin { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_store_begin'); + $state{text_lines} = []; + $state{last_text} = undef; + $state{responses} = []; + _set_prompt_state(STATE_BUY_OR_SELL, reason => 'shop_begin'); +} + +sub on_store_list { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_store_info'); + $state{text_lines} = []; + $state{last_text} = undef; + $state{responses} = []; + _set_prompt_state(STATE_STORE, reason => 'store_list'); +} + +sub on_sell_list { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'npc_sell_list'); + $state{text_lines} = []; + $state{last_text} = undef; + $state{responses} = []; + _set_prompt_state(STATE_SELL, reason => 'sell_list'); +} + +sub on_cash_dealer { + my (%args) = @_; + _begin_conversation(%args, last_packet => 'cash_dealer'); + $state{text_lines} = []; + $state{last_text} = undef; + $state{responses} = []; + _set_prompt_state(STATE_CASH, reason => 'cash_dealer'); +} + +sub on_image_packet { + my (%args) = @_; + $state{image} = $args{image}; + $state{last_update} = time; + _sync_legacy_globals(); +} + +sub clear_image { + delete $state{image}; + $state{last_update} = time; + _sync_legacy_globals(); +} + +sub on_error { + my ($message) = @_; + my $previous_state = $state{state}; + $state{error} = $message; + $state{state} = STATE_ERROR; + $state{waiting_server} = 0; + $state{last_update} = time; + _sync_legacy_globals(); + error TF("[NPC] %s\n", $message); + _emit_state_changed($previous_state, 'error'); + _emit_hook('npc_talk_error', { + error => $message, + }); +} + +sub start { + my ($npc_id, %args) = @_; + return unless defined $npc_id; + return unless _require_sender(); + + _begin_conversation( + npc_id => $npc_id, + name_id => $args{name_id}, + npc_name => $args{npc_name}, + last_packet => 'send_talk', + scheduled_time => time, + ); + $state{text_lines} = []; + $state{last_text} = undef; + $state{responses} = []; + _set_prompt_state(STATE_OPENING, waiting_server => 1, reason => 'start'); + $messageSender->sendTalk($npc_id); + debug TF("[NPC] Sending talk start to npc_id=%s\n", unpack('V', $npc_id)), 'npc'; + return 1; +} + +sub continue { + my (%args) = @_; + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring npcTalkContinue: no active NPC conversation.\n", 'npc'; + return; + } + if (!$args{force} && !expects_continue()) { + warning "[NPC] Ignoring npcTalkContinue: NPC is not waiting for continue.\n", 'npc'; + return; + } + + $messageSender->sendTalkContinue(current_npc_id()); + _mark_waiting_server(); + debug TF("[NPC] Sending continue to npc_id=%s\n", unpack('V', current_npc_id())), 'npc'; + return 1; +} + +sub select_response { + my ($index, %args) = @_; + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring npcTalkSelect: no active NPC conversation.\n", 'npc'; + return; + } + if (!$args{force} && !expects_response()) { + warning "[NPC] Ignoring npcTalkSelect: no response menu is currently open.\n", 'npc'; + return; + } + if (!defined $index || $index !~ /^\d+$/) { + warning "[NPC] Ignoring npcTalkSelect: response index must be numeric.\n", 'npc'; + return; + } + + my $cancel_index = cancel_response_index(); + if (defined $cancel_index && $index == $cancel_index) { + return cancel(); + } + + if ($index < 0 || $index >= response_count()) { + warning TF("[NPC] Ignoring npcTalkSelect: response index %s is out of range.\n", $index), 'npc'; + return; + } + + my $response_text = response_at($index); + $messageSender->sendTalkResponse(current_npc_id(), $index); + _mark_waiting_server(); + debug TF("[NPC] Sending response index=%s text=\"%s\"\n", $index, $response_text), 'npc'; + return 1; +} + +sub select_response_text { + my ($wanted_text) = @_; + my $index = find_response($wanted_text); + if (!defined $index) { + warning TF("[NPC] Ignoring npcTalkSelect: response '%s' was not found.\n", $wanted_text), 'npc'; + return; + } + return select_response($index); +} + +sub select_response_regex { + my ($pattern, $flags) = @_; + my $index = find_response_regex($pattern, $flags); + if (!defined $index) { + warning TF("[NPC] Ignoring npcTalkSelectRegex: no response matched '%s'.\n", $pattern), 'npc'; + return; + } + return select_response($index); +} + +sub send_number { + my ($number, %args) = @_; + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring npcTalkNumber: no active NPC conversation.\n", 'npc'; + return; + } + if (!$args{force} && !expects_number()) { + warning "[NPC] Ignoring npcTalkNumber: NPC is not waiting for a number.\n", 'npc'; + return; + } + if (!defined $number || $number !~ /^-?\d+$/) { + warning TF("[NPC] Ignoring npcTalkNumber: '%s' is not numeric.\n", defined $number ? $number : 'undef'), 'npc'; + return; + } + + $messageSender->sendTalkNumber(current_npc_id(), $number); + _mark_waiting_server(); + debug TF("[NPC] Sending number=%s\n", $number), 'npc'; + return 1; +} + +sub send_text { + my ($value, %args) = @_; + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring npcTalkText: no active NPC conversation.\n", 'npc'; + return; + } + if (!$args{force} && !expects_text()) { + warning "[NPC] Ignoring npcTalkText: NPC is not waiting for text.\n", 'npc'; + return; + } + $value = '' if !defined $value; + + $messageSender->sendTalkText(current_npc_id(), $value); + _mark_waiting_server(); + debug TF("[NPC] Sending text=\"%s\"\n", $value), 'npc'; + return 1; +} + +sub choose_buy_or_sell { + my ($mode) = @_; + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring NPC buy/sell choice: no active NPC conversation.\n", 'npc'; + return; + } + if (prompt_state() ne STATE_BUY_OR_SELL) { + warning "[NPC] Ignoring NPC buy/sell choice: NPC is not waiting for a buy/sell choice.\n", 'npc'; + return; + } + + my $sell = ($mode && $mode eq 'sell') ? 1 : 0; + $messageSender->sendNPCBuySellList(current_npc_id(), $sell); + _mark_waiting_server(); + debug TF("[NPC] Sending buy/sell choice=%s\n", $sell ? 'sell' : 'buy'), 'npc'; + return 1; +} + +sub close { + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring npcTalkClose: no active NPC conversation.\n", 'npc'; + return; + } + if (!can_close()) { + warning "[NPC] Ignoring npcTalkClose: this conversation is not closable yet.\n", 'npc'; + return; + } + + my $npc_id = current_npc_id(); + $messageSender->sendTalkCancel($npc_id); + reset(reason => 'close'); + debug TF("[NPC] Sent talk close to npc_id=%s\n", unpack('V', $npc_id)), 'npc'; + return 1; +} + +sub cancel { + return unless _require_sender(); + if (!current_npc_id()) { + warning "[NPC] Ignoring npcTalkCancel: no active NPC conversation.\n", 'npc'; + return; + } + + if (expects_response()) { + my $cancel_index = cancel_response_index(); + return unless defined $cancel_index; + $messageSender->sendTalkResponse(current_npc_id(), $cancel_index); + _mark_waiting_server(); + debug "[NPC] Sent menu cancel response.\n", 'npc'; + return 1; + } elsif (expects_continue()) { + return continue(force => 1); + } elsif (expects_number()) { + return send_number(0, force => 1); + } elsif (expects_text()) { + return send_text('', force => 1); + } elsif (can_close()) { + return close(); + } + + warning "[NPC] Ignoring npcTalkCancel: conversation is not in a cancellable state.\n", 'npc'; + return; +} + +sub debug_string { + my $snapshot = snapshot(); + my @lines = ( + "active: $snapshot->{active}", + "state: $snapshot->{state}", + "current_state: $snapshot->{current_state}", + "npc_id: " . (defined $snapshot->{npc_id} ? unpack('V', $snapshot->{npc_id}) : 'undef'), + "name_id: " . (defined $snapshot->{name_id} ? $snapshot->{name_id} : 'undef'), + "npc_name: " . (defined $snapshot->{npc_name} ? $snapshot->{npc_name} : 'undef'), + "waiting_server: $snapshot->{waiting_server}", + "can_close: $snapshot->{can_close}", + "legacy_talk: " . (defined $snapshot->{legacy_talk} ? $snapshot->{legacy_talk} : 'undef'), + "last_update: " . (defined $snapshot->{last_update} ? $snapshot->{last_update} : 'undef'), + "opened_at: " . (defined $snapshot->{opened_at} ? $snapshot->{opened_at} : 'undef'), + "sequence_id: " . $snapshot->{sequence_id}, + "error: " . (defined $snapshot->{error} ? $snapshot->{error} : 'undef'), + "text:", + ); + + if (@{$snapshot->{text_lines}}) { + for my $index (0 .. $#{$snapshot->{text_lines}}) { + push @lines, sprintf(" [%d] %s", $index, $snapshot->{text_lines}[$index]); + } + } else { + push @lines, " "; + } + + push @lines, "responses:"; + if (@{$snapshot->{responses}}) { + for my $index (0 .. $#{$snapshot->{responses}}) { + push @lines, sprintf(" [%d] %s", $index, $snapshot->{responses}[$index]); + } + push @lines, sprintf(" [%d] %s", cancel_response_index(), T('Cancel Chat')); + } else { + push @lines, " "; + } + + return join("\n", @lines) . "\n"; +} + +1; diff --git a/src/Network/Receive.pm b/src/Network/Receive.pm index 3f68515844..4c9b4ee088 100644 --- a/src/Network/Receive.pm +++ b/src/Network/Receive.pm @@ -42,6 +42,7 @@ use Interface; use Network; use Network::MessageTokenizer; use Misc; +use NPC::Conversation; use Plugins; use Skill; use Utils; @@ -3170,9 +3171,9 @@ sub npc_image { } unless ($args->{type} == 255) { - $talk{image} = $args->{npc_image}; + NPC::Conversation::on_image_packet(image => $args->{npc_image}); } else { - delete $talk{image}; + NPC::Conversation::clear_image(); } } @@ -7669,28 +7670,22 @@ sub npc_talk { my $nameID = unpack 'V', $ID; autoNpcTalk($ID, $nameID); - $talk{ID} = $args->{ID}; - $talk{nameID} = unpack 'V', $args->{ID}; my $msg = bytesToString ($args->{msg}); # Remove RO color codes - $talk{msg} =~ s/\^[a-fA-F0-9]{6}//g; $msg =~ s/\^[a-fA-F0-9]{6}//g; - - # Prepend existing conversation. - $talk{msg} .= "\n" if $talk{msg}; - $talk{msg} .= $msg; - - $ai_v{'npc_talk'}{'ID'} = $talk{ID}; - $ai_v{'npc_talk'}{talk} = 'initiated'; - $ai_v{'npc_talk'}{time} = time; - - my $name = getNPCName($talk{ID}); + my $name = getNPCName($ID); + NPC::Conversation::on_text_packet( + npc_id => $ID, + name_id => $nameID, + npc_name => $name, + text => $msg, + ); Plugins::callHook('npc_talk', { - ID => $talk{ID}, - nameID => $talk{nameID}, + ID => $ID, + nameID => $nameID, name => $name, - msg => $talk{msg}, + msg => NPC::Conversation::text(), }); message "$name: $msg\n", "npc"; } @@ -7701,20 +7696,19 @@ sub npc_talk_close { my ($self, $args) = @_; # 00b6: long ID - if (!exists $ai_v{'npc_talk'} || !defined $ai_v{'npc_talk'} || !exists $ai_v{'npc_talk'}{'ID'} || !defined $ai_v{'npc_talk'}{'ID'}) { + if (!NPC::Conversation::is_open() || !NPC::Conversation::current_npc_id()) { debug "We received an strange 'npc_talk_done', just ignoring it\n", "npc"; return; } - return if (exists $ai_v{'npc_talk'}{'talk'} && $ai_v{'npc_talk'}{'talk'} eq 'buy_or_sell'); + return if (NPC::Conversation::legacy_talk_state() && NPC::Conversation::legacy_talk_state() eq 'buy_or_sell'); my $ID = $args->{ID}; - my $name = getNPCName($ID); - - $ai_v{'npc_talk'}{'ID'} = $talk{ID}; - $ai_v{'npc_talk'}{'talk'} = 'close'; - $ai_v{'npc_talk'}{'time'} = time; - undef %talk; + NPC::Conversation::on_close_packet( + npc_id => $ID, + name_id => unpack('V', $ID), + npc_name => getNPCName($ID), + ); Plugins::callHook('npc_talk_done', {ID => $ID}); } @@ -7724,11 +7718,11 @@ sub npc_talk_close { sub npc_talk_continue { my ($self, $args) = @_; my $ID = substr($args->{RAW_MSG}, 2, 4); - my $name = getNPCName($ID); - - $ai_v{'npc_talk'}{'ID'} = $ID; - $ai_v{'npc_talk'}{'talk'} = 'next'; - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_continue_packet( + npc_id => $ID, + name_id => unpack('V', $ID), + npc_name => getNPCName($ID), + ); } # Displays an NPC dialog input box for numbers (ZC_OPEN_EDITDLG). @@ -7737,11 +7731,11 @@ sub npc_talk_number { my ($self, $args) = @_; my $ID = $args->{ID}; - - my $name = getNPCName($ID); - $ai_v{'npc_talk'}{'ID'} = $ID; - $ai_v{'npc_talk'}{'talk'} = 'number'; - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_number_input_packet( + npc_id => $ID, + name_id => unpack('V', $ID), + npc_name => getNPCName($ID), + ); } # Displays an NPC dialog menu (ZC_MENU_LIST). @@ -7758,8 +7752,6 @@ sub npc_talk_responses { my $nameID = unpack 'V', $ID; autoNpcTalk($ID, $nameID); - $talk{ID} = $ID; - $talk{nameID} = $nameID; my $talk = unpack("Z*", substr($msg, 8)); $talk = substr($msg, 8) if (!defined $talk); $talk = bytesToString($talk); @@ -7770,7 +7762,7 @@ sub npc_talk_responses { responses => \@preTalkResponses, }); - $talk{responses} = []; + my @responses; foreach my $response (@preTalkResponses) { # Remove RO color codes $response =~ s/\^[a-fA-F0-9]{6}//g; @@ -7778,23 +7770,18 @@ sub npc_talk_responses { $response = itemNameSimple($1); } - push @{$talk{responses}}, $response if ($response ne ""); + push @responses, $response if ($response ne ""); } - $talk{responses}[@{$talk{responses}}] = T("Cancel Chat"); - - $ai_v{'npc_talk'}{'ID'} = $talk{ID}; - $ai_v{'npc_talk'}{'talk'} = 'select'; - $ai_v{'npc_talk'}{'time'} = time; + my $name = getNPCName($ID); + NPC::Conversation::on_responses_packet( + npc_id => $ID, + name_id => $nameID, + npc_name => $name, + responses => \@responses, + ); Commands::run('talk resp'); - - my $name = getNPCName($ID); - Plugins::callHook('npc_talk_responses', { - ID => $ID, - name => $name, - responses => $talk{responses}, - }); } # Displays an NPC dialog input box for numbers (ZC_OPEN_EDITDLGSTR). @@ -7803,22 +7790,22 @@ sub npc_talk_text { my ($self, $args) = @_; my $ID = $args->{ID}; - - my $name = getNPCName($ID); - $ai_v{'npc_talk'}{'ID'} = $ID; - $ai_v{'npc_talk'}{'talk'} = 'text'; - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_text_input_packet( + npc_id => $ID, + name_id => unpack('V', $ID), + npc_name => getNPCName($ID), + ); } # Displays the buy/sell dialog of an NPC shop (ZC_SELECT_DEALTYPE). # 00C4 .L sub npc_store_begin { my ($self, $args) = @_; - undef %talk; - $talk{ID} = $args->{ID}; - $ai_v{'npc_talk'}{'ID'} = $talk{ID}; - $ai_v{'npc_talk'}{'talk'} = 'buy_or_sell'; - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_shop_begin( + npc_id => $args->{ID}, + name_id => unpack('V', $args->{ID}), + npc_name => getNPCName($args->{ID}), + ); $storeList->{npcName} = getNPCName($args->{ID}) || T('Unknown'); } @@ -7844,7 +7831,6 @@ sub npc_store_info { my $len = length pack $pack; $storeList->clear; - undef %talk; for (my $i = 4; $i < $args->{RAW_MSG_SIZE}; $i += $len) { my $item = Actor::Item->new; @$item{@{$keys}} = unpack $pack, substr $msg, $i, $len; @@ -7866,9 +7852,11 @@ sub npc_store_info { debug "Item added to Store: $item->{name} - $item->{price}z\n", "parseMsg", 2; } - $ai_v{'npc_talk'}{talk} = 'store'; - # continue talk sequence now - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_store_list( + npc_id => NPC::Conversation::current_npc_id(), + name_id => NPC::Conversation::current_name_id(), + npc_name => $storeList->{npcName}, + ); if (AI::action() ne 'buyAuto') { Commands::run('store'); @@ -7897,18 +7885,19 @@ sub npc_sell_list { $item->{unsellable} = 1; # flag this item as unsellable } - undef %talk; message T("Ready to start selling items\n"); - - $ai_v{'npc_talk'}{talk} = 'sell'; - # continue talk sequence now - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_sell_list( + npc_id => NPC::Conversation::current_npc_id(), + name_id => NPC::Conversation::current_name_id(), + npc_name => $storeList->{npcName}, + ); } sub npc_clear_dialog { my ($self, $args) = @_; my $ID = $args->{ID}; debug "The dialogue with the NPC " .getHex($ID) ." was closed.\n", "parseMsg"; + NPC::Conversation::on_clear_packet(npc_id => $ID); } # Notification about the result of a purchase attempt from an NPC shop (ZC_PC_PURCHASE_RESULT). @@ -7956,7 +7945,6 @@ sub npc_market_info { my $len = length pack $pack; $storeList->clear; - undef %talk; for (my $i = 0; $i < length($args->{itemList}); $i += $len) { my $item = Actor::Item->new; @@ -7988,9 +7976,11 @@ sub npc_market_info { $in_market = 1; - # continue talk sequence now - $ai_v{'npc_talk'}{'talk'} = 'store'; - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_store_list( + npc_id => NPC::Conversation::current_npc_id(), + name_id => NPC::Conversation::current_name_id(), + npc_name => $storeList->{npcName}, + ); } # Show the purchase result update the list of items, that can be bought in an NPC MARKET shop (PACKET_ZC_NPC_MARKET_OPEN). @@ -8031,7 +8021,6 @@ sub npc_market_purchase_result { my $len = length pack $pack; $storeList->clear; - undef %talk; for (my $i = 0; $i < length($args->{itemList}); $i += $len) { my $item = Actor::Item->new; @@ -8063,9 +8052,11 @@ sub npc_market_purchase_result { $in_market = 1; - # continue talk sequence now - $ai_v{'npc_talk'}{'talk'} = 'store'; - $ai_v{'npc_talk'}{'time'} = time; + NPC::Conversation::on_store_list( + npc_id => NPC::Conversation::current_npc_id(), + name_id => NPC::Conversation::current_name_id(), + npc_name => $storeList->{npcName}, + ); } sub deal_add_you { @@ -10315,10 +10306,11 @@ sub vender_buy_fail { sub cash_dealer { my ($self, $args) = @_; - undef %talk; - $ai_v{'npc_talk'}{talk} = 'cash'; - # continue talk sequence now - $ai_v{'npc_talk'}{time} = time; + NPC::Conversation::on_cash_dealer( + npc_id => NPC::Conversation::current_npc_id(), + name_id => NPC::Conversation::current_name_id(), + npc_name => $storeList->{npcName}, + ); # Parse item_list => ['V2 C v', [qw(price price_discount type nameid)]] $cashList->clear; diff --git a/src/Network/Send.pm b/src/Network/Send.pm index 32495a4e9f..fd85327f18 100644 --- a/src/Network/Send.pm +++ b/src/Network/Send.pm @@ -33,7 +33,7 @@ use Digest::MD5; use Math::BigInt; # TODO: remove 'use Globals' from here, instead pass vars on -use Globals qw(%ai_v %config $bytesSent %packetDescriptions $enc_val1 $enc_val2 $char $masterServer $syncSync $accountID %timeout %talk $skillExchangeItem $net $rodexList $rodexWrite %universalCatalog %rpackets $mergeItemList $repairList %cashShop); +use Globals qw(%ai_v %config $bytesSent %packetDescriptions $enc_val1 $enc_val2 $char $masterServer $syncSync $accountID %timeout $skillExchangeItem $net $rodexList $rodexWrite %universalCatalog %rpackets $mergeItemList $repairList %cashShop); use I18N qw(bytesToString stringToBytes); use Utils qw(existsInList getHex getTickCount getCoordString makeCoordsDir); @@ -577,16 +577,12 @@ sub sendGetCharacterName { sub sendTalk { my ($self, $ID) = @_; - delete $talk{msg}; - delete $talk{image}; $self->sendToServer($self->reconstruct({switch => 'npc_talk', ID => $ID, type => 1})); debug "Sent talk: ".getHex($ID)."\n", "sendPacket", 2; } sub sendTalkCancel { my ($self, $ID) = @_; - undef %talk; - delete $ai_v{'npc_talk'} if (exists $ai_v{'npc_talk'}); $self->sendToServer($self->reconstruct({switch => 'npc_talk_cancel', ID => $ID})); debug "Sent talk cancel: ".getHex($ID)."\n", "sendPacket", 2; } @@ -599,24 +595,18 @@ sub sendTalkContinue { sub sendTalkResponse { my ($self, $ID, $response) = @_; - delete $talk{msg}; - delete $talk{image}; $self->sendToServer($self->reconstruct({switch => 'npc_talk_response', ID => $ID, response => $response})); debug "Sent talk respond: ".getHex($ID).", $response\n", "sendPacket", 2; } sub sendTalkNumber { my ($self, $ID, $number) = @_; - delete $talk{msg}; - delete $talk{image}; $self->sendToServer($self->reconstruct({switch => 'npc_talk_number', ID => $ID, value => $number})); debug "Sent talk number: ".getHex($ID).", $number\n", "sendPacket", 2; } sub sendTalkText { my ($self, $ID, $input) = @_; - delete $talk{msg}; - delete $talk{image}; $input = stringToBytes($input); $self->sendToServer($self->reconstruct({ switch => 'npc_talk_text', diff --git a/src/Task/MapRoute.pm b/src/Task/MapRoute.pm index 5e53306959..40b2701f2b 100644 --- a/src/Task/MapRoute.pm +++ b/src/Task/MapRoute.pm @@ -28,6 +28,7 @@ use base qw(Task::WithSubtask); use Translation qw(T TF); use Log qw(message debug warning error); use Network; +use NPC::Conversation; use Plugins; use Misc qw(canUseTeleport portalExists); use Utils qw(timeOut blockDistance existsInList calcPosFromPathfinding actorFinishedMovement); @@ -877,8 +878,8 @@ sub isRouteTeleportAllowedOnMap { sub setNpcTalk { my ($self) = @_; - if (%talk) { - warning "[mapRoute] [setNpcTalk] % talk is defined for some reason.\n", "ai_npcTalk"; + if (NPC::Conversation::is_open()) { + warning "[mapRoute] [setNpcTalk] NPC conversation is already active for some reason.\n", "ai_npcTalk"; } $self->{substage} = 'Waiting for Warp'; diff --git a/src/Task/TalkNPC.pm b/src/Task/TalkNPC.pm index b26423905f..a072069b88 100644 --- a/src/Task/TalkNPC.pm +++ b/src/Task/TalkNPC.pm @@ -22,12 +22,13 @@ use Modules 'register'; use Task; use AI; use base qw(Task); -use Globals qw($char %timeout $npcsList $monstersList $portalsList %ai_v $messageSender %config $storeList $net %talk); +use Globals qw($char %timeout $npcsList $monstersList $portalsList %config $storeList $net); use Log qw(message debug error warning); use Utils; use Commands; use Network; use Misc; +use NPC::Conversation; use Plugins; use Translation qw(T TF); @@ -53,6 +54,17 @@ use enum qw( AFTER_NPC_CANCEL ); +sub _conversation_open { return NPC::Conversation::is_open(); } +sub _conversation_state { return NPC::Conversation::prompt_state(); } +sub _conversation_id { return NPC::Conversation::current_npc_id(); } +sub _conversation_name_id { return NPC::Conversation::current_name_id(); } +sub _conversation_text { return NPC::Conversation::text(); } +sub _conversation_image { return NPC::Conversation::image(); } +sub _conversation_responses { return NPC::Conversation::responses(); } +sub _conversation_scheduled_time { return NPC::Conversation::scheduled_time(); } +sub _set_conversation_scheduled_time { return NPC::Conversation::set_scheduled_time($_[0]); } +sub _clear_conversation_scheduled_time { return NPC::Conversation::clear_scheduled_time(); } + ## # Task::TalkNPC->new(options...) @@ -269,7 +281,7 @@ sub setTarget { # FIXME: We probably need to look at the target->pos_to (if defined), # not at self, as coordinates can be omitted. if (defined $self->{x} && defined $self->{y}) { - lookAtPosition($self) unless (%talk); + lookAtPosition($self) unless (_conversation_open()); } return 1; @@ -281,8 +293,8 @@ sub find_and_set_target { if ($target) { return unless $self->setTarget($target); - } elsif (exists $talk{nameID} && $ai_v{'npc_talk'}{'talk'} ne 'buy_or_sell') {#check if this is really necessary - $self->{ID} = $talk{ID}; + } elsif (defined _conversation_name_id() && _conversation_state() ne 'BUY_OR_SELL') {#check if this is really necessary + $self->{ID} = _conversation_id(); $self->{target} = Actor::NPC->new; $self->{target}->{appear_time} = time; $self->{target}->{name} = 'Unknown'; @@ -304,7 +316,7 @@ sub iterate { if ($self->{map_change} || $self->{disconnected}) { #A conversation started right after mapchange/disconnection (eg. payon guards) - if (%talk) { + if (_conversation_open()) { debug "[TalkNPC] Done talking with $self->{target}, but another NPC initiated a talk instantly\n", "ai_npcTalk"; # TODO: maybe better create a new task and pass remaining steps to it debug "[TalkNPC] Continuing the talk within the same task and remaining conversation steps\n", "ai_npcTalk"; @@ -345,7 +357,7 @@ sub iterate { $self->{validatedAddSequence} = 1; } - if ((!%talk || $ai_v{'npc_talk'}{'talk'} eq 'close') && $self->{type} eq 'autotalk') { + if ((!_conversation_open() || _conversation_state() eq 'CLOSING') && $self->{type} eq 'autotalk') { debug "Talking was initiated by the other side and finished instantly\n", "ai_npcTalk"; #We must still send talk cancel or otherwise the character can't move. $self->{stage} = AFTER_NPC_CLOSE; @@ -375,15 +387,14 @@ sub iterate { my $target = $self->find_and_set_target; return if ($self->getStatus() == Task::DONE); - if (!%talk) { + if (!_conversation_open()) { debug "[TalkNPC] talk is not defined, setting to start conversation.\n", "ai_npcTalk"; unless ($self->{steps}[0] eq 'x' || $self->{steps}[0] eq 'k') { $self->addSteps('x', 1); } - undef $ai_v{'npc_talk'}{'time'}; - undef $ai_v{'npc_talk'}{'talk'}; + _clear_conversation_scheduled_time(); - } elsif ($talk{nameID} eq $target->{nameID}) { + } elsif (!_conversation_open() || !defined $target || _conversation_name_id() == $target->{nameID}) { debug "[TalkNPC] talk is defined and nameID is right, just adding steps.\n", "ai_npcTalk"; } else { debug "[TalkNPC] talk is defined and nameID is wrong, using manage_wrong_sequence.\n", "ai_npcTalk"; @@ -392,7 +403,7 @@ sub iterate { return if ($self->getStatus() == Task::DONE); - if ($target || %talk) { + if ($target || _conversation_open()) { $self->{stage} = TALKING_TO_NPC; $self->{time} = time; @@ -427,14 +438,14 @@ sub iterate { } # This is where things may bug in npcs which have no chat (private healers) - } elsif (!$ai_v{'npc_talk'}{'time'} && timeOut($self->{time}, $timeResponse)) { + } elsif (!_conversation_scheduled_time() && timeOut($self->{time}, $timeResponse)) { # If NPC does not respond before timing out, then by default, it's # a failure. - $messageSender->sendTalkCancel($self->{ID}); + NPC::Conversation::close(); $self->setError(NPC_NO_RESPONSE, T("The NPC did not respond.")); } elsif ($self->{stage} == TALKING_TO_NPC) { - if (%talk && $ai_v{'npc_talk'}{'talk'} eq 'initiated') { + if (_conversation_open() && _conversation_state() eq 'TEXT') { debug "Spining until a response is needed from us\n", "ai_npcTalk"; return; } @@ -442,7 +453,7 @@ sub iterate { #In theory after the talk_response_cancel is sent we shouldn't receive anything, so just wait the timer and assume it's over if ($self->{sent_talk_response_cancel}) { return unless (timeOut($self->{sent_talk_resp_cancel_time})); - undef %talk; + NPC::Conversation::reset(reason => 'response_cancel_timeout'); if (defined $self->{error_code}) { debug "Done talking with $self->{target}, but with conversation sequence errors\n", "ai_npcTalk"; $self->setError($self->{error_code}, $self->{error_message}); @@ -453,7 +464,7 @@ sub iterate { #This will try to get out of this conversation as much as possible } elsif ($self->{trying_to_cancel}) { - $ai_v{'npc_talk'}{'time'} = time + $timeResponse; + _set_conversation_scheduled_time(time + $timeResponse); $self->{time} = time; $self->cancelTalk; return; @@ -461,11 +472,11 @@ sub iterate { #We must always wait for the last sent step to be answered, if it hasn't then cancel this task. if ($self->{wait_for_answer}) { - if (${self}->{progress_bar}) { - $ai_v{'npc_talk'}{'time'} = time; + if ($self->{progress_bar}) { + _set_conversation_scheduled_time(time); return; } - if (timeOut($ai_v{'npc_talk'}{'time'}, $timeResponse)) { + if (timeOut(_conversation_scheduled_time(), $timeResponse)) { $self->{error_code} = NPC_TIMEOUT_AFTER_ASWER; $self->{error_message} = "We have waited for too long after we sent a response to the npc."; $self->{trying_to_cancel} = 1; @@ -474,13 +485,13 @@ sub iterate { return; } - return unless (timeOut($ai_v{'npc_talk'}{'time'}, $ai_npc_talk_wait_before_continue)); + return unless (timeOut(_conversation_scheduled_time(), $ai_npc_talk_wait_before_continue)); # Wait x seconds. if ($self->{steps}[0] =~ /^w(\d+)/i) { my $time = $1; debug "$self->{target}: Waiting for $time seconds...\n", "ai_npcTalk"; - $ai_v{'npc_talk'}{'time'} = time + $time; + _set_conversation_scheduled_time(time + $time); $self->{time} = time + $time; shift @{$self->{steps}}; return; @@ -490,21 +501,21 @@ sub iterate { my $command = $1; my $timeout = $timeResponse - 4; $timeout = 0 if $timeout < 0; - $ai_v{'npc_talk'}{'time'} = time + $timeout; + _set_conversation_scheduled_time(time + $timeout); $self->{time} = time + $timeout; Commands::run($command); shift @{$self->{steps}}; return; } - if ($ai_v{'npc_talk'}{'talk'} ne 'next') { + if (_conversation_state() ne 'NEXT') { while ($self->{steps}[0] =~ /^c/i) { warning "Ignoring excessive use 'c' in conversation with npc.\n"; shift(@{$self->{steps}}); } #This is to make non-autotalkcont sequences compatible with autotalkcont ones - } elsif ($ai_v{'npc_talk'}{'talk'} eq 'next' && $config{autoTalkCont}) { + } elsif (_conversation_state() eq 'NEXT' && $config{autoTalkCont}) { if ( $self->noMoreSteps || $self->{steps}[0] !~ /^c/i ) { unshift(@{$self->{steps}}, 'c'); } @@ -512,13 +523,13 @@ sub iterate { } #This is done to restart the conversation (check if this is necessary) - if ($ai_v{'npc_talk'}{'talk'} eq 'close' && $self->{steps}[0] =~ /x/i) { - undef $ai_v{'npc_talk'}{'talk'}; + if (_conversation_state() eq 'CLOSING' && $self->{steps}[0] =~ /x/i) { + NPC::Conversation::reset(reason => 'restart_after_close'); } if ($self->noMoreSteps) { # We arrived at a buy or sell selection, but there are no more steps regarding this, so end the conversation - if ($ai_v{'npc_talk'}{'talk'} =~ /^(buy_or_sell|store|sell|cash)$/) { + if (_conversation_state() =~ /^(?:BUY_OR_SELL|STORE|SELL|CASH)$/) { $self->conversation_end; } #Wait for more commands @@ -526,16 +537,16 @@ sub iterate { #We give the NPC some time to respond. This time will be reset once the NPC responds. } else { - $ai_v{'npc_talk'}{'time'} = time + $timeResponse; + _set_conversation_scheduled_time(time + $timeResponse); $self->{time} = time; } my $step = $self->{steps}[0]; - my $current_talk_step = $ai_v{'npc_talk'}{'talk'}; + my $current_talk_step = _conversation_state(); while ( $step =~ /^if~\/(.*?)\/,(.*)/i ) { my ( $regex, $code ) = ( $1, $2 ); - if ( "$talk{msg}:$talk{image}" =~ /$regex/s ) { + if ( (_conversation_text() . ':' . (_conversation_image() || '')) =~ /$regex/s ) { $step = $code; } else { shift @{ $self->{steps} }; @@ -566,7 +577,7 @@ sub iterate { $self->{target}->sendTalk; # Select an answer - } elsif ($current_talk_step eq 'select') { + } elsif ($current_talk_step eq 'RESPONSES') { if ( $step =~ /^r(?:(\d+)|=(.+)|~\/(.*?)\/(i?))/i ) { my $choice = $1; @@ -576,16 +587,12 @@ sub iterate { # Choose a menu item by matching options against a regular expression. my $pattern = $2 ? "^\Q$2\E\$" : $3; my $postCondition = $4; - ( $choice ) = grep { $postCondition ? $talk{responses}[$_] =~ /$pattern/i : $talk{responses}[$_] =~ /$pattern/ } 0..$#{$talk{responses}}; + my $responses = _conversation_responses(); + ( $choice ) = grep { $postCondition ? $responses->[$_] =~ /$pattern/i : $responses->[$_] =~ /$pattern/ } 0..$#{$responses}; # Found valid response - if (defined $choice && $choice < $#{$talk{responses}}) { - $messageSender->sendTalkResponse($talk{ID}, $choice + 1); - - # Found response is fake 'Cancel Chat' - } elsif (defined $choice) { - $self->{trying_to_cancel} = 1; - return; + if (defined $choice) { + NPC::Conversation::select_response($choice); # No match was found } else { @@ -597,14 +604,16 @@ sub iterate { #Normal number response } else { + my $responses = _conversation_responses(); + my $cancel_index = NPC::Conversation::cancel_response_index(); #Normal number response is valid - if ($choice < $#{$talk{responses}}) { + if ($choice < @{$responses}) { debug "$self->{target}: Sending talk response #$choice\n", "ai_npcTalk"; - $messageSender->sendTalkResponse($talk{ID}, $choice + 1); + NPC::Conversation::select_response($choice); #Normal number response is a fake "Cancel Chat" response. - } elsif ($choice == $#{$talk{responses}}) { + } elsif (defined $cancel_index && $choice == $cancel_index) { $self->{trying_to_cancel} = 1; return; @@ -612,7 +621,7 @@ sub iterate { } else { $self->manage_wrong_sequence(TF("According to the given NPC instructions, menu item %d must " . "now be selected, but there are only %d menu items.", - $choice, @{$talk{responses}} - 1)); + $choice, scalar(@{$responses}))); return; } } @@ -624,10 +633,10 @@ sub iterate { } # Click Next. - } elsif ($current_talk_step eq 'next') { + } elsif ($current_talk_step eq 'NEXT') { if ($step =~ /^c/i) { debug "$self->{target}: Sending talk continue (next)\n", "ai_npcTalk"; - $messageSender->sendTalkContinue($talk{ID}); + NPC::Conversation::continue(); # Wrong sequence } else { @@ -636,11 +645,11 @@ sub iterate { } # Send NPC talk number. - } elsif ($current_talk_step eq 'number') { + } elsif ($current_talk_step eq 'NUMBER_INPUT') { if ( $step =~ /^d(\d+)/i ) { my $number = $1; debug "$self->{target}: Sending the number: $number\n", "ai_npcTalk"; - $messageSender->sendTalkNumber($talk{ID}, $number); + NPC::Conversation::send_number($number); # Wrong sequence } else { @@ -649,11 +658,11 @@ sub iterate { } # Send NPC talk text. - } elsif ($current_talk_step eq 'text') { + } elsif ($current_talk_step eq 'TEXT_INPUT') { if ( $step =~ /^t=(.*)/i ) { my $text = $1; debug "$self->{target}: Sending the text: $text\n", "ai_npcTalk"; - $messageSender->sendTalkText($talk{ID}, $text); + NPC::Conversation::send_text($text); # Wrong sequence } else { @@ -662,20 +671,19 @@ sub iterate { } # Get the sell or buy list in a shop. - } elsif ( $current_talk_step eq 'buy_or_sell' ) { + } elsif ( $current_talk_step eq 'BUY_OR_SELL' ) { # Get the sell list in a shop. if ( $step =~ /^s/i ) { - $messageSender->sendNPCBuySellList($talk{ID}, 1); + NPC::Conversation::choose_buy_or_sell('sell'); # Get the buy list in a shop. } elsif ($step =~ /^b$/i) { - $messageSender->sendNPCBuySellList($talk{ID}, 0); + NPC::Conversation::choose_buy_or_sell('buy'); # Click the cancel button in a shop. } elsif ($step =~ /^e$/i) { cancelNpcBuySell(); - $ai_v{'npc_talk'}{'talk'} = 'close'; if ($self->noMoreSteps) { $self->conversation_end; @@ -689,7 +697,7 @@ sub iterate { return; } - } elsif ( $current_talk_step eq 'store' ) { + } elsif ( $current_talk_step eq 'STORE' ) { # Buy Items if ($step =~ /^b(\d+),(\d+)/i) { @@ -719,7 +727,6 @@ sub iterate { if ($self->noMoreSteps) { $self->conversation_end; } else { - $ai_v{'npc_talk'}{'talk'} = 'close'; $self->{time} = time + 2; } return; @@ -732,7 +739,6 @@ sub iterate { if ($self->noMoreSteps) { $self->conversation_end; } else { - $ai_v{'npc_talk'}{'talk'} = 'close'; $self->{time} = time + 2; } @@ -744,7 +750,7 @@ sub iterate { return; } - } elsif ( $current_talk_step eq 'sell' ) { + } elsif ( $current_talk_step eq 'SELL' ) { $self->conversation_end; } else { @@ -779,9 +785,9 @@ sub iterate { $self->{time} = time; $self->{stage} = AFTER_NPC_CANCEL; - my $id = $ai_v{'npc_talk'}{'ID'}; - debug "[TalkNPC] $self->{target}: Sending talk cancel [id '".(unpack ('V', $id))."'] after NPC has done talking\n", "ai_npcTalk"; - $messageSender->sendTalkCancel($id); + my $id = _conversation_id(); + debug "[TalkNPC] $self->{target}: Sending talk cancel [id '".(defined $id ? unpack ('V', $id) : 'undef')."'] after NPC has done talking\n", "ai_npcTalk"; + NPC::Conversation::cancel(); # After a 'npc_talk_cancel' and a timeout we decide what to do next } elsif ($self->{stage} == AFTER_NPC_CANCEL) { @@ -795,11 +801,11 @@ sub iterate { # No more steps to be sent # Usual end of a conversation - if ($self->noMoreSteps && !%talk) { + if ($self->noMoreSteps && !_conversation_open()) { $self->conversation_end; # There are more steps but no conversation with npc - } elsif (!%talk) { + } elsif (!_conversation_open()) { # Usual 'x' step if ($self->{steps}[0] =~ /x/i) { debug "$self->{target}: Reinitiating the talk\n", "ai_npcTalk"; @@ -857,7 +863,7 @@ sub conversation_end { my ($self) = @_; $self->delHooks; $self->setDone(); - debug "Task::TalkNPC::conversation_end called at ai npc_talk '".$ai_v{'npc_talk'}{'talk'}."'.\n", "ai_npcTalk"; + debug "Task::TalkNPC::conversation_end called at ai npc_talk '" . (_conversation_state() || '') . "'.\n", "ai_npcTalk"; message TF("[TalkNPC] Done talking with %s (end)\n", $self->{target}), "ai_npcTalk"; } @@ -878,44 +884,45 @@ my $default_number = 1234; sub cancelTalk { my ($self) = @_; + my $conversation_state = _conversation_state(); if (defined $self->{error_message}) { debug "[TalkNPC] Trying to auto close the conversation due to error.\n", "ai_npcTalk"; } - if ($ai_v{'npc_talk'}{'talk'} eq 'select') { - $messageSender->sendTalkResponse($talk{ID}, $#{$talk{responses}}); + if ($conversation_state eq 'RESPONSES') { + NPC::Conversation::cancel(); $self->{sent_talk_response_cancel} = 1; $self->{sent_talk_resp_cancel_time}{time} = time; $self->{sent_talk_resp_cancel_time}{timeout} = 5; - } elsif ($ai_v{'npc_talk'}{'talk'} eq 'next') { - $messageSender->sendTalkContinue($talk{ID}); + } elsif ($conversation_state eq 'NEXT') { + NPC::Conversation::continue(force => 1); - } elsif ($ai_v{'npc_talk'}{'talk'} eq 'number') { - $messageSender->sendTalkNumber($talk{ID}, $default_number); + } elsif ($conversation_state eq 'NUMBER_INPUT') { + NPC::Conversation::send_number($default_number, force => 1); - } elsif ($ai_v{'npc_talk'}{'talk'} eq 'text') { - $messageSender->sendTalkText($talk{ID}, $default_text); + } elsif ($conversation_state eq 'TEXT_INPUT') { + NPC::Conversation::send_text($default_text, force => 1); - } elsif ( $ai_v{'npc_talk'}{'talk'} eq 'buy_or_sell' ) { + } elsif ( $conversation_state eq 'BUY_OR_SELL' ) { $self->conversation_end; - $ai_v{'npc_talk'}{'talk'} = 'close'; - } elsif ( $ai_v{'npc_talk'}{'talk'} eq 'cash' ) { + NPC::Conversation::reset(reason => 'autocancel_buy_or_sell'); + } elsif ( $conversation_state eq 'CASH' ) { $self->conversation_end; - $ai_v{'npc_talk'}{'talk'} = 'close'; + NPC::Conversation::reset(reason => 'autocancel_cash'); - } elsif ( $ai_v{'npc_talk'}{'talk'} eq 'store' ) { + } elsif ( $conversation_state eq 'STORE' ) { $self->conversation_end; - $ai_v{'npc_talk'}{'talk'} = 'close'; + NPC::Conversation::reset(reason => 'autocancel_store'); - } elsif ( $ai_v{'npc_talk'}{'talk'} eq 'sell' ) { + } elsif ( $conversation_state eq 'SELL' ) { $self->conversation_end; - $ai_v{'npc_talk'}{'talk'} = 'close'; + NPC::Conversation::reset(reason => 'autocancel_sell'); - } elsif (!$ai_v{'npc_talk'}{'talk'}) { + } elsif (!$conversation_state || $conversation_state eq 'CLOSED') { $self->conversation_end; - $ai_v{'npc_talk'}{'talk'} = 'close'; + NPC::Conversation::reset(reason => 'autocancel_closed'); } @@ -966,9 +973,9 @@ sub waitingForSteps { my ($self) = @_; return 0 unless ($self->{stage} == TALKING_TO_NPC); return 0 unless ($self->noMoreSteps); - return 0 if ($ai_v{'npc_talk'}{'talk'} eq 'next' && $config{autoTalkCont}); + return 0 if (_conversation_state() eq 'NEXT' && $config{autoTalkCont}); my $ai_npc_talk_wait_to_answer = $timeout{'ai_npc_talk_wait_to_answer'}{'timeout'} ? $timeout{'ai_npc_talk_wait_to_answer'}{'timeout'} : 1.5; - return 0 unless (timeOut($ai_v{'npc_talk'}{'time'}, $ai_npc_talk_wait_to_answer)); + return 0 unless (timeOut(_conversation_scheduled_time(), $ai_npc_talk_wait_to_answer)); return 1; } diff --git a/src/functions.pl b/src/functions.pl index 934318127a..c7f69c7bfa 100644 --- a/src/functions.pl +++ b/src/functions.pl @@ -27,6 +27,7 @@ package main; use Network::ClientReceive; use Network::PaddedPackets; use Network::MessageTokenizer; +use NPC::Conversation; use Commands; use Plugins; use Utils; @@ -745,8 +746,7 @@ sub initMapChangeVars { undef %items; undef %spells; undef %incomingParty; - undef %talk; - delete $ai_v{'npc_talk'} if (exists $ai_v{'npc_talk'}); + NPC::Conversation::reset(reason => 'global_reset'); $ai_v{temp} = {}; undef $venderID; undef $venderCID; diff --git a/src/test/NPCConversationStaticTest.pm b/src/test/NPCConversationStaticTest.pm new file mode 100644 index 0000000000..ad913c5ee0 --- /dev/null +++ b/src/test/NPCConversationStaticTest.pm @@ -0,0 +1,49 @@ +package NPCConversationStaticTest; + +use strict; +use Test::More; +use File::Find; + +sub start { + print "### Starting NPCConversationStaticTest\n"; + + my @roots = ('src', 'plugins'); + my @violations; + + find( + { + no_chdir => 1, + wanted => sub { + return unless -f $_; + return unless $_ =~ /\.(?:pm|pl)$/; + return if $_ =~ m{(?:^|[\\/])src[\\/]Globals\.pm$}; + return if $_ =~ m{(?:^|[\\/])src[\\/]NPC[\\/]Conversation\.pm$}; + return if $_ =~ m{(?:^|[\\/])src[\\/]test[\\/]}; + return if $_ =~ m{(?:^|[\\/])plugins[\\/]needs-review[\\/]}; + + open my $fh, '<', $_ or die "Cannot open $_: $!"; + my $line_number = 0; + while (my $line = <$fh>) { + $line_number++; + $line =~ s/\r?\n\z//; + my $code = $line; + $code =~ s/\s+#.*$//; + next if $code =~ /^\s*#/; + next if $code =~ /^\s*use\s+Globals\b.*\%talk/; + next if $code =~ /^\s*our\s+\%talk\b/; + + if ($code =~ /\$talk\{/ || $code =~ /\$ai_v\{\s*['"]npc_talk['"]\s*\}/ || $code =~ /(? [] }, $class; + } + + sub _push_call { + my ($self, $method, @args) = @_; + push @{$self->{calls}}, [$method, @args]; + return 1; + } + + sub calls { + my ($self) = @_; + return $self->{calls}; + } + + sub clear { + my ($self) = @_; + $self->{calls} = []; + } + + sub sendTalk { shift->_push_call('sendTalk', @_); } + sub sendTalkContinue { shift->_push_call('sendTalkContinue', @_); } + sub sendTalkResponse { shift->_push_call('sendTalkResponse', @_); } + sub sendTalkNumber { shift->_push_call('sendTalkNumber', @_); } + sub sendTalkText { shift->_push_call('sendTalkText', @_); } + sub sendTalkCancel { shift->_push_call('sendTalkCancel', @_); } + sub sendNPCBuySellList { shift->_push_call('sendNPCBuySellList', @_); } +} + +sub _npc_id { + return pack('V', $_[0]); +} + +sub _last_call { + my ($mock) = @_; + return $mock->calls->[-1]; +} + +sub _reset_world { + NPC::Conversation::reset(reason => 'unit_test_reset'); + undef %talk; + delete $ai_v{'npc_talk'}; +} + +sub start { + print "### Starting NPCConversationTest\n"; + + local $messageSender = NPCConversationTest::MessageSenderMock->new(); + + test_reset(); + test_text_flow(); + test_responses_flow($messageSender); + test_number_and_text_input($messageSender); + test_close_flow($messageSender); +} + +sub test_reset { + _reset_world(); + + NPC::Conversation::on_text_packet( + npc_id => _npc_id(1001), + name_id => 2001, + npc_name => 'Guide', + text => 'Hello there', + ); + + ok(NPC::Conversation::is_open(), 'conversation opened before reset'); + NPC::Conversation::reset(reason => 'test_reset'); + + ok(NPC::Conversation::is_closed(), 'reset closes conversation'); + is(NPC::Conversation::current_state(), 'CLOSED', 'reset returns closed state'); + is(NPC::Conversation::text(), '', 'reset clears text'); + is_deeply(NPC::Conversation::responses(), [], 'reset clears responses'); + ok(!exists $talk{ID}, 'legacy %talk cleared'); + ok(!exists $ai_v{'npc_talk'}, 'legacy ai_v npc_talk cleared'); +} + +sub test_text_flow { + _reset_world(); + + NPC::Conversation::on_text_packet( + npc_id => _npc_id(2002), + name_id => 3002, + npc_name => 'Kafra Employee', + text => 'Welcome adventurer', + ); + NPC::Conversation::on_text_packet( + npc_id => _npc_id(2002), + name_id => 3002, + npc_name => 'Kafra Employee', + text => 'How may I help you?', + ); + + ok(NPC::Conversation::is_open(), 'text packet opens conversation'); + is(NPC::Conversation::prompt_state(), 'TEXT', 'text packet sets TEXT state'); + ok(NPC::Conversation::has_text(), 'conversation has text'); + is_deeply( + NPC::Conversation::text_lines(), + ['Welcome adventurer', 'How may I help you?'], + 'text lines are preserved in order' + ); + like(NPC::Conversation::text(), qr/How may I help you\?/, 'joined text contains latest line'); + is($talk{msg}, "Welcome adventurer\nHow may I help you?", 'legacy talk message stays synchronized'); +} + +sub test_responses_flow { + my ($mock) = @_; + _reset_world(); + $mock->clear; + + NPC::Conversation::on_responses_packet( + npc_id => _npc_id(3003), + name_id => 4003, + npc_name => 'Warp Girl', + responses => ['Prontera', 'Payon', 'Alberta'], + ); + + ok(NPC::Conversation::has_responses(), 'responses packet registers menu entries'); + is(NPC::Conversation::response_count(), 3, 'response count matches packet'); + ok(NPC::Conversation::expects_response(), 'response state expects a selection'); + is_deeply( + NPC::Conversation::legacy_responses(), + ['Prontera', 'Payon', 'Alberta', 'Cancel Chat'], + 'legacy response list includes synthetic cancel item' + ); + + ok(NPC::Conversation::select_response(1), 'valid response is sent'); + is_deeply( + _last_call($mock), + ['sendTalkResponse', _npc_id(3003), 1], + 'response selection preserves historical 0-based protocol index' + ); + is(NPC::Conversation::current_state(), 'WAITING_SERVER', 'selecting a response moves local state to WAITING_SERVER'); + + $mock->clear; + NPC::Conversation::on_responses_packet( + npc_id => _npc_id(3003), + name_id => 4003, + npc_name => 'Warp Girl', + responses => ['Prontera', 'Payon'], + ); + ok(NPC::Conversation::cancel(), 'menu cancel is sent through synthetic cancel index'); + is_deeply( + _last_call($mock), + ['sendTalkResponse', _npc_id(3003), 2], + 'cancel uses appended cancel index for compatibility' + ); + + $mock->clear; + NPC::Conversation::reset(reason => 'invalid_select_test'); + ok(!NPC::Conversation::select_response(0), 'invalid selection is rejected when no menu is open'); + is(scalar @{$mock->calls}, 0, 'invalid selection does not send a packet'); +} + +sub test_number_and_text_input { + my ($mock) = @_; + _reset_world(); + $mock->clear; + + NPC::Conversation::on_number_input_packet( + npc_id => _npc_id(4004), + name_id => 5004, + npc_name => 'Quiz Master', + ); + ok(NPC::Conversation::expects_number(), 'number input state is detected'); + ok(NPC::Conversation::send_number(10), 'numeric reply is sent'); + is_deeply(_last_call($mock), ['sendTalkNumber', _npc_id(4004), 10], 'number reply uses low-level sender'); + + NPC::Conversation::on_text_input_packet( + npc_id => _npc_id(5005), + name_id => 6005, + npc_name => 'Registration Clerk', + ); + ok(NPC::Conversation::expects_text(), 'text input state is detected'); + ok(NPC::Conversation::send_text('OpenKore'), 'text reply is sent'); + is_deeply(_last_call($mock), ['sendTalkText', _npc_id(5005), 'OpenKore'], 'text reply uses low-level sender'); +} + +sub test_close_flow { + my ($mock) = @_; + _reset_world(); + $mock->clear; + + NPC::Conversation::on_text_packet( + npc_id => _npc_id(6006), + name_id => 7006, + npc_name => 'Quest NPC', + text => 'Quest complete.', + ); + NPC::Conversation::on_close_packet( + npc_id => _npc_id(6006), + name_id => 7006, + npc_name => 'Quest NPC', + ); + + is(NPC::Conversation::prompt_state(), 'CLOSING', 'close packet moves conversation into CLOSING state'); + like(NPC::Conversation::text(), qr/Quest complete\./, 'closing state preserves latest text for inspection'); + ok(NPC::Conversation::close(), 'close sends final cancel packet'); + is_deeply(_last_call($mock), ['sendTalkCancel', _npc_id(6006)], 'close delegates to talk cancel packet'); + ok(NPC::Conversation::is_closed(), 'close fully resets conversation'); +} + +1; diff --git a/src/test/unittests.pl b/src/test/unittests.pl index 569872cac1..f43f0c5dc6 100755 --- a/src/test/unittests.pl +++ b/src/test/unittests.pl @@ -19,6 +19,7 @@ ShopTest TaskManagerTest TaskWithSubtaskTest TaskChainedTest TaskTalkNPCTest + NPCConversationTest NPCConversationStaticTest PluginsHookTest FileParsersTest DynamicPortalGroupsTest