name = $name; $this->description = $description; $this->stats = $stats; // upon creation, the "CurHealth" stat is set to the "Health" stat if it exists if ( isset( $this->stats['Health'] ) && ! isset( $this->stats['CurHealth'] ) ) { $this->stats['CurHealth'] = $this->stats['Health']; } } public function get_name(): string { return $this->name; } public function set_name( string $name ): void { $this->name = $name; $this->save_to_db(); } public function get_description(): string { return $this->description; } #region Effects Management /* * Every effect is defined by effect_type (Adventurebook_Effect_Types), effect_name (string), duration (int, in rounds), power (int) */ /** Get all effects applied to the combatant. * * @return array An array of effects. */ public function get_effects(): array { return $this->stats['EFFECTS'] ?? []; } /** Check if the combatant has a specific effect by its type. * * @param string $effect_type The type of the effect to check. * * @return bool True if the effect is present, false otherwise. */ public function has_effect_type( string $effect_type ): bool { $effects = $this->get_effects(); foreach ( $effects as $effect ) { if ( $effect['type'] === $effect_type ) { return true; } } return false; } public function get_effect_power_by_type( string $effect_type ): ?int { $effects = $this->get_effects(); foreach ( $effects as $effect ) { if ( $effect['type'] === $effect_type ) { return $effect['power']; } } return null; } /** Check if the combatant has a specific effect by its name. * * @param string $effect_name The name of the effect to check. * * @return bool True if the effect is present, false otherwise. */ public function has_effect( string $effect_name ): bool { $effects = $this->get_effects(); foreach ( $effects as $effect ) { if ( $effect['name'] === $effect_name ) { return true; } } return false; } /** Get a formatted string with the icons and info text of all effects applied to the combatant. * for characters: to be shown in the character sheet * * @return string A formatted string of effects. */ public function get_effects_output(): string { $effects = $this->get_effects(); $effects_output = ''; foreach ( $effects as $effect ) { $icon = Adventurebook_Effect_Types::get_effect_type_icon( $effect['type'] ); $effect_name = esc_html( $effect['name'] ); $duration = intval( $effect['duration'] ); $power = intval( $effect['power'] ); $effects_output .= ''; // the icon is a string so show it as is with a span for styling and a title attribute $effects_output .= '' . $icon . ' '; $effects_output .= ''; } return $effects_output; } /** Add an effect to the combatant. * * @param string $effect_name The name of the effect. * @param string $effect_type The type of the effect. * @param int $duration The duration of the effect in rounds. * @param int $power The power of the effect. * * @return void */ public function add_effect( string $effect_name, string $effect_type, int $duration, int $power ): void { $effects = $this->get_effects(); // only add an effect if the combatant does not already have it (effect_name is unique) foreach ( $effects as $effect ) { if ( $effect['name'] == $effect_name ) { // if the duration of the new effect is longer, we update the existing effect's duration if ( $duration > $effect['duration'] ) { $effect['duration'] = $duration; // write this effect back to the effects array $effects = array_map( function ( $e ) use ( $effect ) { if ( $e['name'] === $effect['name'] ) { return $effect; } return $e; }, $effects ); $this->stats['EFFECTS'] = $effects; $this->save_to_db(); } return; // effect already present } } $effects[] = [ 'name' => $effect_name, 'type' => $effect_type, 'duration' => $duration, 'power' => $power, ]; $this->stats['EFFECTS'] = $effects; $this->save_to_db(); } /** Remove an effect from the combatant by its name. * * @param string $effect_name The name of the effect to remove. * * @return void */ public function remove_effect( string $effect_name ): void { $effects = $this->get_effects(); $effects = array_filter( $effects, function ( $effect ) use ( $effect_name ) { return $effect['name'] !== $effect_name; } ); $this->stats['EFFECTS'] = array_values( $effects ); // reindex the array $this->save_to_db(); } public function suffer_effects() : array { $effects = $this->get_effects(); $updated_effects = []; $log_messages = []; foreach ( $effects as $effect ) { $effect['duration'] -= 1; if ( $effect['duration'] > 0 ) { // now we apply the effect's impact here if needed switch( $effect['type'] ) { case Adventurebook_Effect_Types::DRUNK: // no effect applied each round, the effect impacts the combat value (see there) /* translators: %s is the combatant's name */ $log_messages[] = esc_html( sprintf( __( '%s is feeling drunk!', 'adventurebook' ), $this->name ) ); break; case Adventurebook_Effect_Types::HEAL: $this->heal( $effect['power'] ); /* translators: %s is the combatant's name, %d is the amount of health healed */ $log_messages[] = esc_html( sprintf( __( '%1$s heals %2$d health from healing effect!', 'adventurebook' ), $this->name, $effect['power'] ) ); break; case Adventurebook_Effect_Types::POISON: // only apply poison damage if the combatant does not have poison immunity with equal power $immunity_power = $this->get_effect_power_by_type( Adventurebook_Effect_Types::POISON_IMMUNITY ); if ( $immunity_power === null || $immunity_power < $effect['power'] ) { $this->take_damage( $effect['power'] ); /* translators: %s is the combatant's name, %d is the amount of poison damage suffered */ $log_messages[] = esc_html( sprintf( __( '%1$s suffers %2$d poison damage!', 'adventurebook' ), $this->name, $effect['power'] ) ); } else { /* translators: %s is the combatant's name */ $log_messages[] = esc_html( sprintf( __( '%s is immune to the poison effect!', 'adventurebook' ), $this->name ) ); } break; } $updated_effects[] = $effect; } } $this->stats['EFFECTS'] = $updated_effects; $this->save_to_db(); return $log_messages; } #endregion #region Stats Management public function get_stats(): array { return $this->stats; } /** Get a specific stat by name. * Returns null if the stat does not exist. * * a Stat MUST start with a capital letter! * e.g. "CurHealth", "Health", "Attack", "Defense", "Initiative", etc.) * * @param string $stat_name The name of the stat to retrieve. * * @return ?string The value of the stat, or null if it does not exist * TODO: some stats count as -3 if they do not exist (e.g. Initiative) */ public function get_stat( string $stat_name ): ?string { return $this->stats[ $stat_name ] ?? null; } /** Set a specific stat by name. * If the stat does not exist, it will be created. * * a Stat MUST start with a capital letter! * * @param string $stat_name The name of the stat to set. * @param int $value The value to set the stat to. * * @return void */ public function set_stat( string $stat_name, $value ): void { $this->stats[ $stat_name ] = $value; $this->save_to_db(); } /** * Get the total value of a stat including bonuses from equipped items. * If the stat does not exist, it is considered to be 0. * * @param string $stat_name The name of the stat to retrieve. * * @return int The total value of the stat including item bonuses. */ public function get_total_stat_including_items( string $stat_name ): int { $base_stat = intval( $this->stats[ $stat_name ] ?? 0 ); $item_bonus = $this->get_stat_bonus_from_equipped_items( $stat_name ); $effect_bonus = 0; // if the combatant is drunk, reduce all combat stats by 1 if ( $this->has_effect_type( Adventurebook_Effect_Types::DRUNK ) ) { $effect_bonus -= 1; } // if the combatant has the effect "enhance_armor", add its power to ARMOR if ( $stat_name === 'Armor' ) { $enhance_armor_power = $this->get_effect_power_by_type( Adventurebook_Effect_Types::ENHANCE_ARMOR ); if ( $enhance_armor_power !== null ) { $effect_bonus += intval( $enhance_armor_power ); } } // if the character has the skill "combatskill", add the skill value to Initiative, Attack and Defense $combatskill = $this->get_skill('combatskill'); if ( $combatskill !== null ) { if ( in_array( $stat_name, [ 'Initiative', 'Attack', 'Defense' ], true ) ) { $effect_bonus += intval( $combatskill['value'] ); } } // add the character's Strength stat to Damage if ( $stat_name === 'Damage' ) { $strength = intval( $this->get_total_stat_including_items( 'Strength' ) ); $effect_bonus += $strength; } // add half the character's Dexterity (rounded down) to Attack and Defense if ( in_array( $stat_name, [ 'Attack', 'Defense' ], true ) ) { $dexterity = intval( $this->get_total_stat_including_items( 'Dexterity' ) ); $effect_bonus += intdiv( $dexterity, 2 ); } return $base_stat + $item_bonus + $effect_bonus; } /** * Get the total bonus for a specific stat from all equipped items. * If an item has more than one Slot (e.g. "Weapon+Shield"), its bonus is counted only once. * * @param string $stat_name The name of the stat to retrieve the bonus for. * * @return int The total bonus for the stat from equipped items. */ public function get_stat_bonus_from_equipped_items( string $stat_name ): int { $bonus = 0; $equipped_items = $this->get_equipped_items(); // if an item has more than one Slot (e.g. "Weapon+Shield"), its bonus is counted only once, so the second slot is skipped $valid_slots = []; // first, we build a list of valid slots to consider foreach ( $equipped_items as $slot_name => $item ) { /** @var Item $item */ $item_slot = $item->get_item_slot(); $item_slot_parts = explode( '+', $item_slot ); $first_slot = $item_slot_parts[0]; if ( ! in_array( $first_slot, $valid_slots, true ) ) { $valid_slots[] = $first_slot; } else { // this slot is already considered, we skip this item unset( $equipped_items[ $slot_name ] ); } } // now we calculate the bonus from the valid equipped items foreach ( $equipped_items as $item ) { /** @var Item $item */ $item_stats = $item->get_complete_stats(); if ( array_key_exists( $stat_name, $item_stats ) ) { $bonus += intval( $item_stats[ $stat_name ] ); } } return $bonus; } public function get_xp_spent(): int { return $this->stats['XP_Spent'] ?? 0; } public function add_xp_spent( int $xp_spent ) : string { if ( ! isset( $this->stats['XP_Spent'] ) ) { $this->stats['XP_Spent'] = 0; } $this->stats['XP_Spent'] += $xp_spent; if($this->can_raise_level()) { $this->raise_level(); return __( 'You have gained a new level! You can spend a development point!', 'adventurebook' ); } $this->save_to_db(); return ''; } public function can_raise_level(): bool { $current_level = $this->get_level(); $xp_needed = $this->get_exp_needed_for_level( $current_level + 1 ); $xp_spent = $this->stats['XP_Spent'] ?? 0; return $xp_spent >= $xp_needed; } public function get_level_for_xp_spent(): int { $xp_spent = $this->stats['XP_Spent'] ?? 0; $level = 1; $xp_for_next_level = 1000; // example: 1000 XP for level 2, 2000 XP for level 3, etc. while ( $xp_spent >= $xp_for_next_level ) { $level++; $xp_for_next_level += $level * 1000; // example progression } return $level; } /** * calculates the xp needed for a certain level * * level 2: 1000 * 3: 3000 * 4: 6000 * 5: 10000 * 6: 15000 * 7: 21000 * 8: 28000 * 9: 36000 * 10: 45000 * 11: 55000 ... * * @param $level * @return int */ public function get_exp_needed_for_level($level): int { $c = 0; for ($i = 1; $i < $level; $i++) { $c += $i * 1000; } return $c; } public function get_exp_needed_for_next_level(): int { $current_level = $this->get_level(); return $this->get_exp_needed_for_level( $current_level + 1 ); } public function raise_level() { $newlevel = $this->get_level() + 1; $this->set_level($newlevel); // add development points $this->set_development_points($this->get_development_points() + 1); $this->recalculate_max_health(); } public function recalculate_max_health(): void { $endurance = $this->get_stat('Endurance') ?? 0; $level = $this->get_level(); $new_max_health = (10 + intval($endurance)) * $level; $this->set_stat('Health', $new_max_health); // also heal the combatant to full health upon level up $this->set_stat('CurHealth', $new_max_health); } public function get_development_points() : int { return $this->stats['Development_Points'] ?? 0; } public function set_development_points( int $points ): void { $this->stats['Development_Points'] = $points; $this->save_to_db(); } public function set_level( int $level ): void { $this->stats['Level'] = $level; $this->save_to_db(); } public function get_level(): int { return $this->stats['Level'] ?? 1; } #endregion public function get_slots(): array { return $this->stats['SLOTS'] ?? []; } /** Get the wealth of the combatant including the banked amount. * (game_var "bank_balance") * * @return int The wealth of the combatant. */ public function get_wealth(): int { $wealth = intval( $this->stats['Wealth'] ?? 0 ); $banked = intval( $this->get_game_var( 'bank_balance' ) ?? 0 ); return $wealth + $banked; } public function is_alive(): bool { // if Health is not set or 0, the combatant is always alive if ( ! isset( $this->stats['Health'] ) || $this->stats['Health'] <= 0 ) { return true; } return ( $this->stats['CurHealth'] ?? 0 ) > 0; } public function take_damage( int $amount ): void { if ( isset( $this->stats['CurHealth'] ) ) { $this->stats['CurHealth'] -= $amount; if ( $this->stats['CurHealth'] < 0 ) { $this->stats['CurHealth'] = 0; } // save the combatant to the database if applicable if ( method_exists( $this, 'save_to_db' ) ) { $this->save_to_db(); } } } public function heal( int $amount ): int { if ( $amount == - 1 ) { // full heal $amount = $this->stats['Health'] - ( $this->stats['CurHealth'] ?? 0 ); } $healed_amount = 0; if ( isset( $this->stats['CurHealth'] ) && isset( $this->stats['Health'] ) ) { $healed_amount = $amount; $this->stats['CurHealth'] += $amount; if ( $this->stats['CurHealth'] > $this->stats['Health'] ) { // get the correct amount healed $healed_amount = $this->stats['Health'] - ( $this->stats['CurHealth'] - $amount ); $this->stats['CurHealth'] = $this->stats['Health']; } // save the combatant to the database if applicable if ( method_exists( $this, 'save_to_db' ) ) { $this->save_to_db(); } } return $healed_amount; } public function get_next_action(): ?string { return $this->next_action; } public function set_next_action( string $action ): void { $this->next_action = $action; $this->save_to_db(); } #region Equipment Slots Management /** * Check if the combatant has a specific slot for equipping items. * The slots are defined in the combatant's stats under 'SLOTS'. * The value is a an associative array with slot names as keys and item_id as values * e.g. ['Weapon' => '1', 'Armor' => '2'] * item_id = 0 means the slot is empty * * @param string $slot_name The name of the slot to check. * * @return bool True if the combatant has the slot, false otherwise. */ public function has_slot( string $slot_name ): bool { $slots = $this->get_slots(); // search for the array key with the slot name return array_key_exists( $slot_name, $slots ); } public function get_slot_value( string $slot_name ): ?int { $slots = $this->get_slots(); if ( array_key_exists( $slot_name, $slots ) ) { return intval( $slots[ $slot_name ] ); } return null; // slot does not exist } /** Set the value of a specific slot. * The value is the item_id of the item equipped in this slot. * If the slot does not exist, it will be created. * * @param string $slot_name The name of the slot to set. * @param int $item_id The item_id to set the slot to (0 = empty). * * @return void */ public function set_slot_value( string $slot_name, int $item_id ): void { $slots = $this->get_slots(); $slots[ $slot_name ] = $item_id; $this->stats['SLOTS'] = $slots; // save the combatant to the database if applicable if ( method_exists( $this, 'save_to_db' ) ) { $this->save_to_db(); } } /** * Determine if the combatant can equip a given item. * This is possible if the combatant has the required slot for the item. * The slots are defined in the combatant's stats under 'SLOTS'. * There has to be a slot with the same name as the item's slot stat. * For example, if the item has a stat 'Slot' with value 'Weapon', * the combatant must have 'Weapon' in its 'SLOTS' stat to equip it * * @param string $item_code the code of the item to check. * * @return bool True if the item can be equipped, false otherwise. */ public function can_equip_item( string $item_code ): bool { // get the item's slot and check if the combatant has this slot $item_definition = Item_Definition::get_item_definition_by_code( $item_code ); $item_stats = $item_definition->get_stats(); $item_slot = $item_stats['Slot'] ?? null; if ( $item_slot === null ) { return false; // item has no slot defined } // the $item_slot can be in the form of "Weapon" or "Weapon+Shield" (for two-handed items) $item_slot_parts = explode( '+', $item_slot ); foreach ( $item_slot_parts as $part ) { if ( ! $this->has_slot( $part ) ) { return false; // combatant does not have the required slot } } return true; } /** * Equip an item to the combatant * This removes one instance of the item from the inventory * and adds it to the character's stats in the Slots stat * e.g. if the item has a Slot stat with value "Weapon", * the character's Slots stat will be updated to "Weapon:short_sword" * * @param int $item_id The code of the item to equip. * * @return bool True if the item was successfully equipped, false otherwise. */ public function equip_item( int $item_id ): bool { $item = Item::get_item_by_id( $item_id ); if ( ! $this->can_equip_item( $item->code ) ) { return false; // cannot equip the item } // check if the item is in the inventory if ( ! $this->has_item_with_id( $item_id ) ) { return false; // item not in inventory } // get the item's slot $item_slot = $item->get_item_slot(); // the $item_slot can be in the form of "Weapon" or "Weapon+Shield" (for two-handed items) $item_slot_parts = explode( '+', $item_slot ); foreach ( $item_slot_parts as $part ) { // unequip any item currently in this slot and add it to the inventory again $this->unequip_slot( $part ); // set the slot to the new item_id $this->set_slot_value( $part, $item_id ); } // invalidate inventory cache as the inventory will change adventurebook_invalidate_inventory_cache($this->id); return true; } /** * Get all equipped items as an associative array * with slot names as keys and item ids as values * * @return array An associative array of equipped items. * e.g. ['Weapon' => (item sword), 'Armor' => (item leather_armor)] */ public function get_equipped_items(): array { $equipped_items = []; foreach ( $this->get_slots() as $slot_name => $item_id ) { $item = Item::get_item_by_id( $item_id ); /** @var Item $item */ if ( $item ) { $item_definition = Item_Definition::get_item_definition_by_code( $item->code ); $equipped_items[ $item_definition->get_main_slot() ] = $item; } } // sort the equipped items by slot order from the Combat_System // they are in the initial_characters_stats "SLOTS" key in the form "Weapon" => 0, "Armor" => 0, etc. $combat_system = adventurebook_get_combat_system_for_character_id( $this->id ); if( ! $combat_system ) { return $equipped_items; // cannot sort without combat system (for enemies) } $slots = $combat_system->get_initial_character_stats()['SLOTS'] ?? []; $sorted_equipped_items = []; foreach ( array_keys( $slots ) as $slot_name ) { if ( array_key_exists( $slot_name, $equipped_items ) ) { $sorted_equipped_items[ $slot_name ] = $equipped_items[ $slot_name ]; } } return $sorted_equipped_items; } public function get_equipped_item_ids(): array { $equipped_item_ids = []; foreach ( $this->get_slots() as $slot_name => $item_id ) { if ( $item_id > 0 ) { $equipped_item_ids[] = $item_id; } } return $equipped_item_ids; } /** * Unequip the item in a given slot * this sets the value of the slot with the key $slot_name to 0 * * @param string $slot_name The name of the slot to unequip. * * @return void */ public function unequip_slot( string $slot_name ): void { // check if the combatant has this slot if ( ! $this->has_slot( $slot_name ) ) { return; // combatant does not have the slot } // we must check if the equipped item in the slot has more than one slot $equipped_item_id = $this->get_slot_value( $slot_name ); $equipped_item = Item::get_item_by_id( $equipped_item_id ); if ( $equipped_item ) { // invalidate inventory cache as the inventory will change adventurebook_invalidate_inventory_cache($this->id); $item_slot = $equipped_item->get_item_slot(); // the $item_slot can be in the form of "Weapon" or "Weapon+Shield" (for two-handed items) $item_slot_parts = explode( '+', $item_slot ); // this item occupies multiple slots, we must set all of them to 0 foreach ( $item_slot_parts as $part ) { $this->set_slot_value( $part, 0 ); } // save the combatant to the database if applicable if ( method_exists( $this, 'save_to_db' ) ) { $this->save_to_db(); } return; } } #endregion // region Inventory Management public function add_item_to_inventory( string $item_code ): void { // create a new item in the Item table with location_id = 0 (inventory) Item::create_item( $this->id, $item_code, 0 ); // invalidate inventory cache as the inventory will change adventurebook_invalidate_inventory_cache($this->id); } /** * Delete an item from the inventory by its item code. * If multiple items with the same code exist, only one will be deleted. * First, all unequipped items in the inventory are searched. * If no item is found there, equipped items are searched as well. * * @param string $item_code The code of the item to delete. * * @return bool True if the item was found and deleted, false otherwise. */ public function delete_itemcode_from_inventory( string $item_code ): bool { // find an item with this code in the inventory and delete it from the db $inventory_items = $this->get_inventory(); foreach ( $inventory_items as $item ) { /** @var Item $item */ if ( $item->code === $item_code ) { // invalidate inventory cache as the inventory will change adventurebook_invalidate_inventory_cache($this->id); // if this item has the stat "Slot" => "Resource", it is a resource. Then we just reduce its "Charges" stat by 1 instead of deleting it. $item_definition = Item_Definition::get_item_definition_by_code( $item->code ); $item_stats = $item_definition->get_stats(); if ( isset( $item_stats['Slot'] ) && $item_stats['Slot'] === 'Resource' ) { // reduce Charges by 1 $current_charges = intval( $item->get_stat('Charges') ?? 1 ); if ( $current_charges > 1 ) { $item->set_stat('Charges', $current_charges - 1 ); $item->save_to_db(); } else { // delete the item if Charges reach 0 Item::delete_item( $item->id ); } return true; // item charges reduced or item deleted } Item::delete_item( $item->id ); return true; // item deleted } } // not found, we search for equipped items as well $equipped_items = $this->get_equipped_items(); foreach ( $equipped_items as $slot => $item ) { /** @var Item $item */ if ( $item->code === $item_code ) { $this->unequip_slot( $slot ); Item::delete_item( $item->id ); // invalidate inventory cache as the inventory will change adventurebook_invalidate_inventory_cache($this->id); return true; // item deleted } } return false; // item not found } /** * Remove an item from the inventory and place it in the current location (zone) * * @param int $item_id The ID of the item to remove from the inventory. * * @return bool True if the item was successfully removed, false otherwise. */ public function remove_item_from_inventory(int $item_id ): bool { // update this item_id in the Item table and set the location to the current character's zone $item = Item::get_item_by_id( $item_id ); if ( $item === null ) { return false; // item not found } // inventory will/has changed, invalidate inventory cache adventurebook_invalidate_inventory_cache($this->id); // if the item is equipped, unequip it first $equipped_items = $this->get_equipped_items(); foreach ( $equipped_items as $slot => $item_equipped ) { /** @var Item $item_equipped */ if ( $item_equipped->id === $item_id ) { $this->unequip_slot( $slot ); break; } } // if this item has the stat "Slot" => "Resource", it is a resource. We check if there is another item with the same code already in the location. // if so, we delete this item instead of placing it in the location (stacking resources) and add it's "Charges" stat to the existing item. $item_definition = Item_Definition::get_item_definition_by_code( $item->code ); $item_stats = $item_definition->get_stats(); if ( isset( $item_stats['Slot'] ) && $item_stats['Slot'] === 'Resource' ) { // find an item with the same code in the current location $existing_items_in_location = get_items_for_page($this->location_id, $this->id); foreach ($existing_items_in_location as $existing_item) { /** @var Item $existing_item */ if ($existing_item->code === $item->code) { // found an existing resource item, we add the charges $existing_charges = intval($existing_item->get_stat('Charges') ?? 1); $item_charges = intval($item->get_stat('Charges') ?? 1); $total_charges = $existing_charges + $item_charges; $existing_item->set_stat('Charges', $total_charges); $existing_item->save_to_db(); // now delete the item being removed Item::delete_item($item_id); return true; // item merged and deleted } } // no existing item found, we proceed to place the item in the location $item->set_stat('Charges', intval($item->get_stat('Charges') ?? 1)); // ensure Charges stat is set } $item->set_location( $this->location_id ); return true; } public function pickup_item( int $item_id ): bool { $item = Item::get_item_by_id( $item_id ); if ( $item === null ) { return false; // item not found } // check if the item is in the current location if ( $item->location_id !== $this->location_id ) { return false; // item not in current location } // inventory will/has changed, invalidate inventory cache adventurebook_invalidate_inventory_cache($this->id); // if this item has the stat "Slot" => "Resource", it is a resource. We check if there is another item with the same code already in the inventory. // if so, we delete this item instead of adding it to the inventory (stacking resources) and add it's "Charges" stat to the existing item. $item_definition = Item_Definition::get_item_definition_by_code( $item->code ); $item_stats = $item_definition->get_stats(); if ( isset( $item_stats['Slot'] ) && $item_stats['Slot'] === 'Resource' ) { // find an item with the same code in the inventory $inventory_items = $this->get_inventory(false); // include equipped items foreach ( $inventory_items as $existing_item ) { /** @var Item $existing_item */ if ( $existing_item->code === $item->code ) { // found an existing resource item, we add the charges $existing_charges = intval( $existing_item->get_stat('Charges') ?? 1 ); $item_charges = intval( $item->get_stat('Charges') ?? 1 ); $total_charges = $existing_charges + $item_charges; $existing_item->set_stat('Charges', $total_charges); $existing_item->save_to_db(); // now delete the item being picked up Item::delete_item( $item_id ); return true; // item merged and deleted } } // no existing item found, we proceed to add the item to the inventory $item->set_stat('Charges', intval( $item->get_stat('Charges') ?? 1 ) ); // ensure Charges stat is set } // set the item's location to 0 (inventory) $item->set_location( 0 ); // we also need to set the spawn_id to 0 to indicate that this item was picked up and is no longer in the world $item->set_spawn_id(0); return true; } public function has_item_with_id( int $item_id ): bool { return in_array( $item_id, $this->inventory ); } /** Check if the character has all ingredients for a given recipe. * * @param string $recipe_data The recipe data string (e.g., "wood:1;club:1"). The last part after the last semicolon is the result item and is ignored. * * @return bool True if the character has all ingredients, false otherwise. */ public function has_ingredients_for_recipe( string $recipe_data ) : bool { $ingredients = explode( ';', $recipe_data ); // remove the last part (the result item) array_pop( $ingredients ); foreach ( $ingredients as $ingredient ) { $parts = explode( ':', $ingredient ); if ( count( $parts ) != 2 ) { continue; // invalid ingredient format } $item_code = $parts[0]; $quantity = intval( $parts[1] ); $item_count = $this->get_item_count_by_code( $item_code, false ); if ( $item_count < $quantity ) { return false; // not enough of this ingredient } } return true; // all ingredients are available } public function use_recipe( string $recipe_data ) : bool { $ingredients = explode( ';', $recipe_data ); // remove the last part (the result item) array_pop( $ingredients ); foreach ( $ingredients as $ingredient ) { $parts = explode( ':', $ingredient ); if ( count( $parts ) != 2 ) { continue; // invalid ingredient format } $item_code = $parts[0]; $quantity = intval( $parts[1] ); for ( $i = 0; $i < $quantity; $i ++ ) { $this->delete_itemcode_from_inventory( $item_code ); } } $result_item_code = Item_Definition::get_recipe_resulting_itemcode( $recipe_data ); $this->add_item_to_inventory( $result_item_code ); return true; // all ingredients used } public function has_collectible( string $collectible_code ): bool { // check if the character has the collectible in the inventory $inventory_items = $this->get_inventory(); foreach ( $inventory_items as $item ) { /** @var Item $item */ // check if the item slot is "Collectible" and the "UseData" stat matches the collectible_code $item_definition = Item_Definition::get_item_definition_by_code( $item->code ); $item_stats = $item_definition->get_stats(); if ( isset( $item_stats['Slot'] ) && $item_stats['Slot'] === 'Collectible' && isset( $item_stats['UseData'] ) && $item_stats['UseData'] === $collectible_code ) { return true; // collectible found } } return false; // collectible not found } public function use_collectible( string $collectible_code ): bool { // check if the character has the collectible in the inventory if( ! $this->has_collectible( $collectible_code ) ) { return false; // collectible not found } // the collectible will raise the game_var "bonus_<$collectible_code>" by 1 $var_name = 'bonus_' . $collectible_code; $current_value = intval( $this->get_game_var( $var_name ) ?? 0 ); $this->set_game_var( $var_name, $current_value + 1 ); // delete one instance of the collectible from the inventory $this->delete_itemcode_from_inventory( $collectible_code ); return true; // collectible used } public function has_bonus_from_collectibles( ): bool { // simply check if there is any game_var starting with "bonus_" and value > 0 foreach ( $this->get_game_vars() as $var_name => $value ) { if (str_starts_with($var_name, 'bonus_') && intval($value) > 0) { return true; } } return false; } /** Get the bonus from all collectibles. * returns array ["Animals" => 3, "Plants" => 2] for example * * @return array The total bonus from collectibles. */ public function get_bonus_from_collectibles( ): array { $bonuses = []; foreach ( $this->get_game_vars() as $var_name => $value ) { if (str_starts_with($var_name, 'bonus_') && intval($value) > 0) { $collectible_type = substr($var_name, strlen('bonus_')); $cards = $this->get_number_of_collectible_cards( $collectible_type ); // the value is the bonus, but it is not the amount of collected cards // the bonus is calculated as follows: 2^bonus = number of cards // 1 card = bonus + 1 // 4 cards = bonus + 2 // 8 cards = bonus + 3 $bonuses[$collectible_type] = $this->get_bonus($cards); } } return $bonuses; } public function get_number_of_cards_until_next_bonus( string $collectible_type ): int { $cards = $this->get_number_of_collectible_cards( $collectible_type ); $current_bonus = $this->get_bonus($cards); $next_bonus = $current_bonus + 1; // calculate the number of cards needed for the next bonus $cards_needed_for_next_bonus = pow(2, $next_bonus - 1); return $cards_needed_for_next_bonus - $cards; } /** Get the bonus for a specific collectible type. * * @param string $collectible_type The type of collectible (e.g., "Animals", "Plants"). * * @return float The bonus for the specified collectible type. */ public function get_bonus_for_collectible( string $collectible_type ): float { $cards = $this->get_number_of_collectible_cards( $collectible_type ); return $this->get_bonus($cards); } public function get_number_of_collectible_cards( string $collectible_type ): int { $var_name = 'bonus_' . $collectible_type; $value = intval( $this->get_game_var( $var_name ) ?? 0 ); return $value; } function get_bonus(int $cards): float { return log($cards, 2) + 1; } /** * Get the count of identical items in the inventory. * This is used for identifying how many items will be sold with the Sell all button. * * @param int $id The ID of the item to count identical items for. * * @return int The count of identical items in the inventory. */ public function get_identical_item_count_by_id(int $id): int { return count($this->get_identical_items_by_id($id)); } public function get_identical_items_by_id(int $id): array { $prev_item = Item::get_item_by_id( $id ); $identical_items = []; foreach ($this->get_inventory() as $item) { /* @var Item $item */ if ($item->is_identical_to_item($prev_item)) { $identical_items[] = $item; } } return $identical_items; } public function get_item_count_by_code( string $code, bool $exclude_equipped = true): int { /** @var Item $inventory_item */ $count = 0; foreach ( $this->get_inventory($exclude_equipped) as $inventory_item ) { if ( $inventory_item->code == $code ) { // if this is a resource item, we count its Charges stat instead of just 1 $item_definition = Item_Definition::get_item_definition_by_code( $inventory_item->code ); $item_stats = $item_definition->get_stats(); if ( isset( $item_stats['Slot'] ) && $item_stats['Slot'] === 'Resource' ) { $charges = intval( $inventory_item->get_stat('Charges') ?? 1 ); $count += $charges; } else { $count ++; } // continue to count all items with the same code } } return $count; } /** * Get all items in the inventory as an array of Item objects * these are entries in the adventurebook_items table with character_id = this character's id and location_id = 0 * * @return array An array of Item objects in the inventory */ public function get_inventory( $exclude_equipped = true ): array { $all_items_in_inventory = get_items_for_page( 0, $this->id ); // get all item_ids in the inventory $all_item_ids_in_inventory = array_map( function ( $item ) { return $item->id; }, $all_items_in_inventory ); if ( $exclude_equipped ) { $equipped_item_ids = $this->get_equipped_item_ids(); // filter out equipped items from the inventory $inventory_items = array_filter( $all_item_ids_in_inventory, function ( $item_id ) use ( $equipped_item_ids ) { return ! in_array( $item_id, $equipped_item_ids ); } ); // convert item ids to Item objects return array_map( function ( $item_id ) { return Item::get_item_by_id( $item_id ); }, $inventory_items ); } return $all_items_in_inventory; } // endregion Inventory Management public function add_xp( int $xp ): string { // if the character has no "XP" stat, we cannot add XP if ( ! isset( $this->stats['XP'] ) || $xp <= 0 ) { return ''; } $this->stats['XP'] += $xp; $this->save_to_db(); /* translators: %d is the amount of XP gained */ return esc_html( sprintf( __( 'You gain %d XP!', 'adventurebook' ), $xp ) ); } /** Adds wealth to the creature's inventory * Usage: add_wealth amount * e.g. add_wealth 10 gold */ public function add_wealth( int $amount, string $unit = '' ): string { // if the character has no "Wealth" stat, we cannot add wealth if ( ! isset( $this->stats['Wealth'] ) || $amount <= 0 ) { return ''; } $this->stats['Wealth'] += $amount; $this->save_to_db(); /* translators: %1$d is the amount of wealth gained, %2$s is the unit (e.g. gold) */ return esc_html( sprintf( __( 'You gain %1$d %2$s!', 'adventurebook' ), $amount, $unit ) ); } /** Removes wealth from the creature's inventory * Usage: removeWealth amount [unit] * e.g. removeWealth 10 gold */ public function remove_wealth( int $amount, string $unit = '' ): string { // if the character has no "Wealth" stat, we cannot remove wealth if ( ! isset( $this->stats['Wealth'] ) || $amount <= 0 ) { return ''; } // we try to subtract the money from the character's wealth first, // then from the bank if necessary (game_var "bank_balance") $total_wealth = $this->get_wealth(); $local_wealth = intval( $this->get_stat( 'Wealth' ) ); if ( $total_wealth < $amount ) { // not enough money $amount = $total_wealth; // remove all money } if ( $local_wealth >= $amount ) { // enough money in the character's wealth $this->set_stat('Wealth', $local_wealth - $amount); } else { // not enough money in the character's wealth, we remove what we can $amount_from_wealth = $local_wealth; $this->stats['Wealth'] = 0; $amount_from_bank = $amount - $amount_from_wealth; $bank_balance = intval( $this->get_game_var( 'bank_balance' ) ); $bank_balance -= $amount_from_bank; $this->set_game_var( 'bank_balance', $bank_balance ); } $this->save_to_db(); /* translators: %1$d is the amount of wealth lost, %2$s is the unit (e.g. gold) */ return esc_html( sprintf( __( 'You lose %1$d %2$s!', 'adventurebook' ), $amount, $unit ) ); } #region Game Variables Management /** Get a game variable by its name * Game variables are stored in the stats array under the key "VARS" as an associative array * If the variable does not exist, an empty string is returned * * @param string $var_name The name of the variable to get * * @return mixed The value of the variable, or an empty string if it does not exist */ public function get_game_var( string $var_name ) { $vars = $this->stats['VARS'] ?? []; if ( is_array( $vars ) && array_key_exists( $var_name, $vars ) ) { return $vars[ $var_name ]; } return ''; } /** Set a game variable by its name * Game variables are stored in the stats array under the key "VARS" as an associative array * * @param string $var_name The name of the variable to set * @param mixed $value The value to set the variable to * * @return void */ public function set_game_var( string $var_name, $value ): void { $vars = $this->stats['VARS'] ?? []; if ( ! is_array( $vars ) ) { $vars = []; } $vars[ $var_name ] = $value; $this->stats['VARS'] = $vars; $this->save_to_db(); } public function get_game_vars(): array { return $this->stats['VARS'] ?? []; } #endregion #region Skills Management /** * Gets a skill by it's code. * * A skill is defined in the "SKILLS" stat with the following structure: * * "SKILLS" => [ * "Climbing" => ["value" => 3, "cooldown" => 0], * "Stealth" => ["value" => 5, "cooldown" => 1], * ] * * @param string $skill_code * * @return array|null The skill array with 'value' and 'cooldown' keys, or null if the skill does not exist */ public function get_skill( string $skill_code ): ?array { $skills = $this->stats['SKILLS'] ?? []; if ( is_array( $skills ) && array_key_exists( $skill_code, $skills ) ) { return $skills[ $skill_code ]; } return null; } public function get_skills(): array { return $this->stats['SKILLS'] ?? []; } /** Get a specific skill value by code. * Returns 0 if the skill does not exist. * * Skills are saved in as an array value in stats with the key "SKILLS" * * @param string $skill_code The code of the skill to retrieve (all lowercase) * * @return int The value of the skill, or 0 if it does not exist */ public function get_skill_value( string $skill_code ): int { $skill = $this->get_skill( $skill_code ); $effect_bonus = 0; if ( $skill && array_key_exists( 'value', $skill ) ) { // check for certain effects // if the combatant is drunk, add 1 to climbing skill if ( $skill_code === 'climbing' ) { $effect_bonus += $this->get_effect_power_by_type( Adventurebook_Effect_Types::DRUNK ); } return intval( $skill['value'] ) + $effect_bonus; } return 0; } /** Get a specific skill cooldown by name (code). * Returns 0 if the skill does not exist or has no cooldown. * * Skills are saved in as an array value in stats with the key "SKILLS" * * @param string $skill_name The name of the skill to retrieve. * * @return int The cooldown of the skill, or 0 if it does not exist */ public function get_skill_cooldown( string $skill_name ): int { $skill = $this->get_skill( $skill_name ); if ( $skill && array_key_exists( 'cooldown', $skill ) ) { return intval( $skill['cooldown'] ); } return 0; } /** Set a specific skill by name. * If the skill does not exist, it will be created. * * Skills are saved in as an array value in stats with the key "SKILLS" * * @param string $skill_name The name of the skill to set. * @param int $amount The value to set the skill to. * * @return void */ public function set_skill( string $skill_name, int $amount ): void { $skills = $this->stats['SKILLS'] ?? []; if ( ! is_array( $skills ) ) { $skills = []; } $skills[ $skill_name ] = ['value' => $amount, 'cooldown' => 0]; $this->stats['SKILLS'] = $skills; $this->save_to_db(); } public function reduce_skill_cooldowns(): void { $skills = $this->stats['SKILLS'] ?? []; if ( ! is_array( $skills ) ) { $skills = []; } foreach ( $skills as $skill_name => $skill_data ) { if ( array_key_exists( 'cooldown', $skill_data ) && $skill_data['cooldown'] > 0 ) { $skills[ $skill_name ]['cooldown'] = max( 0, $skill_data['cooldown'] - 1 ); } } $this->stats['SKILLS'] = $skills; $this->save_to_db(); } /** sets the character cooldown for a skill to the cooldown defined in the combat system * * @param string $skill_name The name of the skill to set. * * @return void */ public function set_skill_cooldown( string $skill_name ): void { $combat_system = adventurebook_get_combat_system_for_character_id( $this->id ); $skill_definition = $combat_system->get_skill_by_code( $skill_name ); $cooldown = $skill_definition["cooldown"] ?? 0; $skills = $this->stats['SKILLS'] ?? []; if ( ! is_array( $skills ) ) { $skills = []; } if ( array_key_exists( $skill_name, $skills ) ) { $skills[ $skill_name ]['cooldown'] = max( 0, $cooldown ); } $this->stats['SKILLS'] = $skills; $this->save_to_db(); } /** Check if a skill can be raised. * * The combat system sets the rules for raising skills. * * @param string $skill_name The name of the skill to check. * * @return bool True if the skill can be raised, false otherwise. */ public function can_raise_skill( string $skill_name ): bool { // we will ask the Combat System if the skill can be raised $combat_system = adventurebook_get_combat_system_for_character_id( $this->id ); return $combat_system->combatant_can_raise_skill( $this, $skill_name ); } #endregion Skills Management #region Combat Values public function get_attack_value(): int { return $this->get_total_stat_including_items( 'Attack' ); } public function get_defense_value(): int { return $this->get_total_stat_including_items( 'Defense' ); } public function get_initiative_value(): int { return $this->get_total_stat_including_items( 'Initiative' ); } public function get_damage_value(): int { return $this->get_total_stat_including_items( 'Damage' ); } public function get_armor_value(): int { return $this->get_total_stat_including_items( 'Armor' ); } /** Get the total combat strength of the combatant. * This is the sum of Attack, Damage, Initiative, Defense and Armor values. * * It is used for comparing combatants in combat. * * @return int The total combat strength. */ public function get_combat_strength() : int { return $this->get_attack_value() + $this->get_damage_value() + $this->get_initiative_value() + $this->get_defense_value() + $this->get_armor_value(); } #endregion #region actions /** Check if the character is currently working (game_var "working" = "1") * This is used for any jobs that may be interrupted by combat or travelling. * Harvesting is also considered working and additionally checked with is_harvesting(). * * @return bool True if the character is working, false otherwise. */ public function is_working() : bool { return $this->get_game_var("working") === "1"; } public function stop_working() : void { $this->set_game_var('working', '0'); if( $this->is_harvesting() ) { $this->stop_harvesting(); } } public function start_working() : void { $this->set_game_var('working', '1'); } public function is_harvesting() : bool { return $this->get_game_var("harvesting") === "1"; } public function start_harvesting() : void { $this->start_working(); $this->set_game_var('harvesting', '1'); $this->set_game_var('harvesting_start_time', strval(time())); $this->set_game_var('harvesting_resource', "wood"); } public function stop_harvesting() : void { $harvesting_start = intval($this->get_game_var('harvesting_start_time')); $harvesting_resource = $this->get_game_var('harvesting_resource'); if ($harvesting_start <= 0 || !$harvesting_resource) { adventurebook_get_page_content_ajax(); return; } // calculate how much resource has been harvested $adventurebook_combat_system = adventurebook_get_combat_system_for_adventurebook($this->adventurebook_id); $resource_yield_1h = $adventurebook_combat_system->get_resource_yield_for_page($this->location_id, $harvesting_resource); // the resource yield is at least 1, else harvesting couldn't have been started // calculate how much percent of 1 hour have passed since harvesting started $now = time(); $seconds_passed = $now - $harvesting_start; // cap percent_passed at 1.0 (1 hour) $percent_passed = min(1.0, $seconds_passed / (60 * 60)); $yield = intval(floor($resource_yield_1h * $percent_passed)); if ($yield >= 1) { // the yield is limited by $resource_yield_1h and by the harvesting skill of the character // if the resource is "wood" we check the skill "woodcutting", if "stone" -> "mining" $harvesting_skill_code = $adventurebook_combat_system->get_skill_code_for_harvesting_resource($harvesting_resource); $harvesting_skill_level = intval($this->get_skill_value($harvesting_skill_code)); // the final yield capped by skill_level * 10 (with skill 4, max yield is 40) $max_yield_by_skill = $harvesting_skill_level * 10; if ($max_yield_by_skill > 0 && $yield > $max_yield_by_skill) { $yield = $max_yield_by_skill; } // create a new item for the harvested resource and add it to the character's location $item_definition = Item_Definition::get_item_definition_by_code($harvesting_resource); if ($item_definition) { $item = Item::create_item($this->id, $harvesting_resource, $this->location_id); $item->set_charges($yield); } } // clear harvesting game vars $this->set_game_var('harvesting', '0'); $this->set_game_var('harvesting_start_time', '0'); $this->set_game_var('harvesting_resource', ''); } #endregion }