* int - operator defaults to: = * array - operator defaults to: IN () * null - operator defaults to: IS [NULL] * operator: modifies/overrides default * ! - negated default value (NOT LIKE; <>; NOT IN) * condition as str * defines linking (AND || OR) * condition as int * defines LIMIT * * example: * array( * ['id', 45], * ['name', 'test%', '!'], * [ * 'AND', * ['flags', 0xFF, '&'], * ['flags2', 0xF, '&'], * ] * [['mask', 0x3, '&'], 0], * ['joinedTbl.field', NULL] // NULL must be explicitly specified "['joinedTbl.field']" would be skipped as erronous definition (only really usefull when left-joining) * 'OR', * 5 * ) * results in * WHERE ((`id` = 45) OR (`name` NOT LIKE "test%") OR ((`flags` & 255) AND (`flags2` & 15)) OR ((`mask` & 3) = 0)) OR (`joinedTbl`.`field` IS NULL) LIMIT 5 */ public function __construct(array $conditions = [], array $miscData = []) { $where = []; $linking = ' AND '; $limit = Cfg::get('SQL_LIMIT_DEFAULT'); $calcTotal = false; $totalQuery = ''; if (!$this->queryBase || $conditions === null) return; $prefixes = []; if (preg_match('/FROM \??[\w\_]+( AS)?\s?`?(\w+)`?$/i', $this->queryBase, $match)) $prefixes['base'] = $match[2]; else $prefixes['base'] = ''; if (!empty($miscData['extraOpts'])) $this->extendQueryOpts($miscData['extraOpts']); if (!empty($miscData['calcTotal'])) $calcTotal = true; $resolveCondition = function ($c, $supLink) use (&$resolveCondition, &$prefixes) { $subLink = ''; if (!$c) return null; foreach ($c as $foo) { if ($foo === 'AND') $subLink = ' AND '; else if ($foo === 'OR') // nessi-bug: if (0 == 'OR') was true once... w/e $subLink = ' OR '; } // need to manually set link for subgroups to be recognized as condition set if ($subLink) { $sql = []; foreach ($c as $foo) if (is_array($foo)) if ($x = $resolveCondition($foo, $supLink)) $sql[] = $x; return $sql ? '('.implode($subLink, $sql).')' : null; } else { if ($c[0] == '1') return '1'; else if ($c[0] == '0') return '(0)'; // trick if ($x = 0) into true... else if (is_array($c[0]) && isset($c[1])) $field = $resolveCondition($c[0], $supLink); else if ($c[0]) { $setPrefix = function($f) use(&$prefixes) { if (is_array($f)) $f = $f[0]; // numeric allows for formulas e.g. (1 < 3) if (Util::checkNumeric($f)) return $f; // skip condition if fieldName contains illegal chars if (preg_match('/[^\d\w\.\_]/i', $f)) return null; $f = explode('.', $f); switch (count($f)) { case 2: if (!in_array($f[0], $prefixes)) { // choose table to join or return null if prefix does not exist if (!in_array($f[0], array_keys($this->queryOpts))) return null; $prefixes[] = $f[0]; } return '`'.$f[0].'`.`'.$f[1].'`'; case 1: return '`'.$prefixes['base'].'`.`'.$f[0].'`'; default: return null; } }; // basic formulas if (preg_match('/^\([\s\+\-\*\/\w\(\)\.]+\)$/i', strtr($c[0], ['`' => '', '´' => '', '--' => '']))) $field = preg_replace_callback('/[\w\]*\.?[\w]+/i', $setPrefix, $c[0]); else $field = $setPrefix($c[0]); if (!$field) return null; } else return null; if (is_array($c[1]) && !empty($c[1])) { array_walk($c[1], function(&$item, $key) { $item = Util::checkNumeric($item) ? $item : DB::Aowow()->escape($item); }); $op = (isset($c[2]) && $c[2] == '!') ? 'NOT IN' : 'IN'; $val = '('.implode(', ', $c[1]).')'; } else if (Util::checkNumeric($c[1])) // Note: should this be a NUM_REQ_* check? { $op = (isset($c[2]) && $c[2] == '!') ? '<>' : '='; $val = $c[1]; } else if (is_string($c[1])) { $op = (isset($c[2]) && $c[2] == '!') ? 'NOT LIKE' : 'LIKE'; $val = DB::Aowow()->escape($c[1]); } else if (count($c) > 1 && $c[1] === null) // specifficly check for NULL { $op = (isset($c[2]) && $c[2] == '!') ? 'IS NOT' : 'IS'; $val = 'NULL'; } else // null for example return null; if (isset($c[2]) && $c[2] != '!') $op = $c[2]; return '('.$field.' '.$op.' '.$val.')'; } }; foreach ($conditions as $i => $c) { switch (getType($c)) { case 'array': break; case 'string': case 'integer': if (is_string($c)) $linking = $c == 'AND' ? ' AND ' : ' OR '; else $limit = $c > 0 ? $c : 0; default: unset($conditions[$i]); } } foreach ($conditions as $c) if ($x = $resolveCondition($c, $linking)) $where[] = $x; // optional query parts may require other optional parts to work foreach ($prefixes as $pre) if (isset($this->queryOpts[$pre][0])) foreach ($this->queryOpts[$pre][0] as $req) if (!in_array($req, $prefixes)) $prefixes[] = $req; // remove optional query parts, that are not required foreach ($this->queryOpts as $k => $arr) if (!in_array($k, $prefixes)) unset($this->queryOpts[$k]); // prepare usage of guids if using multiple realms (which have non-zoro indizes) if (key($this->dbNames) != 0) $this->queryBase = preg_replace('/\s([^\s]+)\sAS\sARRAY_KEY/i', ' CONCAT("DB_IDX", ":", \1) AS ARRAY_KEY', $this->queryBase); // insert additional selected fields if ($s = array_column($this->queryOpts, 's')) $this->queryBase = str_replace('ARRAY_KEY', 'ARRAY_KEY '.implode('', $s), $this->queryBase); // append joins if ($j = array_column($this->queryOpts, 'j')) foreach ($j as $_) $this->queryBase .= is_array($_) ? (empty($_[1]) ? ' JOIN ' : ' LEFT JOIN ').$_[0] : ' JOIN '.$_; // append conditions if ($where) $this->queryBase .= ' WHERE ('.implode($linking, $where).')'; // append grouping if ($g = array_filter(array_column($this->queryOpts, 'g'))) $this->queryBase .= ' GROUP BY '.implode(', ', $g); // append post filtering if ($h = array_filter(array_column($this->queryOpts, 'h'))) $this->queryBase .= ' HAVING '.implode(' AND ', $h); // without applied LIMIT and ORDER if ($calcTotal) $totalQuery = $this->queryBase; // append ordering if ($o = array_filter(array_column($this->queryOpts, 'o'))) $this->queryBase .= ' ORDER BY '.implode(', ', $o); // apply limit if ($limit) $this->queryBase .= ' LIMIT '.$limit; // execute query (finally) $rows = []; // this is purely because of multiple realms per server foreach ($this->dbNames as $dbIdx => $n) { $query = str_replace('DB_IDX', $dbIdx, $this->queryBase); if ($rows = DB::{$n}($dbIdx)->select($query)) { if ($calcTotal) { // hackfix the inner items query to not contain duplicate column names // yes i know the real solution would be to not have items and item_stats share column names // soon™.... if (get_class($this) == __NAMESPACE__.'\ItemList') $totalQuery = str_replace([', `is`.*', ', i.id AS id'], '', $totalQuery); $this->matches += DB::{$n}($dbIdx)->selectCell('SELECT COUNT(*) FROM ('.$totalQuery.') x'); } foreach ($rows as $id => $row) { if (isset($this->templates[$id])) trigger_error('GUID for List already in use #'.$id.'. Additional occurrence omitted!', E_USER_ERROR); else $this->templates[$id] = $row; } } } if (!$this->templates) return; // push first element for instant use $this->reset(); // all clear $this->error = false; } public function &iterate() { $this->itrStack[] = $this->id; // reset on __construct $this->reset(); foreach ($this->templates as $id => $__) { $this->id = $id; $this->curTpl = &$this->templates[$id]; // do not use $tpl from each(), as we want to be referenceable yield $id => $this->curTpl; unset($this->curTpl); // kill reference or it will 'bleed' into the next iteration } // fforward to old index $this->reset(); $oldIdx = array_pop($this->itrStack); do { if (key($this->templates) != $oldIdx) continue; $this->curTpl = current($this->templates); $this->id = key($this->templates); next($this->templates); break; } while (next($this->templates)); } protected function reset() { unset($this->curTpl); // kill reference or strange stuff will happen $this->curTpl = reset($this->templates); $this->id = key($this->templates); } // read-access to templates public function getEntry($id) { if (isset($this->templates[$id])) { unset($this->curTpl); // kill reference or strange stuff will happen $this->curTpl = $this->templates[$id]; $this->id = $id; return $this->templates[$id]; } return null; } public function getField($field, $localized = false, $silent = false) { if (!$this->curTpl || (!$localized && !isset($this->curTpl[$field]))) return ''; if ($localized) return Util::localizedString($this->curTpl, $field, $silent); $value = $this->curTpl[$field]; Util::checkNumeric($value); return $value; } public function getAllFields($field, $localized = false, $silent = false) { $data = []; foreach ($this->iterate() as $__) $data[$this->id] = $this->getField($field, $localized, $silent); return $data; } public function getRandomId() : int { // ORDER BY RAND() is not optimal, so if anyone has an alternative idea.. $where = User::isInGroup(U_GROUP_EMPLOYEE) ? ' WHERE (`cuFlags` & '.CUSTOM_EXCLUDE_FOR_LISTVIEW.') = 0' : ''; if (preg_match('/SELECT .*? FROM (\?\_[\w_-]+) /i', $this->queryBase, $m)) return DB::Aowow()->selectCell(sprintf('SELECT `id` FROM %s%s ORDER BY RAND() ASC LIMIT 1', $m[1], $where)); return 0; } public function getFoundIDs() { return array_keys($this->templates); } public function getMatches() { return $this->matches; } protected function extendQueryOpts($extra) // needs to be called from __construct { foreach ($extra as $tbl => $sets) { foreach ($sets as $module => $value) { if (!$value || !is_array($value)) continue; switch ($module) { // additional (str) case 'g': // group by case 's': // select if (!empty($this->queryOpts[$tbl][$module])) $this->queryOpts[$tbl][$module] .= implode(' ', $value); else $this->queryOpts[$tbl][$module] = implode(' ', $value); break; case 'h': // having if (!empty($this->queryOpts[$tbl][$module])) $this->queryOpts[$tbl][$module] .= implode(' AND ', $value); else $this->queryOpts[$tbl][$module] = implode(' AND ', $value); break; // additional (arr) case 'j': // join if (!empty($this->queryOpts[$tbl][$module]) && is_array($this->queryOpts[$tbl][$module])) $this->queryOpts[$tbl][$module][0][] = $value; else $this->queryOpts[$tbl][$module] = $value; break; // replacement (str) case 'l': // limit case 'o': // order by $this->queryOpts[$tbl][$module] = $value[0]; break; } } } } /* source More .. keys seen used 'n': name [always set] 't': type [always set] 'ti': typeId [always set] 'bd': BossDrop [0; 1] [Creature / GO] 'dd': DungeonDifficulty [-2: DungeonHC; -1: DungeonNM; 1: Raid10NM; 2:Raid25NM; 3:Raid10HM; 4: Raid25HM] [Creature / GO] 'q': cssQuality [Items] 'z': zone [set when all happens in here] 'p': PvP [pvpSourceId] 's': Type::TITLE: side; Type::SPELL: skillId (yeah, double use. Ain't life just grand) 'c': category [Spells / Quests] 'c2': subCat [Quests] 'icon': iconString */ public function getSourceData(int $id = 0) : array { return []; } // should return data required to display a listview of any kind // this is a rudimentary example, that will not suffice for most Types abstract public function getListviewData(); // should return data to extend global js variables for a certain type (e.g. g_items) abstract public function getJSGlobals($addMask = GLOBALINFO_ANY); // NPC, GO, Item, Quest, Spell, Achievement, Profile would require this abstract public function renderTooltip(); } trait listviewHelper { public function hasSetFields(?string ...$fields) : int { $result = 0x0; foreach ($this->iterate() as $__) { foreach ($fields as $k => $str) { if (!$str) { unset($fields[$k]); continue; } if ($this->getField($str)) { $result |= 1 << $k; unset($fields[$k]); } } if (empty($fields)) // all set .. return early { $this->reset(); // Generators have no __destruct, reset manually, when not doing a full iteration return $result; } } return $result; } public function hasDiffFields(?string ...$fields) : int { $base = []; $result = 0x0; foreach ($fields as $k => $str) $base[$str] = $this->getField($str); foreach ($this->iterate() as $__) { foreach ($fields as $k => $str) { if (!$str) { unset($fields[$k]); continue; } if ($base[$str] != $this->getField($str)) { $result |= 1 << $k; unset($fields[$k]); } } if (empty($fields)) // all fields diff .. return early { $this->reset(); // Generators have no __destruct, reset manually, when not doing a full iteration return $result; } } return $result; } public function hasAnySource() { if (!isset($this->sources)) return false; foreach ($this->sources as $src) { if (!is_array($src)) continue; if (!empty($src)) return true; } return false; } } /* !IMPORTANT! It is flat out impossible to distinguish between floors for multi-level areas, if the floors overlap each other! The coordinates generated by the script WILL be on every level and will have to be removed MANUALLY! impossible := you are not keen on reading wmo-data; */ trait spawnHelper { private $spawnResult = array( SPAWNINFO_FULL => null, SPAWNINFO_SHORT => null, SPAWNINFO_ZONES => null, SPAWNINFO_QUEST => null ); private function createShortSpawns() // [zoneId, floor, [[x1, y1], [x2, y2], ..]] as tooltip2 if enabled by or anchor #map (one area, one floor, one creature, no survivors) { $this->spawnResult[SPAWNINFO_SHORT] = new \StdClass; // first get zone/floor with the most spawns if ($res = DB::Aowow()->selectRow('SELECT `areaId`, `floor` FROM ?_spawns WHERE `type` = ?d AND `typeId` = ?d AND `posX` > 0 AND `posY` > 0 GROUP BY `areaId`, `floor` ORDER BY COUNT(1) DESC LIMIT 1', self::$type, $this->id)) { // get relevant spawn points $points = DB::Aowow()->select('SELECT `posX`, `posY` FROM ?_spawns WHERE `type` = ?d AND `typeId` = ?d AND `areaId` = ?d AND `floor` = ?d AND `posX` > 0 AND `posY` > 0', self::$type, $this->id, $res['areaId'], $res['floor']); $spawns = []; foreach ($points as $p) $spawns[] = [$p['posX'], $p['posY']]; $this->spawnResult[SPAWNINFO_SHORT]->zone = $res['areaId']; $this->spawnResult[SPAWNINFO_SHORT]->coords = [$res['floor'] => $spawns]; } } private function createFullSpawns() // for display on map (object/npc detail page) { $data = []; $wpSum = []; $wpIdx = 0; $worldPos = []; $spawns = DB::Aowow()->select("SELECT * FROM ?_spawns WHERE `type` = ?d AND `typeId` = ?d AND `posX` > 0 AND `posY` > 0", self::$type, $this->id); if (!$spawns) return; if (User::isInGroup(U_GROUP_MODERATOR)) if ($guids = array_column(array_filter($spawns, fn($x) => $x['guid'] > 0 || $x['type'] != Type::NPC), 'guid')) $worldPos = WorldPosition::getForGUID(self::$type, ...$guids); foreach ($spawns as $s) { $isAccessory = $s['guid'] < 0 && $s['type'] == Type::NPC; // check, if we can attach waypoints to creature // we will get a nice clusterfuck of dots if we do this for more GUIDs, than we have colors though if (count($spawns) < 6 && $s['type'] == Type::NPC) { if ($wPoints = DB::Aowow()->select('SELECT * FROM ?_creature_waypoints WHERE creatureOrPath = ?d AND floor = ?d', $s['pathId'] ? -$s['pathId'] : $this->id, $s['floor'])) { foreach ($wPoints as $i => $p) { $label = [Lang::npc('waypoint').Lang::main('colon').$p['point']]; if ($p['wait']) $label[] = Lang::npc('wait').Lang::main('colon').Util::formatTime($p['wait'], false); $opts = array( // \0 doesn't get printed and tricks Util::toJSON() into handling this as a string .. i feel slightly dirty now 'label' => "\0$
".implode('
', $label).'
', 'type' => $wpIdx ); // connective line if ($i > 0 && $wPoints[$i - 1]['areaId'] == $p['areaId']) $opts['lines'] = [[$wPoints[$i - 1]['posX'], $wPoints[$i - 1]['posY']]]; $data[$p['areaId']][$p['floor']]['coords'][] = [$p['posX'], $p['posY'], $opts]; if (empty($wpSum[$p['areaId']][$p['floor']])) $wpSum[$p['areaId']][$p['floor']] = 1; else $wpSum[$p['areaId']][$p['floor']]++; } $wpIdx++; } } $opts = $menu = $tt = $info = []; $footer = ''; if ($s['respawn'] > 0) $info[1] = ''.Lang::npc('respawnIn', [Lang::formatTime($s['respawn'] * 1000, 'game', 'timeAbbrev', true)]).''; else if ($s['respawn'] < 0) { $info[1] = ''.Lang::npc('despawnAfter', [Lang::formatTime(-$s['respawn'] * 1000, 'game', 'timeAbbrev', true)]).''; $opts['type'] = 4; // make pip purple } if (User::isInGroup(U_GROUP_STAFF)) { if ($isAccessory) $info[0] = 'Vehicle Accessory'; else if ($s['guid'] > 0 && ($s['type'] == Type::NPC || $s['type'] == Type::OBJECT)) $info[0] = 'GUID'.Lang::main('colon').$s['guid']; if ($s['phaseMask'] > 1 && ($s['phaseMask'] & 0xFFFF) != 0xFFFF) $info[2] = Lang::game('phases').Lang::main('colon').Util::asHex($s['phaseMask']); if ($s['spawnMask'] == 15) $info[3] = Lang::game('mode').Lang::main('colon').Lang::game('modes', -1); else if ($s['spawnMask']) { $_ = []; for ($i = 0; $i < 4; $i++) if ($s['spawnMask'] & 1 << $i) $_[] = Lang::game('modes', $i); $info[4] = Lang::game('mode').Lang::main('colon').implode(', ', $_); } if ($s['type'] == Type::AREATRIGGER) { // teleporter endpoint if ($s['guid'] < 0) { $opts['type'] = 4; $info[5] = 'Teleport Destination'; } else { $o = Util::O2Deg($this->getField('orientation')); $info[5] = 'Orientation'.Lang::main('colon').$o[0].'° ('.$o[1].')'; } } // guid < 0 are vehicle accessories. those are moved by moving the vehicle if (User::isInGroup(U_GROUP_MODERATOR) && $worldPos && !$isAccessory && isset($worldPos[$s['guid']])) $menu = Util::buildPosFixMenu($worldPos[$s['guid']]['mapId'], $worldPos[$s['guid']]['posX'], $worldPos[$s['guid']]['posY'], $s['type'], $s['guid'], $s['areaId'], $s['floor']); if ($menu) $footer = '
Click to move pin'; } if ($info) $tt['info'] = $info; if ($footer) $tt['footer'] = $footer; if ($tt) $opts['tooltip'] = [$this->getField('name', true) => $tt]; if ($menu) $opts['menu'] = $menu; $data[$s['areaId']] [$s['floor']] ['coords'] [] = [$s['posX'], $s['posY'], $opts]; } foreach ($data as $a => &$areas) foreach ($areas as $f => &$floor) $floor['count'] = count($floor['coords']) - (!empty($wpSum[$a][$f]) ? $wpSum[$a][$f] : 0); uasort($data, array($this, 'sortBySpawnCount')); $this->spawnResult[SPAWNINFO_FULL] = $data; } private function sortBySpawnCount($a, $b) { $aCount = current($a)['count']; $bCount = current($b)['count']; if ($aCount == $bCount) { return 0; } return ($aCount < $bCount) ? 1 : -1; } private function createZoneSpawns() // [zoneId1, zoneId2, ..] for locations-column in listview { $res = DB::Aowow()->selectCol("SELECT `typeId` AS ARRAY_KEY, GROUP_CONCAT(DISTINCT `areaId`) FROM ?_spawns WHERE `type` = ?d AND `typeId` IN (?a) AND `posX` > 0 AND `posY` > 0 GROUP BY `typeId`", self::$type, $this->getfoundIDs()); foreach ($res as &$r) { $r = explode(',', $r); if (count($r) > 3) array_splice($r, 3, count($r), -1); } $this->spawnResult[SPAWNINFO_ZONES] = $res; } private function createQuestSpawns() // [zoneId => [floor => [[x1, y1], [x2, y2], ..]]] mapper on quest detail page { if (self::$type == Type::SOUND) return; $res = DB::Aowow()->select('SELECT `areaId`, `floor`, `typeId`, `posX`, `posY` FROM ?_spawns WHERE `type` = ?d AND `typeId` IN (?a) AND `posX` > 0 AND `posY` > 0', self::$type, $this->getFoundIDs()); $spawns = []; foreach ($res as $data) { // zone => floor => spawnData // todo (low): why is there a single set of coordinates; which one should be picked, instead of the first? gets used in ShowOnMap.buildTooltip i think if (!isset($spawns[$data['areaId']][$data['floor']][$data['typeId']])) { $spawns[$data['areaId']][$data['floor']][$data['typeId']] = array( 'type' => self::$type, 'id' => $data['typeId'], 'point' => '', // tbd later (start, end, requirement, sourcestart, sourceend, sourcerequirement) 'name' => Util::localizedString($this->templates[$data['typeId']], 'name'), 'coord' => [$data['posX'], $data['posY']], 'coords' => [[$data['posX'], $data['posY']]], 'objective' => 0, // tbd later (1-4 set a color; id of creature this entry gives credit for) 'reactalliance' => $this->templates[$data['typeId']]['A'] ?: 0, 'reacthorde' => $this->templates[$data['typeId']]['H'] ?: 0 ); } else $spawns[$data['areaId']][$data['floor']][$data['typeId']]['coords'][] = [$data['posX'], $data['posY']]; } $this->spawnResult[SPAWNINFO_QUEST] = $spawns; } public function getSpawns($mode) { // only Creatures, GOs and SoundEmitters can be spawned if (!self::$type || !$this->getfoundIDs() || (self::$type != Type::NPC && self::$type != Type::OBJECT && self::$type != Type::SOUND && self::$type != Type::AREATRIGGER)) return []; switch ($mode) { case SPAWNINFO_SHORT: if ($this->spawnResult[SPAWNINFO_SHORT] === null) $this->createShortSpawns(); return $this->spawnResult[SPAWNINFO_SHORT]; case SPAWNINFO_FULL: if (empty($this->spawnResult[SPAWNINFO_FULL])) $this->createFullSpawns(); return $this->spawnResult[SPAWNINFO_FULL]; case SPAWNINFO_ZONES: if (empty($this->spawnResult[SPAWNINFO_ZONES])) $this->createZoneSpawns(); return !empty($this->spawnResult[SPAWNINFO_ZONES][$this->id]) ? $this->spawnResult[SPAWNINFO_ZONES][$this->id] : []; case SPAWNINFO_QUEST: if (empty($this->spawnResult[SPAWNINFO_QUEST])) $this->createQuestSpawns(); return $this->spawnResult[SPAWNINFO_QUEST]; } return []; } } trait profilerHelper { public static $type = 0; // arena teams dont actually have one public static $brickFile = 'profile'; // profile is multipurpose private static $subjectGUID = 0; public function selectRealms($fi) { $this->dbNames = []; foreach(Profiler::getRealms() as $idx => $r) { if (!empty($fi['sv']) && Profiler::urlize($r['name']) != Profiler::urlize($fi['sv']) && intVal($fi['sv']) != $idx) continue; if (!empty($fi['rg']) && Profiler::urlize($r['region']) != Profiler::urlize($fi['rg'])) continue; $this->dbNames[$idx] = 'Characters'; } return !!$this->dbNames; } } trait sourceHelper { protected $sources = []; protected $sourceMore = null; public function getSources(?array &$s = [], ?array &$sm = []) : bool { $s = $sm = []; if (empty($this->sources[$this->id])) return false; if ($this->sourceMore === null) { $buff = []; $this->sourceMore = []; foreach ($this->iterate() as $_curTpl) if ($_curTpl['moreType'] && $_curTpl['moreTypeId']) $buff[$_curTpl['moreType']][] = $_curTpl['moreTypeId']; foreach ($buff as $type => $ids) $this->sourceMore[$type] = Type::newList($type, [Cfg::get('SQL_LIMIT_NONE'), ['id', $ids]]); } $s = array_keys($this->sources[$this->id]); if ($this->curTpl['moreType'] && $this->curTpl['moreTypeId'] && ($srcData = $this->sourceMore[$this->curTpl['moreType']]->getSourceData($this->curTpl['moreTypeId']))) $sm = $srcData[$this->curTpl['moreTypeId']]; else if (!empty($this->sources[$this->id][SRC_PVP])) $sm['p'] = $this->sources[$this->id][SRC_PVP][0]; if ($z = $this->curTpl['moreZoneId']) $sm['z'] = $z; if ($this->curTpl['moreMask'] & SRC_FLAG_BOSSDROP) $sm['bd'] = 1; if (isset($this->sources[$this->id][SRC_DROP][0])) { /* mode srcFlag log2 dd Flag 10N/D-NH 0b0001 0 0b001 25N/D-HC 0b0010 1 0b010 10H 0b0100 2 0b011 25H 0b1000 3 0b100 */ if ($this->curTpl['moreMask'] & SRC_FLAG_DUNGEON_DROP) $sm['dd'] = $this->sources[$this->id][SRC_DROP][0] * -1; else if ($this->curTpl['moreMask'] & SRC_FLAG_RAID_DROP) { $dd = log($this->sources[$this->id][SRC_DROP][0], 2); if ($dd == intVal($dd)) // only one bit set $sm['dd'] = $dd + 1; } } if ($sm) $sm = [$sm]; return true; } } abstract class Filter { private static $wCards = ['*' => '%', '?' => '_']; public const CR_BOOLEAN = 1; public const CR_FLAG = 2; public const CR_NUMERIC = 3; public const CR_STRING = 4; public const CR_ENUM = 5; public const CR_STAFFFLAG = 6; public const CR_CALLBACK = 7; public const CR_NYI_PH = 999; public const V_EQUAL = 8; public const V_RANGE = 9; public const V_LIST = 10; public const V_CALLBACK = 11; public const V_REGEX = 12; protected const ENUM_ANY = -2323; protected const ENUM_NONE = -2324; protected const PATTERN_NAME = '/[\p{C};%\\\\]/ui'; protected const PATTERN_CRV = '/[\p{C};:%\\\\]/ui'; protected const PATTERN_INT = '/\D/'; protected const ENUM_FACTION = array( 469, 1037, 1106, 529, 1012, 87, 21, 910, 609, 942, 909, 530, 69, 577, 930, 1068, 1104, 729, 369, 92, 54, 946, 67, 1052, 749, 47, 989, 1090, 1098, 978, 1011, 93, 1015, 1038, 76, 470, 349, 1031, 1077, 809, 911, 890, 970, 169, 730, 72, 70, 932, 1156, 933, 510, 1126, 1067, 1073, 509, 941, 1105, 990, 934, 935, 1094, 1119, 1124, 1064, 967, 1091, 59, 947, 81, 576, 922, 68, 1050, 1085, 889, 589, 270); protected const ENUM_CURRENCY = array(32572, 32569, 29736, 44128, 20560, 20559, 29434, 37829, 23247, 44990, 24368, 52027, 52030, 43016, 41596, 34052, 45624, 49426, 40752, 47241, 40753, 29024, 24245, 26045, 26044, 38425, 29735, 24579, 24581, 32897, 22484, 52026, 52029, 4291, 28558, 43228, 34664, 47242, 52025, 52028, 37836, 20558, 34597, 43589); protected const ENUM_EVENT = array( 372, 283, 285, 353, 420, 400, 284, 201, 374, 409, 141, 324, 321, 424, 423, 327, 341, 181, 404, 398, 301); protected const ENUM_ZONE = array( 4494, 36, 2597, 3358, 45, 331, 3790, 4277, 16, 3524, 3, 3959, 719, 1584, 25, 1583, 2677, 3702, 3522, 4, 3525, 3537, 46, 1941, 2918, 3905, 4024, 2817, 4395, 4378, 148, 393, 1657, 41, 2257, 405, 2557, 65, 4196, 1, 14, 10, 15, 139, 12, 3430, 3820, 361, 357, 3433, 721, 394, 3923, 4416, 2917, 4272, 4820, 4264, 3483, 3562, 267, 495, 4742, 3606, 210, 4812, 1537, 4710, 4080, 3457, 38, 4131, 3836, 3792, 2100, 2717, 493, 215, 3518, 3698, 3456, 3523, 2367, 2159, 1637, 4813, 4298, 2437, 722, 491, 44, 3429, 3968, 796, 2057, 51, 3607, 3791, 3789, 209, 3520, 3703, 3711, 1377, 3487, 130, 3679, 406, 1519, 4384, 33, 2017, 1477, 4075, 8, 440, 141, 3428, 3519, 3848, 17, 2366, 3840, 3713, 3847, 3775, 4100, 1581, 3557, 3845, 4500, 4809, 47, 3849, 4265, 4493, 4228, 3698, 4406, 3714, 3717, 3715, 717, 67, 3716, 457, 4415, 400, 1638, 1216, 85, 4723, 4722, 1337, 4273, 490, 1497, 206, 1196, 4603, 718, 3277, 28, 40, 11, 4197, 618, 3521, 3805, 66, 1176, 1977); protected const ENUM_HEROICDUNGEON = array( 4494, 3790, 4277, 4196, 4416, 4272, 4820, 4264, 3562, 4131, 3792, 2367, 4813, 3791, 3789, 3848, 2366, 3713, 3847, 4100, 4809, 3849, 4265, 4228, 3714, 3717, 3715, 3716, 4415, 4723, 206, 1196); protected const ENUM_MULTIMODERAID = array( 4812, 3456, 2159, 4500, 4493, 4722, 4273, 4603, 4987); protected const ENUM_HEROICRAID = array( 4987, 4812, 4722); protected const ENUM_CLASSS = array( null, 1, 2, 3, 4, 5, 6, 7, 8, 9, null, 11, true, false); protected const ENUM_RACE = array( null, 1, 2, 3, 4, 5, 6, 7, 8, null, 10, 11, true, false); protected const ENUM_PROFESSION = array( null, 171, 164, 185, 333, 202, 129, 755, 165, 186, 197, true, false, 356, 182, 773); public $error = false; // erronous search fields private $cndSet = []; /* genericFilter: [FILTER_TYPE, colOrFnName, param1, param2] [self::CR_BOOLEAN, , , null] [self::CR_FLAG, , , ] # default param2: matchExact [self::CR_NUMERIC, , , ] [self::CR_STRING, , , null] [self::CR_ENUM, , , ] # param3 ? crv is val in enum : key in enum [self::CR_STAFFFLAG, , null, null] [self::CR_CALLBACK, , , ] [self::CR_NYI_PH, null, , param2] # mostly 1: to ignore this criterium; 0: to fail the whole query */ protected $genericFilter = []; protected $enums = []; // criteriumID => [validOptionList] /* fieldId => [checkType, checkValue[, fieldIsArray]] */ protected $inputFields = []; // list of input fields defined per page protected $parentCats = []; // used to validate ty-filter protected $fiData = ['c' => [], 'v' =>[]]; protected $formData = array( // data to fill form fields 'form' => [], // base form - unsanitized 'setCriteria' => [], // dynamic criteria list - index checked 'setWeights' => [], // dynamic weights list - index checked 'extraCols' => [], // extra columns for LV - added as required 'reputationCols' => [] // simlar and exclusive to extraCols - added as required ); // parse the provided request into a usable format public function __construct(bool $fromPOST = false, array $opts = []) { if (!empty($opts['parentCats'])) $this->parentCats = $opts['parentCats']; if ($fromPOST) $this->evaluatePOST(); else { // an error occured, while processing POST if (isset($_SESSION['fiError'])) { $this->error = $_SESSION['fiError'] == get_class($this); unset($_SESSION['fiError']); } $this->evaluateGET(); } } // use to generate cacheKey for filterable pages public function __sleep() { return ['formData']; } public function mergeCat(&$cats) : void { foreach ($this->parentCats as $idx => $cat) $cats[$idx] = $cat; } private function &criteriaIterator() : \Generator { if (!$this->fiData['c']) return; for ($i = 0; $i < count($this->fiData['c']['cr']); $i++) { // throws a notice if yielded directly "Only variable references should be yielded by reference" $v = [&$this->fiData['c']['cr'][$i], &$this->fiData['c']['crs'][$i], &$this->fiData['c']['crv'][$i]]; yield $i => $v; } } /***********************/ /* get prepared values */ /***********************/ public function getFilterString(array $override = [], array $addCr = []) : string { $filterURL = []; foreach (array_merge($this->fiData['c'], $this->fiData['v'], $override) as $k => $v) { if (isset($addCr[$k])) { $v = $v ? array_merge((array)$v, (array)$addCr[$k]) : $addCr[$k]; unset($addCr[$k]); } if (is_array($v) && !empty($v)) $filterURL[$k] = $k.'='.implode(':', $v); else if ($v !== '') $filterURL[$k] = $k.'='.$v; } // no criteria were set, so no merge occured .. append if ($addCr) { $filterURL['cr'] = 'cr='.$addCr['cr']; $filterURL['crs'] = 'crs='.$addCr['crs']; $filterURL['crv'] = 'crv='.$addCr['crv']; } return implode(';', $filterURL); } // [ExtraCol, ...] public function getExtraCols() : array { return array_unique($this->formData['extraCols']); } // ['cr' => Criterium, 'crs' => CriteriumSign, 'crv' => CriteriumValue] public function getSetCriteria() : array { return $this->formData['setCriteria']; } // [WeightID, WeightValue] public function getSetWeights() : array { return $this->formData['setWeights']; } // [ExtraCol, ...] public function getReputationCols() : array { return $this->formData['reputationCols']; } // [inputField => FieldValue, ...] public function getForm() : array { return $this->formData['form']; } public function getConditions() : array { if (!$this->cndSet) { // values $this->cndSet = $this->createSQLForValues(); // criteria foreach ($this->criteriaIterator() as &$_cr) if ($cnd = $this->createSQLForCriterium(...$_cr)) $this->cndSet[] = $cnd; if ($this->cndSet) array_unshift($this->cndSet, empty($this->fiData['v']['ma']) ? 'AND' : 'OR'); } return $this->cndSet; } /**********************/ /* input sanitization */ /**********************/ private function evaluatePOST() : void { // doesn't need to set formData['form']; this happens in GET-step foreach ($this->inputFields as $inp => [$type, $valid, $asArray]) { if (!isset($_POST[$inp]) || $_POST[$inp] === '') continue; $val = $_POST[$inp]; $k = in_array($inp, ['cr', 'crs', 'crv']) ? 'c' : 'v'; if ($asArray) { $buff = []; foreach ((array)$val as $v) if ($v !== '' && $this->checkInput($type, $valid, $v) && $v !== '') $buff[] = $v; if ($buff) $this->fiData[$k][$inp] = $buff; } else if ($val !== '' && $this->checkInput($type, $valid, $val) && $val !== '') $this->fiData[$k][$inp] = $val; } $this->setWeights(); $this->setCriteria(); } private function evaluateGET() : void { if (empty($_GET['filter'])) return; // squash into usable format $post = []; foreach (explode(';', $_GET['filter']) as $f) { if (!strstr($f, '=')) { $this->error = true; continue; } $_ = explode('=', $f); $post[$_[0]] = $_[1]; } foreach ($this->inputFields as $inp => [$type, $valid, $asArray]) { if (!isset($post[$inp]) || $post[$inp] === '') continue; $val = $post[$inp]; $k = in_array($inp, ['cr', 'crs', 'crv']) ? 'c' : 'v'; if ($asArray) { $buff = []; foreach (explode(':', $val) as $v) if ($v !== '' && $this->checkInput($type, $valid, $v) && $v !== '') $buff[] = $v; if ($buff) { if ($k == 'v') $this->formData['form'][$inp] = $buff; $this->fiData[$k][$inp] = $buff; } } else if ($val !== '' && $this->checkInput($type, $valid, $val) && $val !== '') { if ($k == 'v') $this->formData['form'][$inp] = $val; $this->fiData[$k][$inp] = $val; } } $this->setWeights(); $this->setCriteria(); } private function setCriteria() : void // [cr]iterium, [cr].[s]ign, [cr].[v]alue { if (empty($this->fiData['c']['cr']) && empty($this->fiData['c']['crs']) && empty($this->fiData['c']['crv'])) return; else if (empty($this->fiData['c']['cr']) || empty($this->fiData['c']['crs']) || empty($this->fiData['c']['crv'])) { unset($this->fiData['c']['cr']); unset($this->fiData['c']['crs']); unset($this->fiData['c']['crv']); $this->error = true; return; } $_cr = &$this->fiData['c']['cr']; $_crs = &$this->fiData['c']['crs']; $_crv = &$this->fiData['c']['crv']; if (count($_cr) != count($_crv) || count($_cr) != count($_crs) || count($_cr) > 5 || count($_crs) > 5 /*|| count($_crv) > 5*/) { // use min provided criterion as basis; 5 criteria at most $min = max(5, min(count($_cr), count($_crv), count($_crs))); if (count($_cr) > $min) array_splice($_cr, $min); if (count($_crv) > $min) array_splice($_crv, $min); if (count($_crs) > $min) array_splice($_crs, $min); $this->error = true; } for ($i = 0; $i < count($_cr); $i++) { // conduct filter specific checks & casts here $unsetme = false; if (isset($this->genericFilter[$_cr[$i]])) { $gf = $this->genericFilter[$_cr[$i]]; switch ($gf[0]) { case self::CR_NUMERIC: $_ = $_crs[$i]; if (!Util::checkNumeric($_crv[$i], $gf[2]) || !$this->int2Op($_)) $unsetme = true; break; case self::CR_BOOLEAN: case self::CR_FLAG: $_ = $_crs[$i]; if (!$this->int2Bool($_)) $unsetme = true; break; case self::CR_ENUM: case self::CR_STAFFFLAG: if (!Util::checkNumeric($_crs[$i], NUM_CAST_INT)) $unsetme = true; break; } } if (!$unsetme && intval($_cr[$i]) && $_crs[$i] !== '' && $_crv[$i] !== '') continue; unset($_cr[$i]); unset($_crs[$i]); unset($_crv[$i]); $this->error = true; } $this->formData['setCriteria'] = array( 'cr' => $_cr, 'crs' => $_crs, 'crv' => $_crv ); } private function setWeights() : void { // both empty: not in use if (empty($this->fiData['v']['wt']) && empty($this->fiData['v']['wtv'])) return; // one empty: erroneous manual input? if (empty($this->fiData['v']['wt']) || empty($this->fiData['v']['wtv'])) { unset($this->fiData['v']['wt']); unset($this->fiData['v']['wtv']); $this->error = true; return; } $_wt = &$this->fiData['v']['wt']; $_wtv = &$this->fiData['v']['wtv']; $nwt = count($_wt); $nwtv = count($_wtv); if ($nwt > $nwtv) { array_splice($_wt, $nwtv); $this->error = true; } else if ($nwtv > $nwt) { array_splice($_wtv, $nwt); $this->error = true; } $this->formData['setWeights'] = [$_wt, $_wtv]; } protected function checkInput(int $type, mixed $valid, mixed &$val, bool $recursive = false) : bool { switch ($type) { case self::V_EQUAL: if (gettype($valid) == 'integer') $val = intval($val); else if (gettype($valid) == 'double') $val = floatval($val); else /* if (gettype($valid) == 'string') */ $val = strval($val); if ($valid == $val) return true; break; case self::V_LIST: if (!Util::checkNumeric($val, NUM_CAST_INT)) return false; foreach ($valid as $k => $v) { if (gettype($v) != 'array') continue; if ($this->checkInput(self::V_RANGE, $v, $val, true)) return true; unset($valid[$k]); } if (in_array($val, $valid)) return true; break; case self::V_RANGE: if (Util::checkNumeric($val, NUM_CAST_INT) && $val >= $valid[0] && $val <= $valid[1]) return true; break; case self::V_CALLBACK: if ($this->$valid($val)) return true; break; case self::V_REGEX: if (!preg_match($valid, $val)) return true; break; } if (!$recursive) $this->error = true; return false; } protected function transformString(string $string, bool $exact) : string { // escape manually entered _; entering % should be prohibited $string = str_replace('_', '\\_', $string); // now replace search wildcards with sql wildcards $string = strtr($string, self::$wCards); return sprintf($exact ? '%s' : '%%%s%%', $string); } protected function modularizeString(array $fields, string $string = '', bool $exact = false, bool $shortStr = false) : array { if (!$string && !empty($this->fiData['v']['na'])) $string = $this->fiData['v']['na']; $qry = []; foreach ($fields as $f) { $sub = []; $parts = $exact ? [$string] : array_filter(explode(' ', $string)); foreach ($parts as $p) { if ($p[0] == '-' && (mb_strlen($p) > 3 || $shortStr)) $sub[] = [$f, $this->transformString(mb_substr($p, 1), $exact), '!']; else if ($p[0] != '-' && (mb_strlen($p) > 2 || $shortStr)) $sub[] = [$f, $this->transformString($p, $exact)]; } // single cnd? if (!$sub) continue; else if (count($sub) > 1) array_unshift($sub, 'AND'); else $sub = $sub[0]; $qry[] = $sub; } // single cnd? if (!$qry) $this->error = true; else if (count($qry) > 1) array_unshift($qry, 'OR'); else $qry = $qry[0]; return $qry; } protected function int2Op(mixed &$op) : bool { switch ($op) { case 1: $op = '>'; return true; case 2: $op = '>='; return true; case 3: $op = '='; return true; case 4: $op = '<='; return true; case 5: $op = '<'; return true; case 6: $op = '!='; return true; default: return false; } } protected function int2Bool(mixed &$op) : bool { switch ($op) { case 1: $op = true; return true; case 2: $op = false; return true; default: return false; } } protected function list2Mask(array $list, bool $noOffset = false) : int { $mask = 0x0; $o = $noOffset ? 0 : 1; // schoolMask requires this..? foreach ($list as $itm) $mask += (1 << (intval($itm) - $o)); return $mask; } /**************************/ /* create conditions from */ /* generic criteria */ /**************************/ private function genericBoolean($field, $op, bool $isString) : ?array { if ($this->int2Bool($op)) { $value = $isString ? '' : 0; $operator = $op ? '!' : null; return [$field, $value, $operator]; } return null; } private function genericBooleanFlags($field, $value, $op, ?bool $matchAny = false) : ?array { if (!$this->int2Bool($op)) return null; if (!$op) return [[$field, $value, '&'], 0]; else if ($matchAny) return [[$field, $value, '&'], 0, '!']; else return [[$field, $value, '&'], $value]; } private function genericString($field, $value, $strFlags) : ?array { if ($strFlags & STR_LOCALIZED) $field .= '_loc'.Lang::getLocale()->value; return $this->modularizeString([$field], (string)$value, $strFlags & STR_MATCH_EXACT, $strFlags & STR_ALLOW_SHORT); } private function genericNumeric($field, $value, $op, $typeCast) : ?array { if (!Util::checkNumeric($value, $typeCast)) return null; if ($this->int2Op($op)) return [$field, $value, $op]; return null; } private function genericEnum($field, $value) : ?array { if (is_bool($value)) return [$field, 0, ($value ? '>' : '<=')]; else if ($value == self::ENUM_ANY) return [$field, 0, '!']; else if ($value == self::ENUM_NONE) return [$field, 0]; else if ($value !== null) return [$field, $value]; return null; } protected function genericCriterion(int $cr, int $crs, string $crv) : ?array { [$crType, $colOrFn, $param1, $param2] = array_pad($this->genericFilter[$cr], 4, null); $result = null; switch ($crType) { case self::CR_NUMERIC: $result = $this->genericNumeric($colOrFn, $crv, $crs, $param1); break; case self::CR_FLAG: $result = $this->genericBooleanFlags($colOrFn, $param1, $crs, $param2); break; case self::CR_STAFFFLAG: if (User::isInGroup(U_GROUP_EMPLOYEE) && $crs > 0) $result = $this->genericBooleanFlags($colOrFn, (1 << ($crs - 1)), true); break; case self::CR_BOOLEAN: $result = $this->genericBoolean($colOrFn, $crs, !empty($param1)); break; case self::CR_STRING: $result = $this->genericString($colOrFn, $crv, $param1); break; case self::CR_ENUM: if (!$param2 && isset($this->enums[$cr][$crs])) $result = $this->genericEnum($colOrFn, $this->enums[$cr][$crs]); if ($param2 && in_array($crs, $this->enums[$cr])) $result = $this->genericEnum($colOrFn, $crs); else if ($param1 && ($crs == self::ENUM_ANY || $crs == self::ENUM_NONE)) $result = $this->genericEnum($colOrFn, $crs); break; case self::CR_CALLBACK: $result = $this->{$colOrFn}($cr, $crs, $crv, $param1, $param2); break; case self::CR_NYI_PH: // do not limit with not implemented filters if (is_int($param1)) return [$param1]; // for nonsensical values; compare against 0 if ($this->int2Op($crs) && Util::checkNumeric($crv)) { if ($crs == '=') $crs = '=='; return eval('return ('.$crv.' '.$crs.' 0);') ? [1] : [0]; } else return [0]; } if ($result && $crType == self::CR_NUMERIC && !empty($param2)) $this->formData['extraCols'][] = $cr; return $result; } /***********************************/ /* create conditions from */ /* non-generic values and criteria */ /***********************************/ protected function createSQLForCriterium(int &$cr, int &$crs, string &$crv) : array { if (!$this->genericFilter) // criteria not in use - no error return []; if (in_array($cr, array_keys($this->genericFilter))) if ($genCr = $this->genericCriterion($cr, $crs, $crv)) return $genCr; $this->error = true; trigger_error('Filter::createSQLForCriterium - received unhandled criterium: ["'.$cr.'", "'.$crs.'", "'.$crv.'"]', E_USER_WARNING); unset($cr, $crs, $crv); return []; } abstract protected function createSQLForValues(); } ?>