# Copyright 2021 Allan Galarza
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import re
import sqlite3
from tibiawikisql import schema
from tibiawikisql.models import abc
from tibiawikisql.utils import clean_links, convert_tibiawiki_position, find_template, strip_code
price_to_template = re.compile(r"{{(?:NPC List\s*|Price to (?:Buy|Sell))\s*([^}]+)}}")
npc_offers = re.compile(r"\|([^|:\[]+)(?::\s?(\d+))?(?:\s?\[\[([^\]]+))?")
teaches_template = re.compile(r"{{Teaches\s*(?:\|name=([^|]+))?([^}]+)}}")
spells_pattern = re.compile(r"\|([^|]+)")
trades_sell_template = re.compile(r"{{Trades/Sells\s*(?:\|note=([^|]+))?([^}]+)}}")
npc_trades = re.compile(r"\|([^|,\[]+)(?:,\s?([+-]?\d+))?(?:\s?\[\[([^\]]+))?")
ilink_pattern = re.compile(r"{{Ilink\|([^}]+)}}")
def parse_destinations(value):
"""Parse an NPC destinations into a list of tuples.
The tuple contains the destination's name, price and notes.
Price and notes may not be present.
Parameters
----------
value: :class:`str`
A string containing destinations.
Returns
-------
list(:class:`tuple`)
A list of tuples containing the parsed destinations.
"""
template = find_template(value, "Transport", partial=True)
if not template:
return []
result = []
for param in template.params:
if param.showkey:
continue
data, *notes = strip_code(param).split(";", 1)
notes = notes[0] if notes else ''
destination, price = data.split(",")
result.append((destination, price, notes))
return result
def parse_item_offers(value):
"""Parse NPC item offers into a list of tuples.
The tuple contains the item's name, price and currency.
Price and currency may not be present.
Parameters
----------
value: :class:`str`
The string containing NPC offers.
Returns
-------
list(:class:`tuple`)
A list of tuples containing the parsed offers.
"""
match = price_to_template.search(value)
if match:
return npc_offers.findall(match.group(1))
else:
return []
def parse_item_trades(value):
"""Parse an NPC item trades into a list of tuples.
The tuple contains the item's name, price and currency.
Price and currency may not be present.
Parameters
----------
value: :class:`str`
A string containing item trades.
Returns
-------
list(:class:`tuple`)
A list of tuples containing the parsed offers.
"""
result = []
value = replace_ilinks(value)
for note, trades in trades_sell_template.findall(value):
result.extend(npc_trades.findall(trades))
return result
def parse_spells(value):
"""Parse an NPC's teacheable spells.
Parameters
----------
value: :class:`str`
A string containing teachable spells.
Returns
-------
A list of spells grouped by vocation.
"""
result = []
for name, spell_list in teaches_template.findall(value):
spells = spells_pattern.findall(spell_list)
spells = [s.strip() for s in spells]
result.append((name, spells))
return result
def replace_ilinks(value):
"""Replace the ILink template with a regular link.
Parameters
----------
value: :class:`str`
A string containing ILink templates.
Returns
-------
:class:`str`
The string with regular links instead of ILink templates.
"""
return ilink_pattern.sub(r"[[\g<1>]]", value)
[docs]class Npc(abc.Row, abc.Parseable, table=schema.Npc):
"""Represents a non-playable character.
Attributes
----------
article_id: :class:`int`
The id of the containing article.
title: :class:`str`
The title of the containing article.
timestamp: :class:`int`
The last time the containing article was edited.
name: :class:`str`
The in-game name of the NPC.
gender: :class:`str`
The gender of the NPC.
races: list of :class:`str`
The races of the NPC.
jobs: list of :class:`str`
The jobs of the NPC.
location: :class:`str`
The location of the NPC.
subarea: :class:`str`
A finer location of the NPC.
city: :class:`str`
The nearest city to where the NPC is located.
x: :class:`int`
The x coordinates of the NPC.
y: :class:`int`
The y coordinates of the NPC.
z: :class:`int`
The z coordinates of the NPC.
version: :class:`str`
The client version where the NPC was implemented.
status: :class:`str`
The status of this NPC in the game.
image: :class:`bytes`
The NPC's image in bytes.
sell_offers: list of :class:`NpcSellOffer`
Items sold by the NPC.
buy_offers: list of :class:`NpcBuyOffer`
Items bought by the NPC.
destinations: list of :class:`NpcDestination`
Places where the NPC can travel to.
teaches: list of :class:`NpcSpell`
Spells this NPC can teach.
"""
__slots__ = (
"article_id",
"title",
"timestamp",
"name",
"gender",
"races",
"jobs",
"location",
"city",
"subarea",
"x",
"y",
"z",
"version",
"image",
"sell_offers",
"buy_offers",
"destinations",
"teaches",
"status",
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
_map = {
"name": ("name", str.strip),
"actualname": ("name", str.strip),
"location": ("location", clean_links),
"gender": ("gender", str.strip),
"city": ("city", str.strip),
"subarea": ("subarea", str.strip),
"posx": ("x", convert_tibiawiki_position),
"posy": ("y", convert_tibiawiki_position),
"posz": ("z", int),
"implemented": ("version", str.strip),
"status": ("status", str.lower),
}
_template = "Infobox_NPC"
@property
def job(self):
""":class:`str`: Get the first listed job of the NPC, if any."""
return self.jobs[0] if self.jobs else None
@property
def race(self):
""":class:`str`: Get the first listed race of the NPC, if any."""
return self.races[0] if self.races else None
[docs] @classmethod
def from_article(cls, article):
npc: cls = super().from_article(article)
if npc is None:
return None
npc._parse_jobs()
npc._parse_races()
if "buys" in npc._raw_attributes and article.title != "Minzy":
cls._parse_buy_offers(npc)
if "sells" in npc._raw_attributes and article.title != "Minzy":
cls._parse_sell_offers(npc)
cls._parse_spells(npc)
destinations = []
if "notes" in npc._raw_attributes and "{{Transport" in npc._raw_attributes["notes"]:
destinations.extend(parse_destinations(npc._raw_attributes["notes"]))
if "sells" in npc._raw_attributes and "{{Transport" in npc._raw_attributes["sells"]:
destinations.extend(parse_destinations(npc._raw_attributes["sells"]))
npc.destinations = []
for destination, price, notes in destinations:
destination.strip()
notes = clean_links(notes.strip())
price = int(price)
if not notes:
notes = None
npc.destinations.append(NpcDestination(npc_id=npc.article_id, name=destination, price=price, notes=notes))
return npc
def _parse_jobs(self):
self.jobs = []
if "job" in self._raw_attributes:
self.jobs.append(self._raw_attributes["job"])
for i in range(2, 7):
key = f"job{i}"
if key in self._raw_attributes:
self.jobs.append(clean_links(self._raw_attributes[key]))
def _parse_races(self):
self.races = []
if "race" in self._raw_attributes:
self.races.append(self._raw_attributes["race"])
for i in range(2, 7):
key = f"race{i}"
if key in self._raw_attributes:
self.races.append(clean_links(self._raw_attributes[key]))
@classmethod
def _parse_buy_offers(cls, npc):
buy_items = parse_item_offers(npc._raw_attributes["buys"])
npc.buy_offers = []
for item, price, currency in buy_items:
# Some items have extra requirements, separated with ;, so we remove them
item = item.split(";")[0]
if not currency.strip():
currency = "Gold Coin"
value = None
if "type=" in item:
continue
if price.strip():
value = int(price)
npc.buy_offers.append(NpcBuyOffer(item_title=item.strip(), currency_title=currency, value=value,
npc_id=npc.article_id))
@classmethod
def _parse_sell_offers(cls, npc):
sell_items = parse_item_offers(npc._raw_attributes["sells"])
npc.sell_offers = []
for item, price, currency in sell_items:
# Some items have extra requirements, separated with ;, so we remove them
item = item.split(";")[0]
if not currency.strip():
currency = "Gold Coin"
value = None
if price.strip():
value = int(price)
npc.sell_offers.append(NpcSellOffer(item_title=item.strip(), currency_title=currency, value=value,
npc_id=npc.article_id))
# Items traded by npcs (these have a different template)
trade_items = parse_item_trades(npc._raw_attributes["sells"])
for item, price, currency in trade_items:
item = item.split(";")[0]
value = None
if price.strip():
value = abs(int(price))
if not currency.strip():
currency = "Gold Coin"
npc.sell_offers.append(NpcSellOffer(item_title=item.strip(), currency_title=currency, value=value,
npc_id=npc.article_id))
@classmethod
def _parse_spells(cls, npc):
spell_list = parse_spells(npc._raw_attributes["sells"])
npc.teaches = []
for group, spells in spell_list:
for spell in spells:
spell = spell.strip()
knight = "knight" in group.lower() or npc.name == "Eliza"
paladin = "paladin" in group.lower() or npc.name == "Ursula" or npc.name == "Eliza"
druid = "druid" in group.lower() or npc.name == "Elathriel" or npc.name == "Eliza"
sorcerer = "sorcerer" in group.lower() or npc.name == "Eliza"
if not(knight or paladin or druid or sorcerer):
def in_jobs(vocation, _npc):
return vocation in ''.join(npc.jobs).lower()
knight = in_jobs("knight", npc)
paladin = in_jobs("paladin", npc)
druid = in_jobs("druid", npc)
sorcerer = in_jobs("sorcerer", npc)
exists = False
for j, s in enumerate(npc.teaches):
# Spell was already in list, so we update vocations
if s.spell_title == spell:
npc.teaches[j] = NpcSpell(npc_id=npc.article_id, spell_title=spell,
knight=knight or s.knight, paladin=paladin or s.paladin,
druid=druid or s.druid, sorcerer=sorcerer or s.sorcerer)
exists = True
break
if not exists:
npc.teaches.append(NpcSpell(npc_id=npc.article_id, spell_title=spell, knight=knight,
paladin=paladin, druid=druid, sorcerer=sorcerer))
[docs] def insert(self, c):
super().insert(c)
# for offer in getattr(self, "buy_offers", []):
# offer.insert(c)
# for offer in getattr(self, "sell_offers", []):
# offer.insert(c)
for spell in getattr(self, "teaches", []):
spell.insert(c)
for destination in getattr(self, "destinations", []):
destination.insert(c)
for job in getattr(self, "jobs", []):
NpcJob(npc_id=self.article_id, name=job).insert(c)
for race in getattr(self, "races", []):
NpcRace(npc_id=self.article_id, name=race).insert(c)
[docs] @classmethod
def get_by_field(cls, c, field, value, use_like=False):
npc: cls = super().get_by_field(c, field, value, use_like)
if npc is None:
return None
npc.sell_offers = NpcSellOffer.search(c, "npc_id", npc.article_id, sort_by="value", ascending=True)
npc.buy_offers = NpcBuyOffer.search(c, "npc_id", npc.article_id, sort_by="value", ascending=False)
npc.teaches = NpcSpell.search(c, "npc_id", npc.article_id)
npc.destinations = NpcDestination.search(c, "npc_id", npc.article_id)
npc.jobs = [j.name for j in NpcJob.search(c, "npc_id", npc.article_id)]
npc.races = [r.name for r in NpcRace.search(c, "npc_id", npc.article_id)]
return npc
class NpcJob(abc.Row, table=schema.NpcJob):
"""Represents a NPC job.
This class is only defined internally for parsing purposes. On runtime, this data is attached to the model as a
plain string list.
"""
__slots__ = (
'npc_id',
'name',
)
def insert(self, c):
columns = {
'npc_id': self.npc_id,
'name': self.name,
}
self.table.insert(c, **columns)
class NpcRace(abc.Row, table=schema.NpcRace):
"""Represents an NPC's race.
This class is only defined internally for parsing purposes. On runtime, this data is attached to the model as a
plain string list.
"""
__slots__ = (
'npc_id',
'name',
)
def insert(self, c):
columns = {
'npc_id': self.npc_id,
'name': self.name,
}
self.table.insert(c, **columns)
class NpcOffer:
"""Represents an NPC buy or sell offer."""
def __init__(self, **kwargs):
self.npc_id = kwargs.get("npc_id")
self.npc_title = kwargs.get("npc_title")
self.item_id = kwargs.get("item_id")
self.item_title = kwargs.get("item_title")
self.currency_id = kwargs.get("currency_id")
self.currency_title = kwargs.get("currency_title")
self.npc_city = kwargs.get("npc_city")
self.value = kwargs.get("value")
def __repr__(self):
attributes = []
for attr in self.__slots__:
try:
v = getattr(self, attr)
if v is None:
continue
attributes.append(f"{attr}={v!r}")
except AttributeError:
pass
return f"{self.__class__.__name__}({','.join(attributes)})"
[docs]class NpcSellOffer(NpcOffer, abc.Row, table=schema.NpcSelling):
"""Represents an item sellable by an NPC.
Attributes
----------
npc_id: :class:`int`
The article id of the npc that sells the item.
npc_title: :class:`str`
The title of the npc that sells the item.
npc_city: :class:`str`
The city where the NPC is located.
item_id: :class:`int`
The id of the item sold by the npc.
item_title: :class:`str`
The title of the item sold by the npc.
currency_id: :class:`int`
The item id of the currency used to buy the item.
currency_title: :class:`str`
The title of the currency used to buy the item
value: :class:`str`
The value of the item in the specified currency.
"""
__slots__ = ("npc_id", "npc_title", "npc_city", "item_id", "item_title", "value", "currency_id", "currency_title")
def __init__(self, **kwargs):
super().__init__(**kwargs)
[docs] def insert(self, c):
try:
if getattr(self, "item_id", None) and getattr(self, "value", None) and getattr(self, "currency_id", None):
super().insert(c)
elif getattr(self, "value", 0):
query = f"""INSERT INTO {self.table.__tablename__}({','.join(col.name for col in self.table.columns)})
VALUES(
?,
(SELECT article_id from item WHERE title = ?),
?,
(SELECT article_id from item WHERE title = ?))"""
c.execute(query, (self.npc_id, self.item_title, self.value, self.currency_title))
else:
query = f"""INSERT INTO {self.table.__tablename__}({','.join(col.name for col in self.table.columns)})
VALUES(
?,
(SELECT article_id from item WHERE title = ?),
(SELECT value_buy from item WHERE title = ?),
(SELECT article_id from item WHERE title = ?))"""
c.execute(query, (self.npc_id, self.item_title, self.item_title, self.currency_title))
except sqlite3.IntegrityError:
pass
@classmethod
def _is_column(cls, name):
return name in cls.__slots__
@classmethod
def _get_base_query(cls):
return f"""SELECT {cls.table.__tablename__}.*, item.title as item_title, npc.title as npc_title,
npc.city as npc_city, currency.title as currency_title
FROM {cls.table.__tablename__}
LEFT JOIN npc ON npc.article_id = npc_id
LEFT JOIN item ON item.article_id = item_id
LEFT JOIN item currency on currency.article_id = currency_id
"""
[docs]class NpcBuyOffer(NpcOffer, abc.Row, table=schema.NpcBuying):
"""Represents an item buyable by an NPC.
Attributes
----------
npc_id: :class:`int`
The article id of the npc that buys the item.
npc_title: :class:`str`
The title of the npc that buys the item.
npc_city: :class:`str`
The city where the NPC is located.
item_id: :class:`int`
The id of the item bought by the npc.
item_title: :class:`str`
The title of the item bought by the npc.
currency_id: :class:`int`
The item id of the currency used to sell the item.
currency_title: :class:`str`
The title of the currency used to sell the item
value: :class:`str`
The value of the item in the specified currency.
"""
__slots__ = ("npc_id", "npc_title", "npc_city", "item_id", "item_title", "value", "currency_id", "currency_title")
def __init__(self, **kwargs):
super().__init__(**kwargs)
[docs] def insert(self, c):
try:
if getattr(self, "item_id", None) and getattr(self, "value", None) and getattr(self, "currency_id", None):
super().insert(c)
elif getattr(self, "value", 0):
query = f"""INSERT INTO {self.table.__tablename__}({','.join(col.name for col in self.table.columns)})
VALUES(
?,
(SELECT article_id from item WHERE title = ?),
?,
(SELECT article_id from item WHERE title = ?))"""
c.execute(query, (self.npc_id, self.item_title, self.value, self.currency_title))
else:
query = f"""INSERT INTO {self.table.__tablename__}({','.join(col.name for col in self.table.columns)})
VALUES(
?,
(SELECT article_id from item WHERE title = ?),
(SELECT value_sell from item WHERE title = ?),
(SELECT article_id from item WHERE title = ?))"""
c.execute(query, (self.npc_id, self.item_title, self.item_title, self.currency_title))
except sqlite3.IntegrityError:
pass
@classmethod
def _is_column(cls, name):
return name in cls.__slots__
@classmethod
def _get_base_query(cls):
return f"""SELECT {cls.table.__tablename__}.*, item.title as item_title, npc.title as npc_title,
npc.city as npc_city, currency.title as currency_title FROM {cls.table.__tablename__}
LEFT JOIN npc ON npc.article_id = npc_id
LEFT JOIN item ON item.article_id = item_id
LEFT JOIN item currency on currency.article_id = currency_id
"""
[docs]class NpcSpell(abc.Row, table=schema.NpcSpell):
"""Represents a spell that a NPC can teach.
Attributes
----------
npc_id: :class:`int`
The article id of the npc that teaches the spell.
npc_title: :class:`str`
The title of the npc that teaches the spell.
spell_id: :class:`int`
The article id of the spell taught by the npc.
spell_title: :class:`str`
The title of the spell taught by the npc.
price: :class:`int`
The price paid to have this spell taught.
npc_city: :class:`str`
The city where the NPC is located.
knight: :class:`bool`
If the spell is taught to knights.
paladin: :class:`bool`
If the spell is taught to paladins.
druid: :class:`bool`
If the spell is taught to druids.
sorcerer: :class:`bool`
If the spell is taught to sorcerers.
"""
__slots__ = ("npc_id", "npc_title", "npc_city", "spell_id", "spell_title", "price", "knight", "sorcerer",
"paladin", "druid")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.npc_city = kwargs.get("npc_city")
self.npc_title = kwargs.get("npc_title")
self.spell_title = kwargs.get("spell_title")
self.price = kwargs.get("price")
def __repr__(self):
attributes = []
for attr in self.__slots__:
try:
v = getattr(self, attr)
if v is None:
continue
if isinstance(v, bool) and not v:
continue
attributes.append(f"{attr}={v!r}")
except AttributeError:
pass
return f"{self.__class__.__name__}({','.join(attributes)})"
[docs] def insert(self, c):
if getattr(self, "spell_id", None):
super().insert(c)
else:
query = f"""INSERT INTO {self.table.__tablename__}({','.join(c.name for c in self.table.columns)})
VALUES(?, (SELECT article_id from spell WHERE title = ?), ?, ?, ?, ?)"""
c.execute(query, (self.npc_id, self.spell_title, self.knight, self.sorcerer, self.paladin, self.druid))
@classmethod
def _is_column(cls, name):
return name in cls.__slots__
@classmethod
def _get_base_query(cls):
return f"""SELECT {cls.table.__tablename__}.*, spell.title as spell_title, npc.title as npc_title,
spell.price as price, npc.city as npc_city
FROM {cls.table.__tablename__}
LEFT JOIN npc ON npc.article_id = npc_id
LEFT JOIN spell ON spell.article_id = spell_id"""
[docs]class NpcDestination(abc.Row, table=schema.NpcDestination):
"""Represents a NPC's travel destination.
Attributes
----------
npc_id: :class:`int`
The article id of the NPC.
name: :class:`str`
The name of the destination
price: :class:`int`
The price in gold to travel.
notes: :class:`str`
Notes about the destination, such as requirements.
"""
__slots__ = (
"npc_id",
"name",
"price",
"notes",
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
[docs]class RashidPosition(abc.Row, table=schema.RashidPosition):
"""Represents a Rashid position.
Attributes
-----------
day: :class:`int`
Day of the week, Monday starts at 0.
x: :class:`int`
The x coordinate of Rashid that day.
y: :class:`int`
The y coordinate of Rashid that day.
z: :class:`int`
The z coordinate of Rashid that day.
city: :class:`str`
The city where Rashid is that day.
location: :class:`str`
The location where Rashid is that day.
"""
__slots__ = (
"day",
"x",
"y",
"z",
"city",
"location",
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
rashid_positions = [
RashidPosition(day=0, x=32210, y=31157, z=7, city="Svargrond", location="Dankwart's Tavern, south of the temple."),
RashidPosition(day=1, x=32303, y=32834, z=7, city="Liberty Bay", location="Lyonel's tavern, west of the depot."),
RashidPosition(day=2, x=32578, y=32754, z=7, city="Port Hope", location="Clyde's tavern, west of the depot."),
RashidPosition(day=3, x=33068, y=32879, z=6, city="Ankrahmun", location="Arito's tavern, above the post office."),
RashidPosition(day=4, x=33239, y=32480, z=7, city="Darashia", location="Miraia's tavern, south of the guildhalls."),
RashidPosition(day=5, x=33172, y=31813, z=6, city="Edron", location="Mirabell's tavern, above the depot."),
RashidPosition(day=6, x=32326, y=31784, z=6, city="Carlin", location="Carlin depot, one floor above."),
]