Transcribed by the Royal scrivener, Sir Acrothet Simfus
Committee Meeting XVII
Insofar as we have yet to ascertain the needs of our citizenry in comparison to those of our benevolent leader and other high ranking officials,
It has been deemed forthwith that a committee should be formed to handle such concerns, and will hereafter report its findings to this committee once every cycle.
Of matter pertaining to politics, it has thusly been found that despite egregious wrongs committed by here-to-for unknown assailants, both afield and afoot, we should, in due time, attempt to develop a plan which results in less of our deaths, and more of theirs.
A committee will be formed and henceforth be titled "The diplomatic committee" and will report to us twice every cycle, and include their findings, which shall hope- fully include less corpses on our behalf
etc. etc. ~
11 | | implemented =
12 | }}
13 |
--------------------------------------------------------------------------------
/tests/resources/content_charm.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Charm|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Curse (Charm)
3 | | actualname = Curse
4 | | type = Offensive
5 | | cost = 900
6 | | effect = Triggers on a creature with a certain chance and deals 5% of its initial hit points as [[Death Damage]] once.
7 | | implemented = 11.50.6055
8 | | notes = The chance of this charm being triggered is 10% per attack done. Since the elemental damage charms are applied on top of the creature's elemental resistances, the Curse charm is recommended for [[Death Damage/Weak|creatures that are weak to Death Damage]]. As with the other elemental damage charms, it's most efficient when used on creatures that have a high amount of hitpoints.
9 |
10 | Elemental damage Charms are the best ones available for [[Druid]]s and [[Sorcerer]]s and should be the first one unlocked by these vocations. They are also very good for [[Paladin]]s depending on the hunt and creatures. Even though [[Knight]]s will usually give preference to Defensive Charms, at some point the Knight may wish to switch to offensive charms to maximize its damage output.
11 |
12 | The choice between the 6 elemental damage charms depends mostly on the creatures the player has unlocked in the bestiary and the creatures usually hunted. Since not many creatures are weak to [[Death Damage]] and those who are usually have some other elemental weakness, as well as the fact that Curse is a rather expensive Charm, it's usually not preferred by players.
13 | | history =
14 | | status = active
15 | }}
16 |
--------------------------------------------------------------------------------
/tests/resources/content_house.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Building|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Crystal Glance
3 | | implemented = 8.00
4 | | type = Guildhall
5 | | location = north part of [[Svargrond]] at the stairs to the magic carpet
6 | | posx = 125.246
7 | | posy = 121.142
8 | | posz = 7
9 | | street = Arena Walk
10 | | houseid = 55302
11 | | size = 315
12 | | beds = 24
13 | | rent = 1000000
14 | | city = Svargrond
15 | | openwindows = 17
16 | | floors = 5
17 | | rooms = 15
18 | | furnishings = [[Beer Cask]], 2x5 [[Big Table (Object)|Big Table]], 3 [[Chalk Board]]s, 3 [[Dustbin]]s, [[Locker (Object)|Locker]], 4x11 [[Red Carpet]], 10sqm [[Table]], 5 [[Torch Bearer (Metal)|Torch Bearer]]s, 15 [[Wall Lamp]]s, [[Wine Cask]]
19 | | notes = Guild depot is {{Mapper Coords|125.244|121.149|8|8|text=here}}.
20 | | history = Before {{OfficialNewsArchive|4984|April 15, 2019}}, this house's rent was 19625 gold per month.
21 | | image =
22 | Crystal Glance (-1).png|Basement floor
23 | Crystal Glance (0).png|Ground floor
24 | Crystal Glance (+1).png|First floor
25 | Crystal Glance (+2).png|Second floor
26 | Crystal Glance (+3).png|Third floor
27 |
28 | }}
29 |
--------------------------------------------------------------------------------
/tests/resources/content_imbuement.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Imbuement|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Powerful Strike
3 | | actualname = Powerful Strike
4 | | prefix = Powerful
5 | | type = Strike
6 | | category = Critical Hit
7 | | effect = {{Imbuement Effect/Strike|50%|10%}}
8 | | slots = swords, clubs, axes, bows, crossbows
9 | | astralsources = Protective Charm: 20, Sabretooth: 25, Vexclaw Talon: 5
10 | | implemented = 11.02
11 | | notes = This imbuement may also be applied to [[Rod of Destruction]], [[Wand of Destruction]], [[Falcon Rod]], [[Lion Wand]] and [[Falcon Wand]].
12 | {{JSpoiler|The ability to apply this imbuement can be obtained by finishing the [[Heart of Destruction Quest]] or by finishing the [[Forgotten Knowledge Quest]].}}
13 | }}
14 |
--------------------------------------------------------------------------------
/tests/resources/content_item.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Fire Sword
3 | | article = a
4 | | actualname = fire sword
5 | | plural = fire swords
6 | | itemid = 3280
7 | | objectclass = Weapons
8 | | primarytype = Sword Weapons
9 | | flavortext = The blade is a magic flame.
10 | | sounds = {{Sound List|}}
11 | | implemented = 3.0
12 | | lightradius = 3
13 | | lightcolor = 199
14 | | immobile = no
15 | | walkable = yes
16 | | pickupable = yes
17 | | usable = yes
18 | | levelrequired = 30
19 | | hands = One
20 | | weapontype = Sword
21 | | attack = 24
22 | | fire_attack = 11
23 | | defense = 20
24 | | defensemod = +1
25 | | upgradeclass = 2
26 | | enchantable = no
27 | | weight = 23.00
28 | | marketable = yes
29 | | droppedby = {{Dropped By|Demodras|Dragon Lord|Enusat the Onyx Wing|Guardian of Tales|Hellfire Fighter|Hellhound|Hero|Kalyassa|Kerberos|Lava Golem|Magma Crawler|Massive Fire Elemental|Mawhawk|Memory of a Hero|Neferi the Spy|Pirat Mate|Renegade Knight|Retching Horror|Sabretooth (Creature)|Sulphur Spouter|The Baron from Below|The Count of the Core|The Duke of the Depths|Thornfire Wolf|Ushuriel|Vile Grandmaster|Vulcongra|Weeper}}
30 | | value = 4,000 - 8,000
31 | | npcvalue = 4000
32 | | npcprice = 0
33 | | buyfrom = --
34 | | sellto = Baltim: 1000, Brengus: 1000, Cedrik: 1000, Esrik: 1000, Flint: 1000, Gamel: 1000, H.L.: 335, Habdel: 1000, Hardek: 1000, Memech: 1000, Morpel: 1000, Nah'Bob, Robert: 1000, Rock In A Hard Place: 1000, Romella: 1000, Rowenna: 1000, Sam: 1000, Shanar: 1000, Turvy: 1000, Ulrik: 1000, Uzgod: 1000, Willard: 1000
35 | | notes = Mainly used from [[level]] 30 to 35 by [[Knight]]s. This sword is also used by higher level Knights to fight creatures immune to [[Physical Damage]], like [[Ghost]]s. Provides a small amount of red light. The graphic for the Fire Sword is an edit of the old [[Rapier]] sprite.
36 | {{JSpoiler|
37 | * Can be obtained in the [[Orc Fortress Quest]].
38 | * 3 Fire Swords can be traded for a [[Magic Sulphur]] with [[Haroun]] or [[Yaman]].
39 | }}
40 | | history = Before [[Updates/7.2|Update 7.2]] the Fire Sword was the best easy-obtainable one handed sword on all [[Game World]]s but [[Antica]]. It was possible to inflict two rounds of damage at a time because of its weight.
41 |
42 | == Earlier ways of obtaining ==
43 | [[Dragon]]s used to drop these [[sword]]s. Long ago they were also obtainable from a [[quest]] [[A Sweaty Cyclops]] was part of.
44 |
45 | == Sprite Change ==
46 | With the [[Updates/9.5|2012 Spring Patch]] the sprite of the Fire Sword changed from [[File:Fire Sword (Old).gif]] to [[File:Fire Sword.gif]].
47 | }}
48 |
--------------------------------------------------------------------------------
/tests/resources/content_item_damage_reflection.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Spiritthorn Armor
3 | | article = a
4 | | actualname = spiritthorn armor
5 | | plural =
6 | | itemid = 39147
7 | | objectclass = Body Equipment
8 | | primarytype = Armors
9 | | implemented = 12.90.12182
10 | | immobile = no
11 | | walkable = yes
12 | | pickupable = yes
13 | | levelrequired = 400
14 | | vocrequired = knights
15 | | imbueslots = 2
16 | | upgradeclass = 4
17 | | attrib = sword fighting +4, axe fighting +4, club fighting +4, 19 damage reflection
18 | | armor = 20
19 | | resist = physical +13%
20 | | weight = 160.00
21 | | stackable = no
22 | | marketable = yes
23 | | droppedby = {{Dropped By|Magma Bubble|Plunder Patriarch}}
24 | | value = Negotiable gp
25 | | npcvalue = 0
26 | | npcprice = 0
27 | | buyfrom = --
28 | | sellto = --
29 | | notes = Part of the [[Spiritthorn Set]]. It is a possible reward of the [[Primal Ordeal Quest]], which guarantees one random item from the [[Primal Set]] through the [[Hazard System]].
30 |
31 | It has [[Damage Reflection]].
32 | }}
33 |
--------------------------------------------------------------------------------
/tests/resources/content_item_no_attrib.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Football
3 | | article = a
4 | | actualname = football
5 | | plural = footballs
6 | | itemid = 2990, 9104
7 | | objectclass = Other Items
8 | | primarytype = Game Tokens
9 | | implemented = 6.0
10 | | destructible = no
11 | | immobile = no
12 | | walkable = yes
13 | | pickupable = no
14 | | value = 0
15 | | npcvalue = 0
16 | | npcprice = 111
17 | | npcvaluerook = 0
18 | | npcpricerook = 111
19 | | buyfrom = Beatrice, Bertha, Gorn, Lee'Delle: 111, Perod, Sarina, Shiantis, Zora
20 | | sellto = --
21 | | notes = It is used to play [[Football (Game)|football]] (Soccer in [[USA]] and Australia). Also, it seems to be worshiped by the [[Swamp Troll]]s near [[Venore]].
22 | }}
23 |
--------------------------------------------------------------------------------
/tests/resources/content_item_perfect_shot.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Gilded Eldritch Wand
3 | | article = a
4 | | actualname = gilded eldritch wand
5 | | plural =
6 | | itemid = 36669
7 | | objectclass = Weapons
8 | | primarytype = Wands
9 | | flavortext = Refined by the legendary artisan Gnomaurum.
10 | | implemented = 12.70.10953
11 | | lightradius = 3
12 | | lightcolor = 210
13 | | immobile = no
14 | | walkable = yes
15 | | pickupable = yes
16 | | levelrequired = 250
17 | | vocrequired = sorcerers
18 | | imbueslots = 2
19 | | upgradeclass = 4
20 | | range = 4
21 | | crithit_ch =
22 | | critextra_dmg =
23 | | manacost = 22
24 | | damagetype = Fire
25 | | damagerange = 85-105
26 | | attrib = magic level +2, fire magic level +1, perfect shot +65 at range 4
27 | | resist = energy +4%
28 | | weight = 37.00
29 | | marketable = yes
30 | | droppedby = {{Dropped By|The Brainstealer}}
31 | | value = Negotiable
32 | | npcvalue = 0
33 | | npcprice = 0
34 | | buyfrom = --
35 | | sellto = --
36 | | notes = It works just like a standard [[Eldritch Wand]].
37 | }}
38 |
--------------------------------------------------------------------------------
/tests/resources/content_item_resist.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Dream Shroud
3 | | article = a
4 | | actualname = dream shroud
5 | | itemid = 29423
6 | | marketable = yes
7 | | implemented = 12.00.7695
8 | | objectclass = Body Equipment
9 | | primarytype = Armors
10 | | levelrequired = 180
11 | | vocrequired = sorcerers and druids
12 | | imbueslots = 1
13 | | armor = 12
14 | | resist = energy +10%
15 | | attrib = magic level +3
16 | | weight = 25.00
17 | | droppedby = {{Dropped By|Alptramun}}
18 | | value = Negotiable
19 | | npcvalue = 0
20 | | npcprice = 0
21 | | buyfrom = --
22 | | sellto = --
23 | | pickupable = yes
24 | }}
25 |
--------------------------------------------------------------------------------
/tests/resources/content_item_sounds.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Mini NabBot
3 | | article = a
4 | | actualname = mini NabBot
5 | | itemid = 32759, 32760
6 | | flavortext = It is the first NabBot prototype of service. Awarded by NabBot.xyz.
7 | | implemented = 12.30.9287
8 | | objectclass = Household Items
9 | | primarytype = Fansite Items
10 | | weight = 60.00
11 | | sounds = {{Sound List|I am the first mini NabBot prototype, at least the first one that works properly.|Too many questions, too many answers.|Congrats ''Player'' on getting that level! Maybe you can solo rats now?|That's what you get ''Player'', for messing with that monster!|''Player'' got a level? Here, have a cookie!}}
12 | | droppedby = {{Dropped By|}}
13 | | value = Negotiable
14 | | npcvalue = 0
15 | | npcprice = 0
16 | | buyfrom = --
17 | | sellto = --
18 | | notes = It's [[NabBot]]'s fansite item. If you ''use'' it, it will turn into a [[Mini NabBot (Activated)]] and play a sound: [[File:Mini NabBot (Activating).gif]] [[File:Mini NabBot (Activated).gif]]
19 | If used again, it will deactivate again: [[File:Mini NabBot (Deactivating).gif]] [[File:Mini NabBot.gif]]
20 |
21 | The following players have '''received''' the item:
22 |
23 | For designing the item:
24 | * {{Character|Juh Mong}}, creator of the item, winner of the fansite's item contest.
25 |
26 | For contributing to the fansite:
27 | * {{Character|Galarzaa Fidera}}, fansite administrator and developer.
28 | * {{Character|Nezune}}, co-creator.
29 | * {{Character|Tschas}}, development contributor.
30 | * {{Character|Sayuri Nowan}}, fansite helper.
31 | * {{Character|Callie Aena}}, graphic designer.
32 | * {{Character|Trollefar}}, Administrator of [[TibiaData]], which provided services to [[NabBot]].
33 | * {{Character|Dev Zimm}}, collaboration with the fansite.
34 |
35 | For winning fansite contests:
36 | * {{Character|Hojkk}} - winner of the Draw NabBot Contest.
37 | * {{Character|Fire Draggon}} - winner of the Tibia Movie Awards 2020, Vocation Guide category.
38 | * {{Character|Griggi}} - winner of the Craft NabBot Contest.
39 | | fansite = [[NabBot]]
40 | | pickupable = yes
41 | }}
42 |
--------------------------------------------------------------------------------
/tests/resources/content_item_store.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Health Potion
3 | | article = a
4 | | actualname = health potion
5 | | plural = health potions
6 | | itemid = 266
7 | | marketable = yes
8 | | usable = yes
9 | | implemented = 8.10
10 | | objectclass = Plants, Animal Products, Food and Drink
11 | | primarytype = Liquids
12 | | weight = 2.70
13 | | stackable = yes
14 | | consumable = yes
15 | | droppedby = {{Dropped By|Barbarian Bloodwalker|Barbarian Headsplitter|Barbarian Skullhunter|Bibby Bloodbath|Bonebeast|Cyclops|Dark Apprentice|Dark Magician|Dawnfly|Death Priest|Demon Skeleton|Dragon Hatchling|Dreadbeast|Dwarf Guard|Dworc Voodoomaster|Elf Arcanist|Elf Overseer|Emerald Damselfly|Frost Dragon Hatchling|Frost Giant|Ghoulish Hyaena|Golden Servant|Golden Servant Replica|Grave Guard|Haunted Treeling|Insectoid Scout|Insectoid Worker|Iron Servant|Iron Servant Replica|Juvenile Cyclops|Kongra|Lizard Sentinel|Lizard Templar|Mad Scientist|Minotaur Guard|Mutated Rat|Oodok Witchmaster|Orc Leader|Orc Warlord|Renegade Orc|Salamander|Terror Bird|The Snapper|Thornback Tortoise|Troll-Trained Salamander|Undead Cavebear|Undead Gladiator|Valkyrie|Wailing Widow}}
16 | | value = < 50
17 | | storevalue = {{Store Trades|{{Store Product|6|amount=125}}|{{Store Product|11|amount=300}}}}
18 | | npcvalue = 0
19 | | npcprice = 50
20 | | buyfrom = Agostina: 75, Alaistar, Alberto: 75, Asima, Asnarus, Brom: 75, Brutus: 75, Chartan, Chuckles, Digger, Dino: 75, Emilio: 75, Fabiana: 75, Faloriel, Frederik, Ghorza, Gnomegica, Hamish, Lorenzo: 75, Maun, Mehkesh, Nelly, Onfroi, Rabaz, Rachel, Raffael, Rock In A Hard Place, Romir, Roughington: 75, Sandra, Shadowpunch: 75, Shiriel, Siflind, Sigurd, Talila, Tandros, Tarun, Topsy, Valindara, Victor: 75, Xodet
21 | | sellto = --
22 | | notes = Heals the target for 125 to 175 [[hitpoint]]s, an average of 150. The leftover [[Empty Potion Flask (Small)|empty potion flask]] can be sold back to the potion vendor for 5 gp. For comparisons of healing efficiency, see the [[Money Spent per Hit Point|GP/HP]] and the [[Capacity Taken per Hit Point|Cap/HP efficiency tables]]. Notice that you can only use this potion on yourself or another player, it doesn't work for summoned or wild creatures.
23 | {{JSpoiler|4 of them can be obtained from [[Gnomish Supply Package]]s.}}
24 | | pickupable = yes
25 | }}
26 |
--------------------------------------------------------------------------------
/tests/resources/content_key.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Key|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | number = 3940
3 | | aka = Fibula Key
4 | | primarytype = Wooden
5 | | secondarytype = Copper
6 | | location = [[Fibula]]
7 | | value = 0 - 800
8 | | npcvalue = 0
9 | | npcprice = 800
10 | | buyfrom = Dermot:2000, Simon the Beggar
11 | | sellto = --
12 | | origin =
13 | | shortnotes = Used to enter/exit the [[Fibula Dungeon]]. Comes in [[Wooden Key|wooden]] and [[Copper Key|copper]] variants.
14 | | longnotes = Used to enter/exit the [[Fibula Dungeon]]. When buying the key from [[Simon]] he will first ask you for some money, continue giving him money, and when you have given him 800 [[gp]] he will hand you the key. There is also a copper key with the same number (3940) that works too.
15 | }}
16 |
--------------------------------------------------------------------------------
/tests/resources/content_loot_statistics.txt:
--------------------------------------------------------------------------------
1 | __NOWYSIWYG__
2 |
3 | {{Loot2
4 | |version=10.37
5 | |kills=36488
6 | |name=Demon
7 | |Empty, times:99
8 | |Gold Coin, times:36302, amount:1-200, total:1829431
9 | |Platinum Coin, times:36300, amount:1-8, total:156453
10 | |Great Mana Potion, times:9150, amount:1-3, total:18360
11 | |Great Spirit Potion, times:9063, amount:1-3, total:18072
12 | |Demon Horn, times:7359, amount:1, total:7359
13 | |Ultimate Health Potion, times:7209, amount:1-3, total:14420
14 | |Fire Mushroom, times:7203, amount:1-6, total:25278
15 | |Demonic Essence, times:7186
16 | |Assassin Star, times:5646, amount:1-10, total:31060
17 | |Small Topaz, times:3700, amount:1-5, total:10998
18 | |Small Ruby, times:3651, amount:1-5, total:10757
19 | |Small Emerald, times:3596, amount:1-5, total:10751
20 | |Small Amethyst, times:3582, amount:1-5, total:10862
21 | |Fire Axe, times:1467, amount:1, total:1467
22 | |Talon, times:1223, amount:1, total:1223
23 | |Red Gem, times:1093, amount:1, total:1093
24 | |Orb, times:1061, amount:1, total:1061
25 | |Ring of Healing, times:967, amount:1, total:967
26 | |Might Ring, times:909, amount:1, total:909
27 | |Stealth Ring, times:882, amount:1, total:882
28 | |Giant Sword, times:714, amount:1, total:714
29 | |Ice Rapier, times:695, amount:1, total:695
30 | |Golden Sickle, times:498, amount:1, total:498
31 | |Purple Tome, times:453, amount:1, total:453
32 | |Devil Helmet, times:447, amount:1, total:447
33 | |Gold Ring, times:375, amount:1, total:375
34 | |Demon Shield, times:279, amount:1, total:279
35 | |Platinum Amulet, times:256, amount:1, total:256
36 | |Mastermind Shield, times:171, amount:1, total:171
37 | |Golden Legs, times:148
38 | |Demon Trophy, times:32, amount:1, total:32
39 | |Magic Plate Armor, times:32, amount:1, total:32
40 | |Demonrage Sword, times:20, amount:1, total:20
41 | }}
42 |
43 |
44 | {{Loot2
45 | |version=8.6
46 | |kills=24276
47 | |name=Demon
48 | |Empty, times:305
49 | |Gold Coin, times:23577, amount:1-199, total:2078775
50 | |Platinum Coin, times:14435, amount:1, total:14435
51 | |Fire Mushroom, times:5042, amount:1-6, total:16884
52 | |Ultimate Health Potion, times:4755, amount:1-3, total:9539
53 | |Double Axe, times:3994, amount:1, total:3994
54 | |Great Mana Potion, times:3603, amount:1-3, total:7284
55 | |Small Emerald, times:2339, amount:1, total:2339
56 | |Assassin Star, times:1250, amount:1-5, total:3796
57 | |Fire Axe, times:933, amount:1, total:933
58 | |Talon, times:865, amount:1, total:865
59 | |Orb, times:688, amount:1, total:688
60 | |Giant Sword, times:507, amount:1, total:507
61 | |Golden Sickle, times:362, amount:1, total:362
62 | |Stealth Ring, times:336, amount:1, total:336
63 | |Purple Tome, times:312, amount:1, total:312
64 | |Devil Helmet, times:294, amount:1, total:294
65 | |Gold Ring, times:257, amount:1, total:257
66 | |Platinum Amulet, times:180, amount:1, total:180
67 | |Ice Rapier, times:169, amount:1, total:169
68 | |Demon Shield, times:163, amount:1, total:163
69 | |Demon Horn, times:124, amount:1, total:124
70 | |Ring of Healing, times:116, amount:1, total:116
71 | |Golden Legs, times:103
72 | |Mastermind Shield, times:100, amount:1, total:100
73 | |Might Ring, times:38, amount:1, total:38
74 | |Demon Trophy, times:25, amount:1, total:25
75 | |Demonrage Sword, times:20, amount:1, total:20
76 | |Magic Plate Armor, times:18, amount:1, total:18
77 | }}
78 |
79 | {{Loot2
80 | |version=8.54
81 | |kills=4
82 | |name=Demon
83 | |Gold Coin, times:4, amount:71-150, total:432
84 | |Platinum Coin, times:3, amount:1, total:3
85 | |Ultimate Health Potion, times:2, amount:1, total:2
86 | |Double Axe, times:1, amount:1, total:1
87 | |Fire Mushroom, times:1, amount:6, total:6
88 | |Orb, times:1, amount:1, total:1
89 | }}
90 |
91 | {{Loot
92 | |version=8.54
93 | |kills=745
94 | |name=Demon
95 | |Empty, 25
96 | |[[Gold Coin]], 60623
97 | |[[Platinum Coin]], 480
98 | |[[Fire Mushroom]], 445
99 | |[[Ultimate Health Potion]], 273
100 | |[[Great Mana Potion]], 183
101 | |[[Double Axe]], 140
102 | |[[Assassin Star]], 72
103 | |[[Small Emerald]], 71
104 | |[[Fire Axe]], 22
105 | |[[Talon]], 21
106 | |[[Small Stone]], 20
107 | |[[Giant Sword]], 16
108 | |[[Gold Ring]], 16
109 | |[[Stealth Ring]], 12
110 | |[[Orb]], 11
111 | |[[Purple Tome]], 10
112 | |[[Platinum Amulet]], 9
113 | |[[Golden Legs]], 8
114 | |[[Golden Sickle]], 8
115 | |[[Demon Shield]], 7
116 | |[[Ice Rapier]], 6
117 | |[[Mastermind Shield]], 6
118 | |[[Devil Helmet]], 5
119 | |[[Demon Horn]], 3
120 | |[[Might Ring]], 2
121 | |[[Ring of Healing]], 2
122 | |[[Magic Plate Armor]], 1
123 | }}
124 | Average gold: 81.37
125 |
126 | {{Loot
127 | |version=8.5
128 | |kills=665
129 | |name=Demon
130 | |[[Assassin Star]], 103
131 | |[[Demon Horn]], 3
132 | |[[Demon Shield]], 5
133 | |[[Devil Helmet]], 6
134 | |[[Double Axe]], 139
135 | |[[Fire Axe]], 40
136 | |[[Fire Mushroom]], 510
137 | |[[Giant Sword]], 11
138 | |[[Gold Coin]], 57124
139 | |[[Gold Ring]], 6
140 | |[[Golden Sickle]], 11
141 | |[[Great Mana Potion]], 199
142 | |[[Ice Rapier]], 7
143 | |[[Magic Plate Armor]], 1
144 | |[[Mastermind Shield]], 2
145 | |[[Orb]], 20
146 | |[[Platinum Coin]], 474
147 | |[[Purple Tome]], 7
148 | |[[Ring of Healing]], 2
149 | |[[Small Emerald]], 67
150 | |[[Stealth Ring]], 6
151 | |[[Talon]], 22
152 | |[[Ultimate Health Potion]], 249
153 | |[[Platinum Amulet]], 5
154 | |[[Might Ring]], 2
155 | |[[Demonrage Sword]], 1
156 | }}
157 | Average gold: 85.87
158 |
159 |
--------------------------------------------------------------------------------
/tests/resources/content_mount.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Mount|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Doombringer
3 | | speed = 10
4 | | mount_id = 644
5 | | taming_method = Buying it on Tibia.com or via the [[Store]].
6 | | bought = yes
7 | | price = 780
8 | | implemented = 10.56
9 | | notes = It can be bought since [[Updates/10.56|Update 10.56]].
10 | }}
11 |
--------------------------------------------------------------------------------
/tests/resources/content_npc.txt:
--------------------------------------------------------------------------------
1 | {{Infobox NPC|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Yaman
3 | | job = Shopkeeper
4 | | location = [[Mal'ouquah]] (Green Djinn Fortress).
5 | | city = Ankrahmun
6 | | posx = 129.21
7 | | posy = 127.108
8 | | posz = 2
9 | | gender = Male
10 | | race = Djinn
11 | | buysell = yes
12 | | implemented = 7.4
13 | | notes = Yaman buys and sells magical items and some other special items. He and [[Alesar]] are dealing in different equipment for the [[Efreet]] and [[Green Djinn]] army. He can preform magical extractions.
14 |
15 | He is particularly indifferent towards humans, but will not trade with them until he has permission from [[Malor]].
16 | {{JSpoiler|Part of {{Spoiler Section|What a Foolish Quest|Mission 10 - A Sweet Surprise}}.}}
17 | | subarea = Mal'ouquah
18 | }}
19 |
--------------------------------------------------------------------------------
/tests/resources/content_npc_travel.txt:
--------------------------------------------------------------------------------
1 | {{Infobox NPC|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Captain Bluebear
3 | | job = Ship Captain
4 | | location = [[Thais]] boat at [[Harbour Street|Harbour]] and [[Main Street]].
5 | | city = Thais
6 | | posx = 126.54
7 | | posy = 125.210
8 | | posz = 6
9 | | gender = Male
10 | | race = Human
11 | | buysell = no
12 | | sounds = {{Sound List|Passages to Carlin, Ab'Dendriel, Edron, Venore, Port Hope, Liberty Bay, Yalahar, Roshamuul and Svargrond.}}
13 | | implemented = 6.4
14 | | notes = Captain Bluebear will transport any [[premium]] players by ship to:
15 | {{Transport |Ab'Dendriel, 130 |Carlin, 110 |Edron, 160 |Liberty Bay, 180 |Port Hope, 160 |Roshamuul, 210 |Oramond, 150 |Svargrond, 180 |Venore, 170 |Yalahar, 200}}
16 |
17 | He can also transport anyone to the [[Character World Transfer|world transfer]] island [[Travora]] for 1000 [[gp]], including [[Free Account]] players.
18 | }}
19 |
--------------------------------------------------------------------------------
/tests/resources/content_outfit.txt:
--------------------------------------------------------------------------------
1 | {{Infobox_Outfit|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Barbarian
3 | | primarytype = Premium
4 | | male_id = 143
5 | | female_id = 147
6 | | premium = yes
7 | | outfit = premium
8 | | addons = premium, see [[Barbarian Outfits Quest]].
9 | | achievement = Brutal Politeness
10 | | implemented = 7.8
11 | | artwork = Barbarian Outfits Artwork.jpg
12 | | notes = This outfit stands for raw power, battle cries and lack of manners. I heard that there are a few barbarians living near Northport. They might just be able to provide fitting addons.
13 | }}
14 |
--------------------------------------------------------------------------------
/tests/resources/content_quest.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Quest|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = The Annihilator Quest
3 | | aka = Anni Quest
4 | | reward = [[Demon Outfits]] and one out of four available items: [[Magic Sword]], [[Demon Armor]], [[Stonecutter Axe]], and a [[Present Box]] with an [[Annihilation Bear]] inside. [[Annihilator]] [[achievement]].
5 | | location = [[Edron]] [[Hero Cave]].
6 | | lvl = 100
7 | | lvlrec = 125
8 | | transcripts = no
9 | | premium = yes
10 | | log = The Ultimate Challenges
11 | | dangers = Most creatures are on the path to the quest such as [[Hunter]]s, [[Demon Skeleton]]s, [[Wild Warrior]]s, [[Priestess]]es, [[Monk]]s, [[Hero]]es and [[Minotaur Guard]]s. The quest involves only [[Angry Demon]]s and summoned [[Fire Elemental]]s.
12 | | legend = Deep in the earth, near hell, the demons guard their great treasure, who only the bravest could dare to retrieve!
13 | | implemented = 7.24
14 | }}
15 |
--------------------------------------------------------------------------------
/tests/resources/content_spell.txt:
--------------------------------------------------------------------------------
1 | {{Infobox Spell|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Flame Strike
3 | | spellid = 89
4 | | implemented = 6.6
5 | | type = Instant
6 | | subclass = Attack
7 | | damagetype = Fire
8 | | words = exori flam
9 | | premium = yes
10 | | mana = 20
11 | | levelrequired = 14
12 | | cooldown = 2
13 | | cooldowngroup = 2
14 | | voc = [[Sorcerer]]s and [[Druid]]s
15 | | spellcost = 800
16 | | effect = [[File:Burned Icon.gif]] Shoots a [[Fire Damage|fire]] missile at the selected target up to 3 square meters away. If no target is selected, the damage is focused on the single square right in front of the caster.
17 | | animation = Flame Strike animation.gif
18 | | notes = One of the most often used spells when fighting creatures vulnerable to [[Fire Damage|fire damage]], as it deals relatively high damage and costs very little mana.
19 | | history = The spell's animation was changed in the [[Winter Update 2007]]. Before the update it could not be cast at a target.
20 | {{{!}} class="wikitable"
21 | !Before
22 | !After
23 | {{!}}-
24 | {{!}}[[File:Flame Strike (Old).gif]]
25 | {{!}}[[File:Flame Strike.gif]]
26 | {{!}}-
27 | {{!}}[[File:Flame strike1.gif]]
28 | {{!}}[[File:Flame strike1(after winter update 2007).gif]]
29 | {{!}}}
30 | }}
31 |
--------------------------------------------------------------------------------
/tests/resources/content_update.txt:
--------------------------------------------------------------------------------
1 | {{Infobox_Update|List={{{1|}}}|GetValue={{{GetValue|}}}
2 | | name = Summer Update 2007
3 | | implemented = 8.00
4 | | date = June 26, 2007
5 | | primarytype = Major
6 | | newsid = 536
7 | | secondarytype = Summer
8 | | image = Frost Dragon.gif
9 | | previous = 7.92
10 | | next = 8.10
11 | | previousmajor = 7.9
12 | | nextmajor = 8.10
13 | | summary = New Ice Islands were added, [[Svargrond]], vocation balancing changes and many new weapons.
14 | | changelist =
15 | * The new ice islands [[Grimlund]], [[Helheim]], [[Hrodmir]], [[Nibelor]], [[Okolnir]] and [[Tyrsung]] were added
16 | ** A new hometown, the city of [[Svargrond]], in [[Hrodmir]].
17 | * New creatures such as the [[Chakoyas]], [[Barbarians]] and more ice themed creatures.
18 | * The [[Svargrond Arena]] was added.
19 | * Many vocation balancing changes
20 | ** Magic damage formula changes.
21 | ** Added over 60 new weapons and ammunition.
22 | ** Added vocation and level requirements to weapons.
23 | | teasers = *{{OfficialNewsArchive|513|Update Teaser Series About to Start Next Week}}
24 | *{{OfficialNewsArchive|519|Welcome to Svargrond}}
25 | *{{OfficialNewsArchive|521|Cool New Monsters}}
26 | *{{OfficialNewsArchive|525|Area Concept - Ice Islands}}
27 | *{{OfficialNewsArchive|526|A New Challenge}}
28 | *{{OfficialNewsArchive|528|Vocation Balancing}}
29 | *{{OfficialNewsArchive|531|Cool New Things}}
30 | *{{OfficialNewsArchive|534|Feel the Merciless Grip of Frost and Cold!}}
31 | | itemsections = {{ItemDPLs|8.00|Axe Weapons|Club Weapons|Sword Weapons|Distance Weapons|Ammunition|Body Equipment|Quest Items}}
32 | | artwork = Summer update 2007.jpg
33 | | notes =
34 | == Vocation Balancing Changes ==
35 | === Damage Formula Changes ===
36 | * The damage dealt with melee and distance weapons was generally raised.
37 | * In general, experience level has less of a influence on damage.
38 | * Damage dealt with weapons will be more influenced by skill level.
39 | * Damage dealt by spells will be more influenced by magic level.
40 | ** This results in a reduction of the damage dealt by knights and paladins using runes.
41 | * To make up for the damage reduction of spells for knights and paladins, new spells were created, with damage based on their respective skills and weapons.
42 |
43 | === Weapon Requirements ===
44 | This update added level and vocation requirements to wield weapons '''properly'''. When a weapon is not wielded properly, the damage dealt is reduced by half.
45 | Only [[knight]]s are able to use two-handed weapons, with the exception of staves and poleaxes.
46 |
47 | This change was necessary since most of the damage depends from skills now, so this would prevent a player fresh out of [[Rookgaard]] to deal great damage using a high attack weapon.
48 |
49 | === New ammunition ===
50 | There was new ammunition for paladins too. They are now sold in shops or made with new spells.
51 |
52 | == New Ice Islands ==
53 | *[[Grimlund]] (with the Chakoya settlement [[Inukaya]])
54 | *[[Helheim]]
55 | *[[Hrodmir]] (with the settlement [[Svargrond]] and the barbarian camps [[Ragnir]], [[Bittermor]] and [[Grimhorn]])
56 | *[[Nibelor]]
57 | *[[Okolnir]]
58 | *[[Tyrsung]] (with the mountain [[Jotunar]] and the hidden [[Frozen Trench]])
59 | | galleries =
60 |
61 | File:Norseman Outfits.jpg
62 | File:Update Teaser 8.0 ethereal spear.jpg
63 | File:Update Teaser 8.0 geyser.jpg
64 | File:Update Teaser 8.0 hut large.jpg
65 | File:Update Teaser 8.0 mammoth.jpg
66 | File:Update Teaser 8.0 seal.jpg
67 | File:Update Teaser 8.0 trophies.jpg
68 |
69 |
70 | File:Chakoya.jpg|Don't let their cute looks decieve you. (tibia.com)
71 | File:Braindeath.jpg|Imagine how hard they are if they summon a nightmare! (tibia.pl)
72 | File:Barbarian.jpg|'Cool new monsters'. (tibiahumor.net)
73 | File:Mammoth small.jpg| 'We've seen this creature before' (tibiabr.com)
74 | File:Frost-dragon.jpg| 'Certainly not a creature to mess with' (tibiahispano.com)
75 | File:Ice-golem.jpg| 'Massive monster looks very tough' (tibiamx.com.mx)
76 | File:Frost-giants.jpg| 'Half mammoth, half troll :P' (tibiacity.org)
77 | File:Penguin-and-silver-rabbit.jpg| 'They're evil!' (tibianews.net)
78 | File:Barbarians2.jpg| (tibianews.net)
79 | File:Crystal-spider.jpg| (tibianews.net)
80 |
81 | }}
82 |
--------------------------------------------------------------------------------
/tests/resources/content_world.txt:
--------------------------------------------------------------------------------
1 | {{Infobox World
2 | | name = Mortera
3 | | type = Retro Open PvP
4 | | online = Oct 15, 2014
5 | | offline = April 19, 2018
6 | | ingamestatus = deprecated
7 | | mergedinto = Firmera
8 | | battleye = yes
9 | | protectedsince = September 26, 2017
10 | | location = USA
11 | | worldboardid = 135438
12 | | tradeboardid = 135441
13 | }}
14 |
15 | == General Information ==
16 | Upon its creation, it was {{OfficialNewsArchive|2946|announced}} that this server would temporarily be [[premium]]-only. This phase ended when free account players were also allowed access on {{OfficialNewsArchive|3126|March 24, 2015}}.
17 |
18 | [[Character World Transfer]]s to and from this world were impossible until {{OfficialNewsArchive|4298|September 14, 2017}}, when this restriction was removed.
19 |
20 | First player to get into mainland was {{Character|Huasteck}}
21 |
--------------------------------------------------------------------------------
/tests/resources/response_category_without_continue.json:
--------------------------------------------------------------------------------
1 | {
2 | "query": {
3 | "categorymembers": [
4 | {
5 | "pageid": 6974,
6 | "ns": 0,
7 | "title": "Creature Spells",
8 | "sortkeyprefix": "*",
9 | "timestamp": "2017-11-13T05:44:47Z"
10 | },
11 | {
12 | "pageid": 21602,
13 | "ns": 0,
14 | "title": "Ultimate Spells",
15 | "sortkeyprefix": "*",
16 | "timestamp": "2017-11-13T05:42:20Z"
17 | },
18 | {
19 | "pageid": 1917,
20 | "ns": 0,
21 | "title": "Animate Dead",
22 | "sortkeyprefix": "",
23 | "timestamp": "2014-09-20T15:56:10Z"
24 | },
25 | {
26 | "pageid": 61903,
27 | "ns": 0,
28 | "title": "Apprentice's Strike",
29 | "sortkeyprefix": "",
30 | "timestamp": "2014-09-20T15:14:55Z"
31 | },
32 | {
33 | "pageid": 5549,
34 | "ns": 0,
35 | "title": "Armageddon Spell",
36 | "sortkeyprefix": "",
37 | "timestamp": "2010-08-30T16:58:37Z"
38 | },
39 | {
40 | "pageid": 68921,
41 | "ns": 0,
42 | "title": "Arrow Call",
43 | "sortkeyprefix": "",
44 | "timestamp": "2014-09-20T17:41:13Z"
45 | },
46 | {
47 | "pageid": 19931,
48 | "ns": 0,
49 | "title": "Avalanche",
50 | "sortkeyprefix": "",
51 | "timestamp": "2014-09-20T15:59:03Z"
52 | },
53 | {
54 | "pageid": 815,
55 | "ns": 0,
56 | "title": "Berserk",
57 | "sortkeyprefix": "",
58 | "timestamp": "2014-09-20T15:15:16Z"
59 | },
60 | {
61 | "pageid": 30160,
62 | "ns": 0,
63 | "title": "Blood Rage",
64 | "sortkeyprefix": "",
65 | "timestamp": "2014-09-20T15:15:36Z"
66 | },
67 | {
68 | "pageid": 68875,
69 | "ns": 0,
70 | "title": "Bruise Bane",
71 | "sortkeyprefix": "",
72 | "timestamp": "2014-09-20T15:04:27Z"
73 | }
74 | ]
75 | }
76 | }
--------------------------------------------------------------------------------
/tests/resources/response_image_info.json:
--------------------------------------------------------------------------------
1 | {
2 | "query": {
3 | "pages": {
4 | "1595": {
5 | "pageid": 1595,
6 | "ns": 6,
7 | "title": "File:Golden Armor.gif",
8 | "imagerepository": "local",
9 | "imageinfo": [
10 | {
11 | "timestamp": "2005-06-13T13:44:13Z",
12 | "user": "Rune Farmer",
13 | "userid": "270071",
14 | "parsedcomment": "Better copy which isn't cropped on the side",
15 | "comment": "Better copy which isn't cropped on the side",
16 | "url": "https://vignette.wikia.nocookie.net/tibia/images/d/d0/Golden_Armor.gif/revision/latest?cb=20050613134413&path-prefix=en",
17 | "descriptionurl": "https://tibia.fandom.com/wiki/File:Golden_Armor.gif",
18 | "bitdepth": "8"
19 | }
20 | ]
21 | },
22 | "-1": {
23 | "ns": 6,
24 | "title": "File:Golden Shield.gif",
25 | "missing": "",
26 | "imagerepository": ""
27 | }
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/tests/resources/response_revisions.json:
--------------------------------------------------------------------------------
1 | {
2 | "query": {
3 | "pages": {
4 | "1600": {
5 | "pageid": 1600,
6 | "ns": 0,
7 | "title": "Golden Armor",
8 | "revisions": [
9 | {
10 | "timestamp": "2017-04-26T20:04:19Z",
11 | "*": "{{Infobox Item|List={{{1|}}}|GetValue={{{GetValue|}}}\n| name = Golden Armor\n| marketable = yes\n| sprites = {{Frames|{{Frame Sprite|55355}}}}\n| article = a\n| actualname = golden armor\n| plural = ?\n| itemid = 3360\n| flavortext = It's an enchanted armor.\n| itemclass = Body Equipment\n| primarytype = Armors\n| secondarytype = \n| stackable = no\n| vocrequired = knights and paladins\n| armor = 14\n| imbueslots = 2\n| weight = 80.00\n| droppedby = {{Dropped By|Ferumbras|Ferumbras Mortal Shell|Ghazbaran|Golden Servant|Hellflayer|Hellgorak|Horadron|Juggernaut|Kerberos|Massacre|The Last Lore Keeper|Undead Dragon|Warlock|Zanakeph|Zarabustor}}\n| value = 20,000 - 32,000\n| npcvalue = 20000\n| npcprice = 0\n| buyfrom = --\n| sellto = Rashid, Shanar:1500, Hardek:1500, H.L.:580\n| notes = Tenth best armor, part of the [[Golden Set]].\nAfter the Update 8.4, the Golden Armor became for knights and paladins only. This can also be gotten by [[Rust Remover|unrusting]] a [[Rusty Armor (Rare)]].\n{{JSpoiler|Obtainable through the [[Behemoth Quest]].}}\n}}"
12 | }
13 | ]
14 | },
15 | "-1": {
16 | "ns": 0,
17 | "title": "Golden Shield",
18 | "missing": ""
19 | }
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/tests/test_generation.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from tibiawikisql.generation import parse_spell_data
4 |
5 |
6 | class TestGeneration(unittest.TestCase):
7 | def test_parse_spell_data(self):
8 | # language=lua
9 | content = """return {
10 | ["Animate Dead"] = {
11 | ["vocation"] = {"Druid", "Sorcerer"},
12 | ["level"] = 27,
13 | ["price"] = 1200,
14 | ["sellers"] = {
15 | ["Azalea"] = "Druid",
16 | ["Barnabas Dee"] = "Sorcerer",
17 | ["Charlotta"] = "Druid",
18 | ["Gundralph"] = true,
19 | ["Hjaern"] = "Druid",
20 | ["Malunga"] = "Sorcerer",
21 | ["Myra"] = "Sorcerer",
22 | ["Rahkem"] = "Druid",
23 | ["Romir"] = "Sorcerer",
24 | ["Shalmar"] = true,
25 | ["Tamara"] = "Druid",
26 | ["Tamoril"] = "Sorcerer",
27 | ["Tothdral"] = "Sorcerer",
28 | ["Ustan"] = "Druid"
29 | }
30 | },
31 | ["Annihilation"] = {
32 | ["vocation"] = {"Knight"},
33 | ["level"] = 110,
34 | ["price"] = 20000,
35 | ["sellers"] = {
36 | ["Ormuhn"] = true,
37 | ["Razan"] = true,
38 | ["Puffels"] = true,
39 | ["Tristan"] = true,
40 | ["Uso"] = true,
41 | ["Graham"] = true,
42 | ["Thorwulf"] = true,
43 | ["Zarak"] = true
44 | }
45 | },
46 | ["Apprentice's Strike"] = {
47 | ["vocation"] = {"Druid", "Sorcerer"},
48 | ["level"] = 8,
49 | ["price"] = 0,
50 | ["sellers"] = {
51 | ["Azalea"] = "Druid",
52 | ["Barnabas Dee"] = "Sorcerer",
53 | ["Charlotta"] = "Druid",
54 | ["Chatterbone"] = "Sorcerer",
55 | ["Eroth"] = "Druid",
56 | ["Etzel"] = "Sorcerer",
57 | ["Garamond"] = true,
58 | ["Gundralph"] = true,
59 | ["Hjaern"] = "Druid",
60 | ["Lea"] = "Sorcerer",
61 | ["Malunga"] = "Sorcerer",
62 | ["Marvik"] = "Druid",
63 | ["Muriel"] = "Sorcerer",
64 | ["Myra"] = "Sorcerer",
65 | ["Padreia"] = "Druid",
66 | ["Rahkem"] = "Druid",
67 | ["Romir"] = "Sorcerer",
68 | ["Shalmar"] = true,
69 | ["Smiley"] = "Druid",
70 | ["Tamara"] = "Druid",
71 | ["Tamoril"] = "Sorcerer",
72 | ["Tothdral"] = "Sorcerer",
73 | ["Ustan"] = "Druid"
74 | }
75 | },
76 | ["Arrow Call"] = {
77 | ["vocation"] = {"Paladin"},
78 | ["level"] = 1,
79 | ["price"] = 0,
80 | ["sellers"] = {
81 | ["Asrak"] = true,
82 | ["Dario"] = true,
83 | ["Elane"] = true,
84 | ["Ethan"] = true,
85 | ["Hawkyr"] = true,
86 | ["Helor"] = true,
87 | ["Irea"] = true,
88 | ["Isolde"] = true,
89 | ["Legola"] = true,
90 | ["Razan"] = true,
91 | ["Silas"] = true,
92 | ["Ursula"] = true
93 | }
94 | },
95 | ["Avalanche"] = {
96 | ["vocation"] = {"Druid"},
97 | ["level"] = 30,
98 | ["price"] = 1200,
99 | ["sellers"] = {
100 | ["Azalea"] = true,
101 | ["Charlotta"] = true,
102 | ["Elathriel"] = true,
103 | ["Gundralph"] = true,
104 | ["Hjaern"] = true,
105 | ["Marvik"] = true,
106 | ["Padreia"] = true,
107 | ["Rahkem"] = true,
108 | ["Shalmar"] = true,
109 | ["Smiley"] = true,
110 | ["Tamara"] = true,
111 | ["Ustan"] = true
112 | }
113 | },
114 | ["Balanced Brawl"] = {
115 | ["vocation"] = {"Monk"},
116 | ["level"] = 175,
117 | ["price"] = 250000,
118 | ["sellers"] = {["Enpa Rudra"] = true}
119 | }
120 | }"""
121 |
122 | spell_offers = parse_spell_data(content)
123 |
124 | self.assertEqual(70, len(spell_offers))
125 |
--------------------------------------------------------------------------------
/tests/tests_parsers.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import unittest
3 |
4 | from tests import load_resource
5 | from tibiawikisql.api import Article
6 | from tibiawikisql.models import Achievement
7 | from tibiawikisql.parsers import AchievementParser
8 |
9 |
10 | class TestParsers(unittest.TestCase):
11 | def test_achievement_parser_success(self):
12 | article = Article(
13 | article_id=1,
14 | title="Demonic Barkeeper",
15 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"),
16 | content=load_resource("content_achievement.txt"),
17 | )
18 |
19 | achievement = AchievementParser.from_article(article)
20 |
21 | self.assertIsInstance(achievement, Achievement)
22 |
23 |
24 |
--------------------------------------------------------------------------------
/tests/tests_schema.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | import unittest
3 | import datetime
4 |
5 | from tibiawikisql.errors import InvalidColumnValueError
6 | from tibiawikisql.schema import AchievementTable
7 |
8 | SAMPLE_ACHIEVEMENT_ROW = {
9 | "article_id": 2744,
10 | "title": "Annihilator",
11 | "name": "Annihilator",
12 | "grade": 2,
13 | "points": 5,
14 | "description": "You've daringly jumped into the infamous Annihilator and survived - taking home fame, glory and your reward.",
15 | "spoiler": "Obtainable by finishing The Annihilator Quest.",
16 | "is_secret": False,
17 | "is_premium": True,
18 | "achievement_id": 57,
19 | "version": "8.60",
20 | "status": "active",
21 | "timestamp": datetime.datetime.fromisoformat("2021-05-26T20:40:00+00:00"),
22 | }
23 |
24 |
25 | class TestSchema(unittest.TestCase):
26 |
27 | def setUp(self):
28 | self.conn = sqlite3.connect(":memory:")
29 | self.conn.row_factory = sqlite3.Row
30 |
31 | def test_achievement_table_insert_success(self):
32 | self.conn.executescript(AchievementTable.get_create_table_statement())
33 |
34 | AchievementTable.insert(self.conn, **SAMPLE_ACHIEVEMENT_ROW)
35 |
36 | def test_achievement_table_insert_none_non_nullable_field(self):
37 | self.conn.executescript(AchievementTable.get_create_table_statement())
38 | row = SAMPLE_ACHIEVEMENT_ROW.copy()
39 | row["title"] = None
40 |
41 | with self.assertRaises(InvalidColumnValueError):
42 | AchievementTable.insert(self.conn, **row)
43 |
44 | def test_achievement_table_insert_wrong_type(self):
45 | self.conn.executescript(AchievementTable.get_create_table_statement())
46 | row = SAMPLE_ACHIEVEMENT_ROW.copy()
47 | row["is_premium"] = "yes"
48 |
49 | with self.assertRaises(InvalidColumnValueError):
50 | AchievementTable.insert(self.conn, **row)
51 |
52 | def test_achievement_table_get_by_field_wrong_column(self):
53 | with self.assertRaises(ValueError):
54 | AchievementTable.get_one_by_field(self.conn, "unknown", True)
55 |
56 | def test_achievement_table_get_by_field_no_results(self):
57 | self.conn.executescript(AchievementTable.get_create_table_statement())
58 |
59 | result = AchievementTable.get_one_by_field(self.conn, "title", "Annihilator")
60 |
61 | self.assertIsNone(result)
62 |
63 | def test_achievement_table_get_by_field_get_results(self):
64 | self.conn.executescript(AchievementTable.get_create_table_statement())
65 | AchievementTable.insert(self.conn, **SAMPLE_ACHIEVEMENT_ROW)
66 |
67 | result = AchievementTable.get_one_by_field(self.conn, "title", "Annihilator")
68 |
69 | self.assertIsNotNone(result)
70 | self.assertEqual(5, result["points"])
71 |
--------------------------------------------------------------------------------
/tests/tests_utils.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from tests import load_resource
4 | from tibiawikisql.utils import (clean_links, client_color_to_rgb, parse_boolean, parse_float, parse_integer,
5 | parse_loot_statistics, parse_min_max, parse_sounds)
6 |
7 |
8 | class TestUtils(unittest.TestCase):
9 | def test_clean_links(self):
10 | # Regular link
11 | self.assertEqual(clean_links("[[Holy Damage]]"), "Holy Damage")
12 | # Named link
13 | self.assertEqual(clean_links("[[Curse (Charm)|Curse]]"), "Curse")
14 | # Comments
15 | self.assertEqual(clean_links("Hello "), "Hello")
16 |
17 | def test_clean_links_list(self):
18 | content = """* The new ice islands [[Grimlund]], [[Helheim]], [[Hrodmir]], [[Nibelor]], [[Okolnir]] and [[Tyrsung]] were added
19 | ** A new hometown, the city of [[Svargrond]], in [[Hrodmir]].
20 | * New creatures such as the [[Chakoyas]], [[Barbarians]] and more ice themed creatures.
21 | * The [[Svargrond Arena]] was added.
22 | * Many vocation balancing changes
23 | ** Magic damage formula changes.
24 | ** Added over 60 new weapons and ammunition.
25 | ** Added vocation and level requirements to weapons."""
26 |
27 | clean_content = clean_links(content)
28 |
29 | expected = """- The new ice islands Grimlund, Helheim, Hrodmir, Nibelor, Okolnir and Tyrsung were added
30 | - A new hometown, the city of Svargrond, in Hrodmir.
31 | - New creatures such as the Chakoyas, Barbarians and more ice themed creatures.
32 | - The Svargrond Arena was added.
33 | - Many vocation balancing changes
34 | - Magic damage formula changes.
35 | - Added over 60 new weapons and ammunition.
36 | - Added vocation and level requirements to weapons."""
37 | self.assertEqual(expected, clean_content)
38 |
39 | def test_parse_boolean(self):
40 | self.assertTrue(parse_boolean("yes"))
41 | self.assertFalse(parse_boolean("no"))
42 | self.assertFalse(parse_boolean("--"))
43 | self.assertTrue(parse_boolean("--", True))
44 | self.assertTrue(parse_boolean("no", invert=True))
45 |
46 | def test_parse_float(self):
47 | self.assertEqual(parse_float("1.45"), 1.45)
48 | self.assertEqual(parse_float("?"), 0.0)
49 | self.assertIsNone(parse_float("?", None))
50 | self.assertEqual(parse_float("2.55%"), 2.55)
51 |
52 | def test_parse_integer(self):
53 | self.assertEqual(parse_integer("100 tibia coins"), 100)
54 | self.assertEqual(parse_integer("10056"), 10056)
55 | self.assertEqual(parse_integer("--"), 0)
56 |
57 | def test_parse_min_max(self):
58 | self.assertEqual(parse_min_max("5-20"), (5, 20))
59 | self.assertEqual(parse_min_max("50"), (0, 50))
60 |
61 | def test_parse_sounds(self):
62 | sound_string = "{{Sound List|Sound 1|Sound 2|Sound 3}}"
63 | sounds = parse_sounds(sound_string)
64 | self.assertEqual(len(sounds), 3)
65 |
66 | self.assertFalse(parse_sounds("?"))
67 |
68 | def test_parse_loot_statistics(self):
69 | content = load_resource("content_loot_statistics.txt")
70 | kills, loot_statistics = parse_loot_statistics(content)
71 | self.assertEqual(36488, kills)
72 | self.assertEqual(34, len(loot_statistics))
73 |
74 | kills, loot_statistics = parse_loot_statistics("Something else")
75 | self.assertEqual(kills, 0)
76 | self.assertFalse(loot_statistics)
77 |
78 | def test_client_light_to_rgb(self):
79 | self.assertEqual(client_color_to_rgb(-1), 0)
80 | self.assertEqual(client_color_to_rgb(0), 0)
81 | self.assertEqual(client_color_to_rgb(3), 0x99)
82 | self.assertEqual(client_color_to_rgb(215), 0xffffff)
83 | self.assertEqual(client_color_to_rgb(216), 0)
84 |
--------------------------------------------------------------------------------
/tests/tests_wikiapi.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | import tibiawikisql.api
5 | from tests import load_resource
6 | from tibiawikisql.api import Article, WikiClient, WikiEntry, Image
7 |
8 |
9 | class TestWikiApi(unittest.TestCase):
10 |
11 |
12 | def setUp(self):
13 | self.wiki_client = WikiClient()
14 |
15 | def test_category_functions(self):
16 | json_response = load_resource("response_category_without_continue.json")
17 | tibiawikisql.api.requests.Session.get = MagicMock()
18 | tibiawikisql.api.requests.Session.get.return_value.text = json_response
19 | members = list(self.wiki_client.get_category_members("Spells"))
20 | self.assertIsInstance(members[0], WikiEntry)
21 | self.assertEqual(len(members), 8)
22 |
23 | members = list(self.wiki_client.get_category_members("Spells", False))
24 | self.assertEqual(len(members), 10)
25 |
26 | titles = list(self.wiki_client.get_category_members_titles("Spells"))
27 | self.assertIsInstance(titles[0], str)
28 | self.assertEqual(len(titles), 8)
29 |
30 | def test_article_functions(self):
31 | json_response = load_resource("response_revisions.json")
32 | tibiawikisql.api.requests.Session.get = MagicMock()
33 | tibiawikisql.api.requests.Session.get.return_value.text = json_response
34 | # Response is mocked, so this doesn't affect the output, but this matches the order in the mocked response.
35 | titles = ["Golden Armor", "Golden Shield"]
36 | articles = list(self.wiki_client.get_articles(titles))
37 | self.assertIsInstance(articles[0], Article)
38 | self.assertEqual(articles[0].title, titles[0])
39 | self.assertIsNone(articles[1])
40 |
41 | article = self.wiki_client.get_article(titles[0])
42 | self.assertIsInstance(article, Article)
43 | self.assertEqual(article.title, titles[0])
44 |
45 | def test_image_functions(self):
46 | json_response = load_resource("response_image_info.json")
47 | tibiawikisql.api.requests.Session.get = MagicMock()
48 | tibiawikisql.api.requests.Session.get.return_value.text = json_response
49 | tibiawikisql.api.requests.Session.get.return_value.status_code = 200
50 | # Response is mocked, so this doesn't affect the output, but this matches the order in the mocked response.
51 | titles = ["Golden Armor.gif", "Golden Shield.gif"]
52 | images = list(self.wiki_client.get_images_info(titles))
53 | self.assertIsInstance(images[0], Image)
54 | self.assertEqual(images[0].file_name, titles[0])
55 | self.assertIsNone(images[1])
56 |
57 | image = self.wiki_client.get_image_info(titles[0])
58 | self.assertIsInstance(image, Image)
59 | self.assertEqual(image.file_name, titles[0])
60 | self.assertEqual(image.extension, ".gif")
61 | self.assertEqual(image.clean_name, "Golden Armor")
62 |
--------------------------------------------------------------------------------
/tibiawikisql/__init__.py:
--------------------------------------------------------------------------------
1 | """API that reads and parses information from `TibiaWiki `_."""
2 |
3 | __author__ = "Allan Galarza"
4 | __copyright__ = "Copyright 2025 Allan Galarza"
5 |
6 | __license__ = "Apache 2.0"
7 | __version__ = "7.0.1"
8 |
--------------------------------------------------------------------------------
/tibiawikisql/__main__.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 |
3 | import click
4 | import colorama
5 |
6 | from tibiawikisql import __version__, generation
7 | from tibiawikisql.utils import timed
8 |
9 | DATABASE_FILE = "tibiawiki.db"
10 |
11 | colorama.init()
12 |
13 |
14 | @click.group(context_settings={'help_option_names': ['-h', '--help']})
15 | @click.version_option(__version__, '-V', '--version')
16 | def cli():
17 | # Empty command group to disable default command.
18 | pass
19 |
20 |
21 | @cli.command(name="generate")
22 | @click.option('-s', '--skip-images', help="Skip fetching and loading images to the database.", is_flag=True)
23 | @click.option('-db', '--db-name', help="Name for the database file.", default=DATABASE_FILE)
24 | @click.option('-sd', '--skip-deprecated', help="Skips fetching deprecated articles and their images.", is_flag=True)
25 | def generate(skip_images, db_name, skip_deprecated):
26 | """Generates a database file."""
27 | with timed() as t:
28 | with sqlite3.connect(db_name) as conn:
29 | generation.generate(conn, skip_images, skip_deprecated)
30 | click.echo(f"Command finished in {t.elapsed:.2f} seconds.")
31 |
32 |
33 | if __name__ == "__main__":
34 | cli()
35 |
--------------------------------------------------------------------------------
/tibiawikisql/api.py:
--------------------------------------------------------------------------------
1 | """API to fetch information from [TibiaWiki](https://tibia.fandom.com) through MediaWiki's API."""
2 |
3 | import datetime
4 | import json
5 | import urllib.parse
6 | from collections.abc import Generator
7 | from typing import ClassVar
8 |
9 | from pydantic import BaseModel, computed_field
10 | import requests
11 |
12 | from tibiawikisql import __version__
13 | from tibiawikisql.utils import parse_templatates_data
14 |
15 | BASE_URL = "https://tibia.fandom.com"
16 |
17 |
18 | class WikiEntry(BaseModel):
19 | """Represents a Wiki entry, such as an article or file."""
20 |
21 | article_id: int
22 | """The entry's ID."""
23 | title: str
24 | """The entry's title."""
25 | timestamp: datetime.datetime
26 | """The date of the entry's last edit."""
27 |
28 | def __eq__(self, other: object) -> bool:
29 | """Check for equality.
30 |
31 | Returns:
32 | `True` if both objects are instances of this class and have the same `article_id`.
33 |
34 | """
35 | if isinstance(other, self.__class__):
36 | return self.article_id == other.article_id
37 | return False
38 |
39 | @computed_field
40 | @property
41 | def url(self) -> str:
42 | """The URL to the article's display page."""
43 | return f"{BASE_URL}/wiki/{urllib.parse.quote(self.title.replace(' ','_'))}"
44 |
45 |
46 | class Article(WikiEntry):
47 | """Represents a Wiki article."""
48 |
49 | content: str
50 | """The article's source content."""
51 |
52 | @property
53 | def infobox_attributes(self) -> dict:
54 | """Returns a mapping of the template attributes."""
55 | return parse_templatates_data(self.content)
56 |
57 |
58 | class Image(WikiEntry):
59 | """Represents an image info."""
60 |
61 | file_url: str
62 | """The URL to the file."""
63 |
64 | @property
65 | def extension(self) -> str | None:
66 | """The image's file extension."""
67 | parts = self.title.split(".")
68 | if len(parts) == 1:
69 | return None
70 | return f".{parts[-1]}"
71 |
72 | @property
73 | def file_name(self) -> str:
74 | """The image's file name."""
75 | return self.title.replace("File:", "")
76 |
77 | @property
78 | def clean_name(self) -> str:
79 | """The image's name without extension and prefix."""
80 | return self.file_name.replace(self.extension, "")
81 |
82 |
83 | class WikiClient:
84 | """Contains methods to communicate with TibiaWiki's API."""
85 |
86 | ENDPOINT: ClassVar[str] = f"{BASE_URL}/api.php"
87 |
88 | headers: ClassVar[dict[str, str]]= {
89 | "User-Agent": f'tibiawikisql/{__version__}', # noqa: Q000
90 | }
91 |
92 | def __init__(self) -> None:
93 | """Creates a new instance of the client."""
94 | self.session = requests.Session()
95 |
96 | def get_category_members(self, name: str, skip_index: bool = True) -> Generator[WikiEntry]:
97 | """Create a generator that obtains entries in a certain category.
98 |
99 | Args:
100 | name: The category's name. ``Category:`` prefix is not necessary.
101 | skip_index: Whether to skip index articles or not.
102 |
103 | Yields:
104 | Articles in this category.
105 |
106 | """
107 | cmcontinue = None
108 | params = {
109 | "action": "query",
110 | "list": "categorymembers",
111 | "cmtitle": f"Category:{name}",
112 | "cmlimit": 500,
113 | "cmtype": "page",
114 | "cmprop": "ids|title|sortkeyprefix|timestamp",
115 | "format": "json",
116 | }
117 | while True:
118 | params["cmcontinue"] = cmcontinue
119 | r = self.session.get(self.ENDPOINT, params=params)
120 | data = json.loads(r.text)
121 | for member in data["query"]["categorymembers"]:
122 | if member["sortkeyprefix"] == "*" and skip_index:
123 | continue
124 | yield WikiEntry(
125 | article_id=member["pageid"],
126 | title=member["title"],
127 | timestamp=member["timestamp"],
128 | )
129 | try:
130 | cmcontinue = data["continue"]["cmcontinue"]
131 | except KeyError:
132 | # If there's no "cmcontinue", means we reached the end of the list.
133 | break
134 |
135 | def get_category_members_titles(self, name: str, skip_index: bool =True) -> Generator[str]:
136 | """Create a generator that obtains a list of article titles in a category.
137 |
138 | Args:
139 | name: The category's name. ``Category:`` prefix is not necessary.
140 | skip_index: Whether to skip index articles or not.
141 |
142 | Yields:
143 | Titles of articles in this category.
144 |
145 | """
146 | for member in self.get_category_members(name, skip_index):
147 | yield member.title
148 |
149 |
150 | def get_image_info(self, name: str) -> Image:
151 | """Get an image's info.
152 |
153 | It is not required to prefix the name with ``File:``, but the extension is required.
154 |
155 | Args:
156 | name: The name of the image.
157 |
158 | Returns:
159 | The image's information.
160 |
161 | """
162 | gen = self.get_images_info([name])
163 | return next(gen)
164 |
165 | def get_images_info(self, names: list[str]) -> Generator[Image | None]:
166 | """Get the information of a list of image names.
167 |
168 | It is not required to prefix the name with ``File:``, but the extension is required.
169 |
170 | Warning:
171 | The order of the returned images might not match the order of the provided names due to an API limitation.
172 |
173 | Args:
174 | names: A list of names of images to get the info of.
175 |
176 | Yields:
177 | An image's information.
178 |
179 | """
180 | i = 0
181 | params = {
182 | "action": "query",
183 | "prop": "imageinfo",
184 | "iiprop": "url|timestamp",
185 | "format": "json",
186 | }
187 | while True:
188 | if i >= len(names):
189 | break
190 | params["titles"] = "|".join(f"File:{n}" for n in names[i:min(i + 50, len(names))])
191 |
192 | r = self.session.get(self.ENDPOINT, params=params)
193 | if r.status_code >= 400:
194 | continue
195 | data = json.loads(r.text)
196 | i += 50
197 | for image_data in data["query"]["pages"].values():
198 | if "missing" in image_data:
199 | yield None
200 | continue
201 | try:
202 | yield Image(
203 | article_id=image_data["pageid"],
204 | title=image_data["title"],
205 | timestamp=image_data["imageinfo"][0]["timestamp"],
206 | file_url=image_data["imageinfo"][0]["url"],
207 | )
208 | except KeyError:
209 | continue
210 |
211 | def get_articles(self, names: list[str]) -> Generator[Article | None]:
212 | """Create a generator that obtains a list of articles given their titles.
213 |
214 | Warning:
215 | The order of the returned articles might not match the order of the provided names due to an API limitation.
216 |
217 | Args:
218 | names: A list of names of articles to get the info of.
219 |
220 | Yields:
221 | An article in the list of names.
222 |
223 | """
224 | i = 0
225 | params = {
226 | "action": "query",
227 | "prop": "revisions",
228 | "rvprop": "content|timestamp",
229 | "format": "json",
230 | }
231 | while True:
232 | if i >= len(names):
233 | break
234 | params["titles"] = "|".join(names[i:min(i + 50, len(names))])
235 | i += 50
236 | r = self.session.get(self.ENDPOINT, params=params)
237 | data = json.loads(r.text)
238 | for article in data["query"]["pages"].values():
239 | if "missing" in article:
240 | yield None
241 | continue
242 | yield Article(
243 | article_id=article["pageid"],
244 | timestamp=article["revisions"][0]["timestamp"],
245 | title=article["title"],
246 | content=article["revisions"][0]["*"],
247 | )
248 |
249 | def get_article(self, name: str) -> Article:
250 | """Get an article's info.
251 |
252 | Args:
253 | name: The name of the Article.
254 |
255 | Returns:
256 | The article matching the title.
257 |
258 | """
259 | gen = self.get_articles([name])
260 | return next(gen)
261 |
--------------------------------------------------------------------------------
/tibiawikisql/errors.py:
--------------------------------------------------------------------------------
1 | """Custom exceptions used by the package."""
2 | from __future__ import annotations
3 |
4 | from typing import TYPE_CHECKING
5 |
6 |
7 | if TYPE_CHECKING:
8 | from tibiawikisql.api import Article
9 | from tibiawikisql.parsers import BaseParser
10 | from tibiawikisql.database import Column, Table
11 |
12 |
13 | class TibiaWikiSqlError(Exception):
14 | """Base class for all exceptions raised by tibiawiki-sql."""
15 |
16 |
17 | class AttributeParsingError(TibiaWikiSqlError):
18 | """Error raised when trying to parse an attribute."""
19 | def __init__(self, cause: type[Exception]) -> None:
20 | """Create an instance of the class.
21 |
22 | Args:
23 | cause: The exception that caused this.
24 |
25 | """
26 | super().__init__(f"{cause.__class__.__name__}: {cause}")
27 |
28 |
29 | class ArticleParsingError(TibiaWikiSqlError):
30 | """Error raised when an article failed to be parsed."""
31 |
32 | def __init__(self, article: Article, msg: str | None = None, cause: type[Exception] | None = None) -> None:
33 | """Create an instance of the class.
34 |
35 | Args:
36 | article: The article that failed to parse.
37 | msg: An error message for the error.
38 | cause: The original exception that caused the parsing to fail.
39 | """
40 | self.article = article
41 | if cause:
42 | msg = f"Error parsing article: `{article.title}` | {cause.__class__.__name__}: {cause}"
43 | else:
44 | msg = f"Error parsing article: `{article.title}` | {msg}"
45 | super().__init__(msg)
46 |
47 |
48 | class TemplateNotFoundError(ArticleParsingError):
49 | """Error raised when the required template is not found in the article."""
50 | def __init__(self, article: Article, parser: type[BaseParser]) -> None:
51 | """Create an instance of the class.
52 |
53 | Args:
54 | article: The article that failed to parse.
55 | parser: The parser class used.
56 | """
57 | super().__init__(article, f"Template `{parser.template_name}` not found.")
58 |
59 |
60 | class DatabaseError(TibiaWikiSqlError):
61 | """Error raised when a database related error happens."""
62 |
63 |
64 | class InvalidColumnValueError(TibiaWikiSqlError):
65 | """Error raised when an invalid value is assigned to a column."""
66 |
67 | def __init__(self, table: type[Table], column: Column, message: str) -> None:
68 | """Create an instance of the class.
69 |
70 | Args:
71 | table: The table where the column is located.
72 | column: The column with the error.
73 | message: A brief description of the error.
74 | """
75 | super().__init__(f"Column {column.name!r} in table {table.__tablename__!r}: {message}")
76 | self.table = table
77 | self.column = column
78 |
79 |
80 | class SchemaError(DatabaseError):
81 | """Error raised for invalid schema definitions.
82 |
83 | Notes:
84 | This error is raised very early when running, to verify that classes are defined correctly,
85 | so it is not an error that should be seen when using the library.
86 | """
87 |
88 |
--------------------------------------------------------------------------------
/tibiawikisql/models/__init__.py:
--------------------------------------------------------------------------------
1 | """Contains all the models representing TibiaWiki articles."""
2 |
3 | from tibiawikisql.models.achievement import Achievement
4 | from tibiawikisql.models.charm import Charm
5 | from tibiawikisql.models.creature import Creature, CreatureAbility, CreatureDrop, CreatureMaxDamage
6 | from tibiawikisql.models.house import House
7 | from tibiawikisql.models.imbuement import Imbuement, ImbuementMaterial
8 | from tibiawikisql.models.item import Book, Item, ItemAttribute, ItemStoreOffer, Key
9 | from tibiawikisql.models.mount import Mount
10 | from tibiawikisql.models.npc import Npc, NpcDestination, NpcOffer, NpcSpell, RashidPosition
11 | from tibiawikisql.models.outfit import Outfit, OutfitImage, OutfitQuest
12 | from tibiawikisql.models.quest import Quest, QuestDanger, QuestReward
13 | from tibiawikisql.models.spell import Spell
14 | from tibiawikisql.models.update import Update
15 | from tibiawikisql.models.world import World
16 |
--------------------------------------------------------------------------------
/tibiawikisql/models/achievement.py:
--------------------------------------------------------------------------------
1 |
2 | from tibiawikisql.api import WikiEntry
3 | from tibiawikisql.models.base import RowModel, WithStatus, WithVersion
4 | from tibiawikisql.schema import AchievementTable
5 |
6 |
7 | class Achievement(WikiEntry, WithStatus, WithVersion, RowModel, table=AchievementTable):
8 | """Represents an Achievement."""
9 |
10 | name: str
11 | """The achievement's name."""
12 | grade: int | None
13 | """The achievement's grade, from 1 to 3. Also known as 'stars'."""
14 | points: int | None
15 | """The amount of points given by this achievement."""
16 | description: str
17 | """The official description shown for the achievement."""
18 | spoiler: str | None
19 | """Instructions or information on how to obtain the achievement."""
20 | is_secret: bool
21 | """Whether the achievement is secret or not."""
22 | is_premium: bool
23 | """Whether a premium account is required to get this achievement."""
24 | achievement_id: int | None
25 | """The internal ID of the achievement."""
26 |
--------------------------------------------------------------------------------
/tibiawikisql/models/base.py:
--------------------------------------------------------------------------------
1 | """Module with base classes used by models."""
2 | from __future__ import annotations
3 |
4 | from sqlite3 import Connection, Cursor, Row
5 | from typing import Any, ClassVar, TYPE_CHECKING
6 |
7 | from pydantic import BaseModel, Field
8 |
9 | from tibiawikisql.database import Table
10 |
11 | if TYPE_CHECKING:
12 | from typing_extensions import Self
13 |
14 |
15 | class WithStatus(BaseModel):
16 | """Adds the status field to a model."""
17 |
18 | status: str
19 | """The in-game status for this element"""
20 |
21 |
22 | class WithVersion(BaseModel):
23 | """Adds the version field to a model."""
24 |
25 | version: str | None
26 | """The client version when this was implemented in the game, if known."""
27 |
28 |
29 | class WithImage(BaseModel):
30 | """Adds the image field to a model."""
31 |
32 | image: bytes | None = Field(None, exclude=True)
33 | """An image representing this article."""
34 |
35 |
36 | class RowModel(BaseModel):
37 | """A mixin class to indicate that this model comes from a SQL table."""
38 |
39 | table: ClassVar[type[Table]] = NotImplemented
40 | """The SQL table where this model is stored."""
41 |
42 | def __init_subclass__(cls, **kwargs) -> None:
43 | super().__init_subclass__()
44 |
45 | if cls.__name__ == "RowModel":
46 | return # skip base class
47 |
48 | if "table" not in kwargs:
49 | msg = f"{cls.__name__} must define a `table` attribute."
50 | raise NotImplementedError(msg)
51 |
52 | table = kwargs["table"]
53 | if not isinstance(table, type) or not issubclass(table, Table):
54 | msg = f"{cls.__name__}.table must be a subclass of Table."
55 | raise TypeError(msg)
56 | cls.table = table
57 |
58 |
59 | def insert(self, conn: Connection | Cursor) -> None:
60 | """Insert the model into its respective database table.
61 |
62 | Args:
63 | conn: A cursor or connection to the database.
64 | """
65 | rows = {}
66 | for column in self.table.columns:
67 | try:
68 | value = getattr(self, column.name)
69 | if value == column.default:
70 | continue
71 | rows[column.name] = value
72 | except AttributeError:
73 | continue
74 | self.table.insert(conn, **rows)
75 |
76 | @classmethod
77 | def from_row(cls, row: Row | dict[str, Any]) -> Self:
78 | """Return an instance of the model from a row or dictionary.
79 |
80 | Args:
81 | row: A dict representing a row or a Row object.
82 |
83 | Returns:
84 | An instance of the class, based on the row.
85 |
86 | """
87 | if isinstance(row, Row):
88 | row = dict(row)
89 | return cls.model_validate(row)
90 |
91 | @classmethod
92 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None:
93 | """Get a single element matching the field's value.
94 |
95 | Args:
96 | conn: A connection or cursor of the database.
97 | field: The field to filter with.
98 | value: The value to look for.
99 | use_like: Whether to use ``LIKE`` as a comparator instead of ``=``.
100 |
101 | Returns:
102 | The object found, or ``None`` if no entries match.
103 |
104 | Raises:
105 | ValueError: The specified field doesn't exist in the table.
106 |
107 | """
108 | row = cls.table.get_one_by_field(conn, field, value, use_like)
109 | return cls.from_row(row) if row else None
110 |
111 | @classmethod
112 | def get_list_by_field(
113 | cls,
114 | conn: Connection | Cursor,
115 | field: str,
116 | value: Any | None = None,
117 | use_like: bool = False,
118 | sort_by: str | None = None,
119 | ascending: bool = True,
120 | ) -> list[Self]:
121 | """Get a list of elements matching the specified field's value.
122 |
123 | Note that this won't get values found in child tables.
124 |
125 | Args:
126 | conn: A connection or cursor of the database.
127 | field: The name of the field to filter by.
128 | value: The value to filter by.
129 | use_like: Whether to use ``LIKE`` as a comparator instead of ``=``.
130 | sort_by: The name of the field to sort by.
131 | ascending: Whether to sort ascending or descending.
132 |
133 | Returns:
134 | A list containing all matching objects.
135 |
136 | Raises:
137 | ValueError: The specified field doesn't exist in the table.
138 |
139 | """
140 | rows = cls.table.get_list_by_field(conn, field, value, use_like, sort_by, ascending)
141 | return [cls.from_row(r) for r in rows]
142 |
143 | @classmethod
144 | def get_by_id(cls, conn: Connection | Cursor, article_id: int) -> Self | None:
145 | """Get an entry by its article ID.
146 |
147 | Args:
148 | conn: A connection to the database.
149 | article_id: The article ID to search for.
150 |
151 | Returns:
152 | The model matching the article ID if found.
153 |
154 | """
155 | return cls.get_one_by_field(conn, "article_id", article_id)
156 |
157 | @classmethod
158 | def get_by_title(cls, conn: Connection | Cursor, title: str) -> Self | None:
159 | """Get an entry by its title.
160 |
161 | Args:
162 | conn: A connection to the database.
163 | title: The title of the article.
164 |
165 | Returns:
166 | The model matching the article title if found.
167 |
168 | """
169 | return cls.get_one_by_field(conn, "title", title)
170 |
--------------------------------------------------------------------------------
/tibiawikisql/models/charm.py:
--------------------------------------------------------------------------------
1 | from tibiawikisql.api import WikiEntry
2 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion
3 | from tibiawikisql.schema import CharmTable
4 |
5 |
6 | class Charm(WikiEntry, WithStatus, WithVersion, WithImage, RowModel, table=CharmTable):
7 | """Represents a charm."""
8 |
9 | name: str
10 | """The name of the charm."""
11 | type: str
12 | """The type of the charm."""
13 | effect: str
14 | """The charm's description."""
15 | cost: int
16 | """The number of charm points needed to unlock."""
17 |
--------------------------------------------------------------------------------
/tibiawikisql/models/house.py:
--------------------------------------------------------------------------------
1 |
2 | from tibiawikisql.models.base import RowModel, WithStatus, WithVersion
3 | from tibiawikisql.api import WikiEntry
4 | from tibiawikisql.schema import HouseTable
5 |
6 |
7 | class House(WikiEntry, WithVersion, WithStatus, RowModel, table=HouseTable):
8 | """Represents a house or guildhall."""
9 |
10 | house_id: int
11 | """The house's id on tibia.com."""
12 | name: str
13 | """The name of the house."""
14 | is_guildhall: bool
15 | """Whether the house is a guildhall or not."""
16 | city: str
17 | """The city where the house is located."""
18 | street: str | None
19 | """The name of the street where the house is located."""
20 | location: str | None
21 | """A brief description of where the house is."""
22 | beds: int
23 | """The maximum number of beds the house can have."""
24 | rent: int
25 | """The monthly rent of the house."""
26 | size: int
27 | """The number of tiles (SQM) of the house."""
28 | rooms: int | None
29 | """The number of rooms the house has."""
30 | floors: int | None
31 | """The number of floors the house has."""
32 | x: int | None
33 | """The x coordinate of the house."""
34 | y: int | None
35 | """The y coordinate of the house."""
36 | z: int | None
37 | """The z coordinate of the house."""
38 |
--------------------------------------------------------------------------------
/tibiawikisql/models/imbuement.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | from sqlite3 import Connection, Cursor, IntegrityError
3 | from typing import Any
4 |
5 | from pydantic import BaseModel, Field
6 | from pypika import Parameter, Query
7 | from typing_extensions import Self
8 |
9 | from tibiawikisql.api import WikiEntry
10 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion
11 | from tibiawikisql.schema import ImbuementMaterialTable, ImbuementTable, ItemTable
12 |
13 | class Material(BaseModel):
14 | """A material needed to use this imbuement."""
15 |
16 | item_id: int = 0
17 | """The article ID of the item material."""
18 | item_title: str
19 | """The title of the item material."""
20 | amount: int
21 | """The amount of items required."""
22 |
23 | def insert(self, conn: Connection | Cursor, imbuement_id: int) -> None:
24 | item_table = ItemTable.__table__
25 | imbuement_material_table = ImbuementMaterialTable.__table__
26 | q = (
27 | Query.into(imbuement_material_table)
28 | .columns(
29 | "imbuement_id",
30 | "item_id",
31 | "amount",
32 | )
33 | .insert(
34 | Parameter(":imbuement_id"),
35 | (
36 | Query.from_(item_table)
37 | .select(item_table.article_id)
38 | .where(item_table.title == Parameter(":item_title"))
39 | ),
40 | Parameter(":amount"),
41 | )
42 | )
43 | query_str = q.get_sql()
44 | with contextlib.suppress(IntegrityError):
45 | conn.execute(query_str, {"imbuement_id": imbuement_id} | self.model_dump(mode="json"))
46 |
47 | class ImbuementMaterial(RowModel, table=ImbuementMaterialTable):
48 | """Represents an item material for an imbuement."""
49 |
50 | imbuement_id: int
51 | """The article id of the imbuement this material belongs to."""
52 | imbuement_title: str | None = None
53 | """The title of the imbuement this material belongs to."""
54 | item_id: int | None = None
55 | """The article id of the item material."""
56 | item_title: str | None = None
57 | """The title of the item material."""
58 | amount: int
59 | """The amount of items required."""
60 |
61 |
62 | class Imbuement(WikiEntry, WithStatus, WithVersion, WithImage, RowModel, table=ImbuementTable):
63 | """Represents an imbuement type."""
64 |
65 | name: str
66 | """The name of the imbuement."""
67 | tier: str
68 | """The tier of the imbuement."""
69 | type: str
70 | """The imbuement's type."""
71 | category: str
72 | """The imbuement's category."""
73 | effect: str
74 | """The effect given by the imbuement."""
75 | slots: str
76 | """The type of items this imbuement may be applied on."""
77 | materials: list[Material] = Field(default_factory=list)
78 | """The materials needed for the imbuement."""
79 |
80 | def insert(self, conn):
81 | super().insert(conn)
82 | for material in self.materials:
83 | material.insert(conn, self.article_id)
84 |
85 | @classmethod
86 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None:
87 | imbuement: Self = super().get_one_by_field(conn, field, value, use_like)
88 | if imbuement is None:
89 | return None
90 | imbuement.materials = [Material(**dict(r)) for r in ImbuementMaterialTable.get_by_imbuement_id(conn, imbuement.article_id)]
91 | return imbuement
92 |
--------------------------------------------------------------------------------
/tibiawikisql/models/mount.py:
--------------------------------------------------------------------------------
1 |
2 | from tibiawikisql.api import WikiEntry
3 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion
4 | from tibiawikisql.schema import MountTable
5 |
6 |
7 | class Mount(WikiEntry, WithStatus, WithVersion, WithImage, RowModel, table=MountTable):
8 | """Represents a Mount."""
9 |
10 | name: str
11 | """The name of the mount."""
12 | speed: int
13 | """The speed given by the mount."""
14 | taming_method: str
15 | """A brief description on how the mount is obtained."""
16 | is_buyable: bool
17 | """Whether the mount can be bought from the store or not."""
18 | price: int | None
19 | """The price in Tibia coins to buy the mount."""
20 | achievement: str | None
21 | """The achievement obtained for obtaining this mount."""
22 | light_color: int | None
23 | """The color of the light emitted by this mount in RGB, if any."""
24 | light_radius: int | None
25 | """The radius of the light emitted by this mount, if any."""
26 |
27 |
--------------------------------------------------------------------------------
/tibiawikisql/models/outfit.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | from sqlite3 import Connection, Cursor, IntegrityError
3 | from typing import Any
4 |
5 | from pydantic import BaseModel, Field
6 | from pypika import Parameter, Query, Table
7 | from typing_extensions import Self
8 |
9 | from tibiawikisql.api import WikiEntry
10 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion
11 | from tibiawikisql.schema import OutfitImageTable, OutfitQuestTable, OutfitTable, QuestTable
12 |
13 | class UnlockQuest(BaseModel):
14 | """A quest that unlocks the outfit and/or its addons."""
15 |
16 | quest_id: int = None
17 | """The article id of the quest that gives the outfit or its addons."""
18 | quest_title: str
19 | """The title of the quest."""
20 | unlock_type: str
21 | """Whether the quest is for the outfit or addons."""
22 |
23 | def insert(self, conn: Connection | Cursor, outfit_id: int):
24 | quest_table = Table(QuestTable.__tablename__)
25 | oufit_quest_table = Table(OutfitQuestTable.__tablename__)
26 | q = (
27 | Query.into(oufit_quest_table)
28 | .columns(
29 | "outfit_id",
30 | "quest_id",
31 | "unlock_type",
32 | )
33 | .insert(
34 | Parameter(":outfit_id"),
35 | (
36 | Query.from_(quest_table)
37 | .select(quest_table.article_id)
38 | .where(quest_table.title == Parameter(":quest_title"))
39 | ),
40 | Parameter(":unlock_type"),
41 | )
42 | )
43 | query_str = q.get_sql()
44 | with contextlib.suppress(IntegrityError):
45 | conn.execute(query_str, {"outfit_id": outfit_id} | self.model_dump(mode="json"))
46 |
47 |
48 | class OutfitQuest(RowModel, table=OutfitQuestTable):
49 | """Represents a quest that grants an outfit or it's addon."""
50 |
51 | outfit_id: int
52 | """The article id of the outfit given."""
53 | outfit_title: str | None = None
54 | """The title of the outfit given."""
55 | quest_id: int | None = None
56 | """The article id of the quest that gives the outfit or its addons."""
57 | quest_title: str
58 | """The title of the quest."""
59 | unlock_type: str
60 | """Whether the quest is for the outfit or addons."""
61 |
62 |
63 | class OutfitImage(RowModel, WithImage, table=OutfitImageTable):
64 | """Represents an outfit image."""
65 |
66 | outfit_id: int
67 | """The article id of the outfit the image belongs to."""
68 | outfit_name: str
69 | """The name of the outfit."""
70 | sex: str
71 | """The sex the outfit is for."""
72 | addon: int
73 | """The addons represented by the image.
74 | 0 for no addons, 1 for first addon, 2 for second addon and 3 for both addons."""
75 |
76 |
77 | class Outfit(WikiEntry, WithStatus, WithVersion, RowModel, table=OutfitTable):
78 | """Represents an outfit."""
79 |
80 | name: str
81 | """The name of the outfit."""
82 | outfit_type: str
83 | """The type of outfit. Basic, Quest, Special, Premium."""
84 | is_premium: bool
85 | """Whether the outfit requires a premium account or not."""
86 | is_bought: bool
87 | """Whether the outfit can be bought from the Store or not."""
88 | is_tournament: bool
89 | """Whether the outfit can be bought with Tournament coins or not."""
90 | full_price: int | None
91 | """The full price of this outfit in the Tibia Store."""
92 | achievement: str | None
93 | """The achievement obtained for acquiring this full outfit."""
94 | images: list[OutfitImage] = Field(default_factory=list, exclude=True)
95 | """The outfit's images."""
96 | quests: list[UnlockQuest] = Field(default_factory=list)
97 | """Quests that grant the outfit or its addons."""
98 |
99 | def insert(self, conn: Connection | Cursor) -> None:
100 | super().insert(conn)
101 | for quest in self.quests:
102 | quest.insert(conn, self.article_id)
103 |
104 | @classmethod
105 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None:
106 | outfit: Self = super().get_one_by_field(conn, field, value, use_like)
107 | if outfit is None:
108 | return None
109 | outfit.quests = [UnlockQuest(**dict(r)) for r in OutfitQuestTable.get_list_by_outfit_id(conn, outfit.article_id)]
110 | return outfit
111 |
112 |
--------------------------------------------------------------------------------
/tibiawikisql/models/quest.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import sqlite3
3 | from sqlite3 import Connection, Cursor
4 | from typing import Any
5 |
6 | from pydantic import BaseModel, Field
7 | from pypika import Parameter, Query
8 | from typing_extensions import Self
9 |
10 | from tibiawikisql.api import WikiEntry
11 | from tibiawikisql.models.base import RowModel, WithStatus, WithVersion
12 | from tibiawikisql.schema import (
13 | CreatureTable,
14 | ItemTable,
15 | QuestDangerTable,
16 | QuestRewardTable,
17 | QuestTable,
18 | )
19 |
20 |
21 | class ItemReward(BaseModel):
22 | """An item awarded in the quest."""
23 | item_id: int = 0
24 | """The article id of the rewarded item."""
25 | item_title: str
26 | """The title of the rewarded item."""
27 |
28 | def insert(self, conn: Connection | Cursor, quest_id: int) -> None:
29 | quest_table = QuestRewardTable.__table__
30 | item_table = ItemTable.__table__
31 | q = (
32 | Query.into(quest_table)
33 | .columns(
34 | "quest_id",
35 | "item_id",
36 | )
37 | .insert(
38 | Parameter(":quest_id"),
39 | (
40 | Query.from_(item_table)
41 | .select(item_table.article_id)
42 | .where(item_table.title == Parameter(":item_title"))
43 | ),
44 | )
45 | )
46 |
47 | query_str = q.get_sql()
48 | parameters = {"quest_id": quest_id} | self.model_dump()
49 | with contextlib.suppress(sqlite3.IntegrityError):
50 | conn.execute(query_str, parameters)
51 |
52 | class QuestReward(RowModel, table=QuestRewardTable):
53 | """Represents an item obtained in the quest."""
54 |
55 | quest_id: int
56 | """The article id of the quest."""
57 | quest_title: str
58 | """The title of the quest."""
59 | item_id: int | None = None
60 | """The article id of the rewarded item."""
61 | item_title: str | None = None
62 | """The title of the rewarded item."""
63 |
64 | def insert(self, conn: sqlite3.Connection | sqlite3.Cursor) -> None:
65 | if self.item_id is not None:
66 | super().insert(conn)
67 | return
68 | quest_table = self.table.__table__
69 | item_table = ItemTable.__table__
70 | q = (
71 | Query.into(quest_table)
72 | .columns(
73 | "quest_id",
74 | "item_id",
75 | )
76 | .insert(
77 | Parameter(":quest_id"),
78 | (
79 | Query.from_(item_table)
80 | .select(item_table.article_id)
81 | .where(item_table.title == Parameter(":item_title"))
82 | ),
83 | )
84 | )
85 |
86 | query_str = q.get_sql()
87 | with contextlib.suppress(sqlite3.IntegrityError):
88 | conn.execute(query_str, self.model_dump(mode="json"))
89 |
90 |
91 | class QuestCreature(BaseModel):
92 | """Represents a creature found in the quest."""
93 |
94 | creature_id: int = 0
95 | """The article id of the found creature."""
96 | creature_title: str
97 | """The title of the found creature."""
98 |
99 | def insert(self, conn: Connection | Cursor, quest_id: int) -> None:
100 | quest_table = QuestDangerTable.__table__
101 | creature_table = CreatureTable.__table__
102 | q = (
103 | Query.into(quest_table)
104 | .columns(
105 | "quest_id",
106 | "creature_id",
107 | )
108 | .insert(
109 | Parameter(":quest_id"),
110 | (
111 | Query.from_(creature_table)
112 | .select(creature_table.article_id)
113 | .where(creature_table.title == Parameter(":creature_title"))
114 | ),
115 | )
116 | )
117 |
118 | query_str = q.get_sql()
119 | parameters = {"quest_id": quest_id} | self.model_dump()
120 | with contextlib.suppress(sqlite3.IntegrityError):
121 | conn.execute(query_str, parameters)
122 |
123 | class QuestDanger(RowModel, table=QuestDangerTable):
124 | """Represents a creature found in the quest."""
125 |
126 | quest_id: int
127 | """The article id of the quest."""
128 | quest_title: str
129 | """The title of the quest."""
130 | creature_id: int | None = None
131 | """The article id of the found creature."""
132 | creature_title: str | None = None
133 | """The title of the found creature."""
134 |
135 | def insert(self, conn: sqlite3.Connection | sqlite3.Cursor) -> None:
136 | if self.creature_id is not None:
137 | super().insert(conn)
138 | return
139 | quest_table = self.table.__table__
140 | creature_table = CreatureTable.__table__
141 | q = (
142 | Query.into(quest_table)
143 | .columns(
144 | "quest_id",
145 | "creature_id",
146 | )
147 | .insert(
148 | Parameter(":quest_id"),
149 | (
150 | Query.from_(creature_table)
151 | .select(creature_table.article_id)
152 | .where(creature_table.title == Parameter(":creature_title"))
153 | ),
154 | )
155 | )
156 |
157 | query_str = q.get_sql()
158 | with contextlib.suppress(sqlite3.IntegrityError):
159 | conn.execute(query_str, self.model_dump(mode="json"))
160 |
161 |
162 |
163 | class Quest(WikiEntry, WithStatus, WithVersion, RowModel, table=QuestTable):
164 | """Represents a quest."""
165 |
166 | name: str
167 | """The name of the quest."""
168 | location: str | None
169 | """The location of the quest."""
170 | is_rookgaard_quest: bool
171 | """Whether this quest is in Rookgaard or not."""
172 | is_premium: bool
173 | """Whether this quest requires a Premium account or not."""
174 | type: str | None
175 | """The type of quest."""
176 | quest_log: bool | None
177 | """Whether this quest is registered in the quest log or not."""
178 | legend: str | None
179 | """The legend of the quest."""
180 | level_required: int | None
181 | """The level required to finish the quest."""
182 | level_recommended: int | None
183 | """The recommended level to finish the quest."""
184 | active_time: str | None
185 | """Times of the year when this quest is active."""
186 | estimated_time: str | None
187 | """Estimated time to finish this quest."""
188 | dangers: list[QuestCreature]= Field(default_factory=list)
189 | """Creatures found in the quest."""
190 | rewards: list[ItemReward] = Field(default_factory=list)
191 | """Items rewarded in the quest."""
192 |
193 |
194 | def insert(self, conn: sqlite3.Connection | sqlite3.Cursor) -> None:
195 | super().insert(conn)
196 | for reward in self.rewards:
197 | reward.insert(conn, self.article_id)
198 | for danger in self.dangers:
199 | danger.insert(conn, self.article_id)
200 |
201 | @classmethod
202 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None:
203 | quest: Self = super().get_one_by_field(conn, field, value, use_like)
204 | if quest is None:
205 | return None
206 | quest.rewards = [ItemReward(**dict(r)) for r in QuestRewardTable.get_list_by_quest_id(conn, quest.article_id)]
207 | quest.dangers = [QuestCreature(**dict(r)) for r in QuestDangerTable.get_list_by_quest_id(conn, quest.article_id)]
208 | return quest
209 |
--------------------------------------------------------------------------------
/tibiawikisql/models/spell.py:
--------------------------------------------------------------------------------
1 | from sqlite3 import Connection, Cursor
2 | from typing import Any
3 |
4 | from pydantic import BaseModel, Field
5 | from typing_extensions import Self
6 |
7 | from tibiawikisql.api import WikiEntry
8 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion
9 | from tibiawikisql.schema import NpcSpellTable, SpellTable
10 |
11 |
12 | class SpellTeacher(BaseModel):
13 | """An NPC that teaches the spell.
14 |
15 | Note that even if the spell can be learned by multiple vocations, an NPC might only teach it to a specific vocation.
16 | """
17 |
18 | npc_id: int
19 | """The article ID of the NPC that teaches it."""
20 | npc_title: str
21 | """The title of the NPC that teaches it."""
22 | npc_city: str
23 | """The city where the NPC is located."""
24 | knight: bool
25 | """If the NPC teaches the spell to knights."""
26 | paladin: bool
27 | """If the NPC teaches the spell to paladins."""
28 | druid: bool
29 | """If the NPC teaches the spell to druids."""
30 | sorcerer: bool
31 | """If the NPC teaches the spell to sorcerers."""
32 | monk: bool
33 | """If the NPC teaches the spell to monks."""
34 |
35 |
36 | class Spell(WikiEntry, WithVersion, WithStatus, WithImage, RowModel, table=SpellTable):
37 | """Represents a Spell."""
38 |
39 | name: str
40 | """The name of the spell."""
41 | words: str | None = Field(None)
42 | """The spell's invocation words."""
43 | effect: str
44 | """The effects of casting the spell."""
45 | spell_type: str
46 | """The spell's type."""
47 | group_spell: str
48 | """The spell's group."""
49 | group_secondary: str | None = Field(None)
50 | """The spell's secondary group."""
51 | group_rune: str | None = Field(None)
52 | """The group of the rune created by this spell."""
53 | element: str | None = Field(None)
54 | """The element of the damage made by the spell."""
55 | mana: int
56 | """The mana cost of the spell."""
57 | soul: int
58 | """The soul cost of the spell."""
59 | price: int | None = None
60 | """The gold cost of the spell."""
61 | cooldown: int
62 | """The spell's individual cooldown in seconds."""
63 | cooldown2: int | None
64 | """The spell's individual cooldown for the level 2 perk of the Wheel of Destiny."""
65 | cooldown3: int | None
66 | """The spell's individual cooldown for the level 3 perk of the Wheel of Destiny."""
67 | cooldown_group: int | None = Field(None)
68 | """The spell's group cooldown in seconds. The time you have to wait before casting another spell in the same group."""
69 | cooldown_group_secondary: int | None = Field(None)
70 | """The spell's secondary group cooldown."""
71 | level: int
72 | """The level required to use the spell."""
73 | is_premium: bool
74 | """Whether the spell is premium only or not."""
75 | is_promotion: bool
76 | """Whether you need to be promoted to buy or cast this spell."""
77 | is_wheel_spell: bool
78 | """Whether this spell is acquired through the Wheel of Destiny."""
79 | is_passive: bool
80 | """Whether this spell is triggered automatically without casting."""
81 | knight: bool = Field(False)
82 | """Whether the spell can be used by knights or not."""
83 | paladin: bool = Field(False)
84 | """Whether the spell can be used by paladins or not."""
85 | druid: bool = Field(False)
86 | """Whether the spell can be used by druids or not."""
87 | sorcerer: bool = Field(False)
88 | """Whether the spell can be used by sorcerers or not."""
89 | monk: bool = Field(False)
90 | """Whether the spell can be used by monks or not."""
91 | taught_by: list[SpellTeacher] = Field(default_factory=list)
92 | """NPCs that teach this spell."""
93 |
94 | @classmethod
95 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None:
96 | spell: Self = super().get_one_by_field(conn, field, value, use_like)
97 | if spell is None:
98 | return spell
99 | spell.taught_by = [SpellTeacher(**dict(r)) for r in NpcSpellTable.get_by_spell_id(conn, spell.article_id)]
100 | return spell
101 |
--------------------------------------------------------------------------------
/tibiawikisql/models/update.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from tibiawikisql.api import WikiEntry
4 | from tibiawikisql.models.base import RowModel, WithVersion
5 | from tibiawikisql.schema import UpdateTable
6 |
7 |
8 | class Update(WikiEntry, WithVersion, RowModel, table=UpdateTable):
9 | """Represents a game update."""
10 |
11 | name: str | None
12 | """The name of the update, if any."""
13 | news_id: int | None
14 | """The id of the news article that announced the release."""
15 | release_date: datetime.date
16 | """The date when the update was released."""
17 | type_primary: str
18 | """The primary type of the update."""
19 | type_secondary: str | None
20 | """The secondary type of the update."""
21 | previous: str | None
22 | """The version before this update."""
23 | next: str | None
24 | """The version after this update."""
25 | summary: str | None
26 | """A brief summary of the update."""
27 | changes: str | None
28 | """A brief list of the changes introduced."""
29 |
--------------------------------------------------------------------------------
/tibiawikisql/models/world.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from tibiawikisql.api import WikiEntry
4 | from tibiawikisql.models.base import RowModel
5 | from tibiawikisql.schema import WorldTable
6 |
7 |
8 | class World(WikiEntry, RowModel, table=WorldTable):
9 | """Represents a Game World."""
10 |
11 | name: str
12 | """The name of the world."""
13 | pvp_type: str
14 | """The world's PvP type."""
15 | location: str
16 | """The world's server's physical location."""
17 | is_preview: bool
18 | """Whether the world is a preview world or not."""
19 | is_experimental: bool
20 | """Whether the world is a experimental world or not."""
21 | online_since: datetime.date
22 | """Date when the world became online for the first time."""
23 | offline_since: datetime.date | None
24 | """Date when the world went offline."""
25 | merged_into: str | None
26 | """The name of the world this world got merged into, if applicable."""
27 | battleye: bool
28 | """Whether the world is BattlEye protected or not."""
29 | battleye_type: str | None
30 | """The type of BattlEye protection the world has. Can be either green or yellow."""
31 | protected_since: datetime.date | None
32 | """Date when the world started being protected by BattlEye."""
33 | world_board: int | None
34 | """The board ID for the world's board."""
35 | trade_board: int | None
36 | """The board ID for the world's trade board."""
37 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/__init__.py:
--------------------------------------------------------------------------------
1 | from tibiawikisql.parsers.base import BaseParser, AttributeParser
2 | from tibiawikisql.parsers.achievement import AchievementParser
3 | from tibiawikisql.parsers.charm import CharmParser
4 | from tibiawikisql.parsers.spell import SpellParser
5 | from tibiawikisql.parsers.item import ItemParser
6 | from tibiawikisql.parsers.creature import CreatureParser
7 | from tibiawikisql.parsers.book import BookParser
8 | from tibiawikisql.parsers.key import KeyParser
9 | from tibiawikisql.parsers.npc import NpcParser
10 | from tibiawikisql.parsers.imbuement import ImbuementParser
11 | from tibiawikisql.parsers.quest import QuestParser
12 | from tibiawikisql.parsers.house import HouseParser
13 | from tibiawikisql.parsers.outfit import OutfitParser
14 | from tibiawikisql.parsers.world import WorldParser
15 | from tibiawikisql.parsers.mount import MountParser
16 | from tibiawikisql.parsers.update import UpdateParser
17 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/achievement.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from tibiawikisql.models.achievement import Achievement
4 | from tibiawikisql.parsers.base import AttributeParser, BaseParser
5 | from tibiawikisql.schema import AchievementTable
6 | from tibiawikisql.utils import clean_links, parse_boolean, parse_integer
7 |
8 |
9 | class AchievementParser(BaseParser):
10 | """Parser for achievements."""
11 |
12 | model = Achievement
13 | table = AchievementTable
14 | template_name = "Infobox_Achievement"
15 | attribute_map: ClassVar = {
16 | "name": AttributeParser(lambda x: x.get("actualname") or x.get("name")),
17 | "grade": AttributeParser.optional("grade", parse_integer),
18 | "points": AttributeParser.optional("points", parse_integer),
19 | "is_premium": AttributeParser.optional("premium", parse_boolean, False),
20 | "is_secret": AttributeParser.optional("secret", parse_boolean, False),
21 | "description": AttributeParser.required("description", clean_links),
22 | "spoiler": AttributeParser.optional("spoiler", clean_links),
23 | "achievement_id": AttributeParser.optional("achievementid", parse_integer),
24 | "version": AttributeParser.optional("implemented"),
25 | "status": AttributeParser.status(),
26 | }
27 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/base.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 | from typing import Any, ClassVar, Generic, TypeVar
3 |
4 | import pydantic
5 | from pydantic import ValidationError
6 | from typing_extensions import Self
7 |
8 | import tibiawikisql.database
9 | from tibiawikisql.api import Article
10 | from tibiawikisql.database import Table
11 | from tibiawikisql.errors import (
12 | ArticleParsingError,
13 | AttributeParsingError,
14 | TemplateNotFoundError,
15 | )
16 | from tibiawikisql.models.base import RowModel
17 | from tibiawikisql.utils import parse_templatates_data
18 |
19 | M = TypeVar("M", bound=RowModel)
20 | P = TypeVar("P", bound=pydantic.BaseModel)
21 | T = TypeVar("T")
22 | D = TypeVar("D")
23 |
24 |
25 | class AttributeParser(Generic[T]):
26 | """Defines how to parser an attribute from a Wiki article into a python object."""
27 |
28 | def __init__(self, func: Callable[[dict[str, str]], T], fallback: D = ...) -> None:
29 | """Create an instance of the class.
30 |
31 | Args:
32 | func: A callable that takes the template's attributes as a parameter and returns a value.
33 | fallback: Fallback value to set if the value is not found or the callable failed.
34 |
35 | """
36 | self.func = func
37 | self.fallback = fallback
38 |
39 | def __call__(self, attributes: dict[str, str]) -> T | D:
40 | """Perform parsing on the defined attribute.
41 |
42 | Args:
43 | attributes: The template attributes.
44 |
45 | Returns:
46 | The result of the parser's function or the fallback value if applicable.
47 |
48 | Raises:
49 | AttributeParsingError: If the parser function fails and no fallback was provided.
50 | """
51 | try:
52 | return self.func(attributes)
53 | except Exception as e:
54 | if self.fallback is Ellipsis:
55 | raise AttributeParsingError(e) from e
56 | return self.fallback
57 |
58 | @classmethod
59 | def required(cls, field_name: str, post_process: Callable[[str], T] = str.strip) -> Self:
60 | """Define a required attribute.
61 |
62 | Args:
63 | field_name: The name of the template attribute in the wiki.
64 | post_process: A function to call on the attribute's value.
65 |
66 | Returns:
67 | An attribute parser expecting a required value.
68 |
69 | """
70 | return cls(lambda x: post_process(x[field_name]))
71 |
72 | @classmethod
73 | def optional(cls, field_name: str, post_process: Callable[[str], T | None] = str.strip, default: T | None = None) -> Self:
74 | """Create optional attribute parser. Will fall back to None.
75 |
76 | Args:
77 | field_name: The name of the template attribute in the wiki.
78 | post_process: A function to call on the attribute's value.
79 | default:
80 |
81 | Returns:
82 | An attribute parser for an optional value.
83 |
84 | """
85 | return cls(lambda x: post_process(x[field_name]), default)
86 |
87 |
88 | @classmethod
89 | def status(cls) -> Self:
90 | """Create a parser for the commonly found "status" parameter.
91 |
92 | Returns:
93 | An attribute parser for the status parameter, falling back to "active" if not found.
94 |
95 | """
96 | return cls(lambda x: x.get("status").lower(), "active")
97 |
98 | @classmethod
99 | def version(cls) -> Self:
100 | """Create a parser for the commonly found "implemented" parameter.
101 |
102 | Returns:
103 | An attribute parser for the implemented parameter.
104 |
105 | """
106 | return cls(lambda x: x.get("implemented").lower())
107 |
108 |
109 | class ParserMeta(type):
110 | """Metaclass for all parsers."""
111 |
112 | registry: ClassVar[dict[str, type["BaseParser"]]] = {}
113 |
114 | def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> type:
115 | cls = super().__new__(mcs, name, bases, namespace)
116 |
117 | if name == "BaseParser":
118 | return cls
119 | required_attrs = (
120 | ("template_name", str, False),
121 | ("attribute_map", dict, False),
122 | ("model", RowModel, True),
123 | ("table", tibiawikisql.database.Table, True),
124 | )
125 | for attr, expected_type, is_class in required_attrs:
126 | value = getattr(cls, attr, NotImplemented)
127 | if value is NotImplemented:
128 | msg = f"{name} must define `{attr}`"
129 | raise NotImplementedError(msg)
130 | if is_class:
131 | if not isinstance(value, type) or not issubclass(value, expected_type):
132 | msg = f"{name}.{attr} must be a subclass of {expected_type.__name__}"
133 | raise TypeError(msg)
134 | elif not isinstance(value, expected_type):
135 | msg = f"{name}.{attr} must be of type {expected_type.__name__}"
136 | raise TypeError(msg)
137 |
138 | template_name = getattr(cls, "template_name") # noqa: B009
139 | if not isinstance(template_name, str) or not template_name:
140 | msg = f"{name} must define a non-empty string for `template_name`."
141 | raise ValueError(msg)
142 |
143 | # Register the parser class
144 | if template_name in ParserMeta.registry:
145 | msg = f"Duplicate parser for template '{template_name}'."
146 | raise ValueError(msg)
147 | ParserMeta.registry[template_name] = cls
148 | return cls
149 |
150 |
151 | class BaseParser(metaclass=ParserMeta):
152 | """Base class that defines how to extract information from a Wiki template into a model."""
153 |
154 | template_name: ClassVar[str] = NotImplemented
155 | """The name of the template that contains the information."""
156 |
157 | model: ClassVar[type[RowModel]] = NotImplemented
158 | """The model to convert the data into."""
159 |
160 | table: ClassVar[type[Table]] = NotImplemented
161 | """The SQL table where the data wil be stored."""
162 |
163 | attribute_map: ClassVar[dict[str, AttributeParser]] = NotImplemented
164 | """A map defining how to process every template attribute."""
165 |
166 |
167 | @classmethod
168 | def parse_attributes(cls, article: Article) -> dict[str, Any]:
169 | """Parse the attributes of an article into a mapping.
170 |
171 | By default, it will apply the attribute map, but it can be overridden to parse attributes in more complex ways.
172 | It is called by `parse_article`.
173 |
174 | Args:
175 | article: The article to extract the data from.
176 |
177 | Returns:
178 | A dictionary containing the parsed attribute values.
179 |
180 | Raises:
181 | AttributeParsingError: If the required template is not found.
182 |
183 | """
184 | templates = parse_templatates_data(article.content)
185 | if cls.template_name not in templates:
186 | raise TemplateNotFoundError(article, cls)
187 | attributes = templates[cls.template_name]
188 | row = {
189 | "article_id": article.article_id,
190 | "timestamp": article.timestamp,
191 | "title": article.title,
192 | "_raw_attributes": attributes,
193 | }
194 | try:
195 | for field, parser in cls.attribute_map.items():
196 | row[field] = parser(attributes)
197 | except AttributeParsingError as e:
198 | raise ArticleParsingError(article, e) from e
199 | return row
200 |
201 | @classmethod
202 | def from_article(cls, article: Article) -> M:
203 | """Parse an article into a TibiaWiki model.
204 |
205 | Args:
206 | article: The article from where the model is parsed.
207 |
208 | Returns:
209 | An inherited model object for the current article.
210 |
211 | """
212 | row = cls.parse_attributes(article)
213 | try:
214 | return cls.model.model_validate(row)
215 | except ValidationError as e:
216 | raise ArticleParsingError(article, cause=e) from e
217 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/book.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from tibiawikisql.models.item import Book
4 | from tibiawikisql.parsers import BaseParser
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.schema import BookTable
7 | from tibiawikisql.utils import clean_links
8 |
9 |
10 | class BookParser(BaseParser):
11 | """Parser for book articles."""
12 |
13 | model = Book
14 | table = BookTable
15 | template_name = "Infobox_Book"
16 | attribute_map: ClassVar = {
17 | "name": AttributeParser.required("title"),
18 | "book_type": AttributeParser.optional("booktype", clean_links),
19 | "location": AttributeParser.optional("location", lambda x: clean_links(x, True)),
20 | "blurb": AttributeParser.optional("blurb", lambda x: clean_links(x, True)),
21 | "author": AttributeParser.optional("author", lambda x: clean_links(x, True)),
22 | "prev_book": AttributeParser.optional("prevbook"),
23 | "next_book": AttributeParser.optional("nextbook"),
24 | "text": AttributeParser.required("text", clean_links),
25 | "version": AttributeParser.optional("implemented"),
26 | "status": AttributeParser.status(),
27 | }
28 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/charm.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from tibiawikisql.schema import CharmTable
4 | from tibiawikisql.models.charm import Charm
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.utils import clean_links, parse_integer
8 |
9 |
10 | class CharmParser(BaseParser):
11 | """Parser for charms."""
12 | model = Charm
13 | table = CharmTable
14 | template_name = "Infobox_Charm"
15 | attribute_map: ClassVar = {
16 | "name": AttributeParser(lambda x: x.get("actualname") or x.get("name")),
17 | "type": AttributeParser(lambda x: x.get("type")),
18 | "effect": AttributeParser(lambda x: clean_links(x.get("effect"))),
19 | "cost": AttributeParser(lambda x: parse_integer(x.get("cost"))),
20 | "version": AttributeParser(lambda x: x.get("implemented"), None),
21 | "status": AttributeParser.status(),
22 | }
23 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/house.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | import tibiawikisql.schema
4 | from tibiawikisql.models.house import House
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.utils import clean_links, convert_tibiawiki_position, parse_integer
8 |
9 |
10 | class HouseParser(BaseParser):
11 | """Parses houses and guildhalls."""
12 | model = House
13 | table = tibiawikisql.schema.HouseTable
14 | template_name = "Infobox_Building"
15 | attribute_map: ClassVar = {
16 | "house_id": AttributeParser.required("houseid", parse_integer),
17 | "name": AttributeParser.required("name"),
18 | "is_guildhall": AttributeParser.required("type", lambda x: x is not None and "guildhall" in x.lower()),
19 | "city": AttributeParser.required("city"),
20 | "street": AttributeParser.optional("street"),
21 | "location": AttributeParser.optional("location", clean_links),
22 | "beds": AttributeParser.required("beds", parse_integer),
23 | "rent": AttributeParser.required("rent", parse_integer),
24 | "size": AttributeParser.required("size", parse_integer),
25 | "rooms": AttributeParser.optional("rooms", parse_integer),
26 | "floors": AttributeParser.optional("floors", parse_integer),
27 | "x": AttributeParser.optional("posx", convert_tibiawiki_position),
28 | "y": AttributeParser.optional("posy", convert_tibiawiki_position),
29 | "z": AttributeParser.optional("posz", int),
30 | "version": AttributeParser.version(),
31 | "status": AttributeParser.status(),
32 | }
33 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/imbuement.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Any, ClassVar
3 |
4 | from tibiawikisql.api import Article
5 | from tibiawikisql.models.imbuement import Imbuement, Material
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.schema import ImbuementTable
8 | from tibiawikisql.parsers.base import AttributeParser
9 |
10 | astral_pattern = re.compile(r"\s*([^:]+):\s*(\d+),*")
11 | effect_pattern = re.compile(r"Effect/([^|]+)\|([^}|]+)")
12 |
13 |
14 | def parse_astral_sources(content: str) -> dict[str, int]:
15 | """Parse the astral sources of an imbuement.
16 |
17 | Args:
18 | content: A string containing astral sources.
19 |
20 | Returns:
21 | A dictionary containing the material name and te amount required.
22 |
23 | """
24 | materials = astral_pattern.findall(content)
25 | if materials:
26 | return {item: int(amount) for (item, amount) in materials}
27 | return {}
28 |
29 |
30 | effect_map = {
31 | "Bash": "Club fighting +{}",
32 | "Punch": "Fist fighting +{}",
33 | "Chop": "Axe fighting +{}",
34 | "Slash": "Sword fighting +{}",
35 | "Precision": "Distance fighting +{}",
36 | "Blockade": "Shielding +{}",
37 | "Epiphany": "Magic level +{}",
38 | "Scorch": "Fire damage {}",
39 | "Venom": "Earth damage {}",
40 | "Frost": "Ice damage {}",
41 | "Electrify": "Energy damage {}",
42 | "Reap": "Death damage {}",
43 | "Vampirism": "Life leech {}",
44 | "Void": " Mana leech {}",
45 | "Strike": " Critical hit damage {}",
46 | "Lich Shroud": "Death protection {}",
47 | "Snake Skin": "Earth protection {}",
48 | "Quara Scale": "Ice protection {}",
49 | "Dragon Hide": "Fire protection {}",
50 | "Cloud Fabric": "Energy protection {}",
51 | "Demon Presence": "Holy protection {}",
52 | "Swiftness": "Speed +{}",
53 | "Featherweight": "Capacity +{}",
54 | "Vibrancy": "Remove paralysis chance {}",
55 | }
56 |
57 |
58 | def parse_effect(effect: str) -> str:
59 | """Parse TibiaWiki's effect template into a string effect.
60 |
61 | Args:
62 | effect: The string containing the template.
63 |
64 | Returns:
65 | The effect string.
66 |
67 | """
68 | m = effect_pattern.search(effect)
69 | category, amount = m.groups()
70 | try:
71 | return effect_map[category].format(amount)
72 | except KeyError:
73 | return f"{category} {amount}"
74 |
75 |
76 | def parse_slots(content: str) -> str:
77 | """Parse the list of slots.
78 |
79 | Cleans up spaces between items.
80 |
81 | Args:
82 | content: A string containing comma separated values.
83 |
84 | Returns:
85 | The slots string.
86 |
87 | """
88 | slots = content.split(",")
89 | return ",".join(s.strip() for s in slots)
90 |
91 |
92 | class ImbuementParser(BaseParser):
93 | """Parses imbuements."""
94 | model = Imbuement
95 | table = ImbuementTable
96 | template_name = "Infobox_Imbuement"
97 | attribute_map: ClassVar = {
98 | "name": AttributeParser.required("name"),
99 | "tier": AttributeParser.required("prefix"),
100 | "type": AttributeParser.required("type"),
101 | "category": AttributeParser.required("category"),
102 | "effect": AttributeParser.required("effect", parse_effect),
103 | "version": AttributeParser.required("implemented"),
104 | "slots": AttributeParser.required("slots", parse_slots),
105 | "status": AttributeParser.status(),
106 | }
107 |
108 | @classmethod
109 | def parse_attributes(cls, article: Article) -> dict[str, Any]:
110 | row = super().parse_attributes(article)
111 | if not row:
112 | return row
113 | raw_attributes = row["_raw_attributes"]
114 | if "astralsources" in raw_attributes:
115 | materials = parse_astral_sources(raw_attributes["astralsources"])
116 | row["materials"] = [Material(item_title=name, amount=amount) for name, amount in materials.items()]
117 | return row
118 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/item.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Any, ClassVar
3 |
4 | from tibiawikisql.api import Article
5 | from tibiawikisql.models.item import Item, ItemAttribute, ItemStoreOffer
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.parsers.base import AttributeParser
8 | from tibiawikisql.schema import ItemTable
9 | from tibiawikisql.utils import (
10 | clean_links,
11 | clean_question_mark,
12 | client_color_to_rgb,
13 | find_templates,
14 | parse_boolean,
15 | parse_float,
16 | parse_integer,
17 | parse_sounds,
18 | strip_code,
19 | )
20 |
21 |
22 | class ItemParser(BaseParser):
23 | """Parses items and objects."""
24 | model = Item
25 | table = ItemTable
26 | template_name = "Infobox_Object"
27 | attribute_map: ClassVar = {
28 | "name": AttributeParser.required("name"),
29 | "actual_name": AttributeParser.optional("actualname"),
30 | "plural": AttributeParser.optional("plural", clean_question_mark),
31 | "article": AttributeParser.optional("article"),
32 | "is_marketable": AttributeParser.optional("marketable", parse_boolean, False),
33 | "is_stackable": AttributeParser.optional("stackable", parse_boolean, False),
34 | "is_pickupable": AttributeParser.optional("pickupable", parse_boolean, False),
35 | "is_immobile": AttributeParser.optional("immobile", parse_boolean, False),
36 | "value_sell": AttributeParser.optional("npcvalue", parse_integer),
37 | "value_buy": AttributeParser.optional("npcprice", parse_integer),
38 | "weight": AttributeParser.optional("weight", parse_float),
39 | "flavor_text": AttributeParser.optional("flavortext"),
40 | "item_class": AttributeParser.optional("objectclass"),
41 | "item_type": AttributeParser.optional("primarytype"),
42 | "type_secondary": AttributeParser.optional("secondarytype"),
43 | "light_color": AttributeParser.optional("lightcolor", lambda x: client_color_to_rgb(parse_integer(x))),
44 | "light_radius": AttributeParser.optional("lightradius", parse_integer),
45 | "version": AttributeParser.optional("implemented"),
46 | "client_id": AttributeParser.optional("itemid", parse_integer),
47 | "status": AttributeParser.status(),
48 | }
49 |
50 | item_attributes: ClassVar = {
51 | "level": "levelrequired",
52 | "vocation": "vocrequired",
53 | "attack": "attack",
54 | "defense": "defense",
55 | "defense_modifier": "defensemod",
56 | "armor": "armor",
57 | "hands": "hands",
58 | "imbue_slots": "imbueslots",
59 | "imbuements": "imbuements",
60 | "attack+": "atk_mod",
61 | "hit%+": "hit_mod",
62 | "range": "range",
63 | "damage_type": "damagetype",
64 | "damage_range": "damagerange",
65 | "mana_cost": "manacost",
66 | "magic_level": "mlrequired",
67 | "words": "words",
68 | "critical_chance": "crithit_ch",
69 | "critical%": "critextra_dmg",
70 | "hpleech_chance": "hpleech_ch",
71 | "hpleech%": "hpleech_am",
72 | "manaleech_chance": "manaleech_ch",
73 | "manaleech%": "manaleech_am",
74 | "volume": "volume",
75 | "charges": "charges",
76 | "food_time": "regenseconds",
77 | "duration": "duration",
78 | "fire_attack": "fire_attack",
79 | "energy_attack": "energy_attack",
80 | "ice_attack": "ice_attack",
81 | "earth_attack": "earth_attack",
82 | "weapon_type": "weapontype",
83 | "destructible": "destructible",
84 | "holds_liquid": "holdsliquid",
85 | "is_hangable": "hangable",
86 | "is_writable": "writable",
87 | "is_rewritable": "rewritable",
88 | "writable_chars": "writechars",
89 | "is_consumable": "consumable",
90 | "fansite": "fansite",
91 | "blocks_projectiles": "unshootable",
92 | "blocks_path": "blockspath",
93 | "is_walkable": "walkable",
94 | "tile_friction": "walkingspeed",
95 | "map_color": "mapcolor",
96 | "upgrade_classification": "upgradeclass",
97 | "is_rotatable": "rotatable",
98 | "augments": "augments",
99 | "elemental_bond": "elementalbond",
100 | }
101 |
102 | @classmethod
103 | def parse_attributes(cls, article: Article) -> dict[str, Any]:
104 | row = super().parse_attributes(article)
105 | row["attributes"] = []
106 | for name, attribute in cls.item_attributes.items():
107 | if attribute in row["_raw_attributes"] and row["_raw_attributes"][attribute]:
108 | row["attributes"].append(ItemAttribute(
109 | name=name,
110 | value=clean_links(row["_raw_attributes"][attribute]),
111 | ))
112 | cls.parse_item_attributes(row)
113 | cls.parse_resistances(row)
114 | cls.parse_sounds(row)
115 | cls.parse_store_value(row)
116 | return row
117 |
118 | @classmethod
119 | def parse_item_attributes(cls, row: dict[str, Any]):
120 | raw_attributes = row["_raw_attributes"]
121 | attributes = row["attributes"]
122 | if "attrib" not in raw_attributes:
123 | return
124 | attribs = raw_attributes["attrib"].split(",")
125 | for attr in attribs:
126 | attr = attr.strip()
127 | m = re.search(r"([\s\w]+)\s([+\-\d]+)", attr)
128 | if "perfect shot" in attr.lower():
129 | numbers = re.findall(r"(\d+)", attr)
130 | if len(numbers) == 2:
131 | attributes.extend([
132 | ItemAttribute(name="perfect_shot", value=f"+{numbers[0]}"),
133 | ItemAttribute(name="perfect_shot_range", value=numbers[1]),
134 | ])
135 | continue
136 | if "damage reflection" in attr.lower():
137 | value = parse_integer(attr)
138 | attributes.append(ItemAttribute(name="damage_reflection", value=str(value)))
139 | if "damage reflection" in attr.lower():
140 | value = parse_integer(attr)
141 | attributes.append(ItemAttribute(name="damage_reflection", value=str(value)))
142 | if "magic shield capacity" in attr.lower():
143 | numbers = re.findall(r"(\d+)", attr)
144 | if len(numbers) == 2:
145 | attributes.extend([
146 | ItemAttribute(name="magic_shield_capacity", value=f"+{numbers[0]}"),
147 | ItemAttribute(name="magic_shield_capacity%", value=f"{numbers[1]}%"),
148 | ])
149 | continue
150 | if m:
151 | attribute = m.group(1).replace("fighting", "").replace("level", "").strip().replace(" ", "_").lower()
152 | value = m.group(2)
153 | attributes.append(ItemAttribute(name=attribute.lower(), value=value))
154 | if "regeneration" in attr:
155 | attributes.append(ItemAttribute(name="regeneration", value="faster regeneration"))
156 |
157 | @classmethod
158 | def parse_resistances(cls, row):
159 | raw_attributes = row["_raw_attributes"]
160 | attributes = row["attributes"]
161 | if "resist" not in raw_attributes:
162 | return
163 | resistances = raw_attributes["resist"].split(",")
164 | for element in resistances:
165 | element = element.strip()
166 | m = re.search(r"([a-zA-Z0-9_ ]+) +(-?\+?\d+)%", element)
167 | if not m:
168 | continue
169 | attribute = m.group(1) + "%"
170 | try:
171 | value = int(m.group(2))
172 | except ValueError:
173 | value = 0
174 | attributes.append(ItemAttribute(name=attribute, value=str(value)))
175 |
176 | @classmethod
177 | def parse_sounds(cls, row):
178 | if "sounds" not in row["_raw_attributes"]:
179 | return
180 | row["sounds"] = parse_sounds(row["_raw_attributes"]["sounds"])
181 |
182 | @classmethod
183 | def parse_store_value(self, row):
184 | if "storevalue" not in row["_raw_attributes"]:
185 | return
186 | templates = find_templates(row["_raw_attributes"]["storevalue"], "Store Product", recursive=True)
187 | row["store_offers"] = []
188 | for template in templates:
189 | price = int(strip_code(template.get(1, 0)))
190 | currency = strip_code(template.get(2, "Tibia Coin"))
191 | amount = int(strip_code(template.get("amount", 1)))
192 | row["store_offers"].append(
193 | ItemStoreOffer(price=price, currency=currency, amount=amount),
194 | )
195 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/key.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | import tibiawikisql.schema
4 | from tibiawikisql.models.item import Key
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.utils import clean_links, parse_integer
8 |
9 |
10 | class KeyParser(BaseParser):
11 | """Parser for keys."""
12 | model = Key
13 | table = tibiawikisql.schema.ItemKeyTable
14 | template_name = "Infobox_Key"
15 | attribute_map: ClassVar = {
16 | "name": AttributeParser.optional("aka", clean_links),
17 | "number": AttributeParser.optional("number", parse_integer),
18 | "material": AttributeParser.optional("primarytype"),
19 | "location": AttributeParser.optional("location", clean_links),
20 | "notes": AttributeParser.optional("shortnotes", clean_links),
21 | "origin": AttributeParser.optional("origin", clean_links),
22 | "status": AttributeParser.status(),
23 | "version": AttributeParser.optional("implemented", clean_links),
24 | }
25 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/mount.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | import tibiawikisql.schema
4 | from tibiawikisql.models.mount import Mount
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.utils import clean_links, client_color_to_rgb, parse_boolean, parse_integer
8 |
9 |
10 | def remove_mount(name: str) -> str:
11 | """Remove "(Mount)" from the name, if found.
12 |
13 | Args:
14 | name: The name to check.
15 |
16 | Returns:
17 | The name with "(Mount)" removed from it.
18 |
19 | """
20 | return name.replace("(Mount)", "").strip()
21 |
22 | class MountParser(BaseParser):
23 | """Parser for mounts."""
24 |
25 | model = Mount
26 | table = tibiawikisql.schema.MountTable
27 | template_name = "Infobox_Mount"
28 | attribute_map: ClassVar = {
29 | "name": AttributeParser.required("name", remove_mount),
30 | "speed": AttributeParser.required("speed", int),
31 | "taming_method": AttributeParser.required("taming_method", clean_links),
32 | "is_buyable": AttributeParser.optional("bought", parse_boolean, False),
33 | "price": AttributeParser.optional("price", parse_integer),
34 | "achievement": AttributeParser.optional("achievement"),
35 | "light_color": AttributeParser.optional("lightcolor", lambda x: client_color_to_rgb(parse_integer(x))),
36 | "light_radius": AttributeParser.optional("lightradius", int),
37 | "version": AttributeParser.version(),
38 | "status": AttributeParser.status(),
39 | }
40 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/npc.py:
--------------------------------------------------------------------------------
1 | from typing import Any, ClassVar
2 |
3 | import tibiawikisql.schema
4 | from tibiawikisql.api import Article
5 | from tibiawikisql.models.npc import Npc, NpcDestination
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.parsers.base import AttributeParser
8 | from tibiawikisql.utils import clean_links, convert_tibiawiki_position, find_template, strip_code
9 |
10 |
11 | class NpcParser(BaseParser):
12 | """Parser for NPCs."""
13 |
14 | table = tibiawikisql.schema.NpcTable
15 | model = Npc
16 | template_name = "Infobox_NPC"
17 | attribute_map: ClassVar = {
18 | "name": AttributeParser(lambda x: x.get("actualname") or x.get("name")),
19 | "gender": AttributeParser.optional("gender"),
20 | "location": AttributeParser.optional("location", clean_links),
21 | "subarea": AttributeParser.optional("subarea"),
22 | "city": AttributeParser.required("city"),
23 | "x": AttributeParser.optional("posx", convert_tibiawiki_position),
24 | "y": AttributeParser.optional("posy", convert_tibiawiki_position),
25 | "z": AttributeParser.optional("posz", int),
26 | "version": AttributeParser.optional("implemented"),
27 | "status": AttributeParser.status(),
28 | }
29 |
30 | @classmethod
31 | def parse_attributes(cls, article: Article) -> dict[str, Any]:
32 | row = super().parse_attributes(article)
33 | raw_attributes = row["_raw_attributes"]
34 | cls._parse_jobs(row)
35 | cls._parse_races(row)
36 |
37 | row["destinations"] = []
38 | destinations = []
39 | if "notes" in raw_attributes and "{{Transport" in raw_attributes["notes"]:
40 | destinations.extend(cls._parse_destinations(raw_attributes["notes"]))
41 | for destination, price, notes in destinations:
42 | name = destination.strip()
43 | clean_notes = clean_links(notes.strip())
44 | if not notes:
45 | clean_notes = None
46 | row["destinations"].append(NpcDestination(
47 | name=name,
48 | price=price,
49 | notes=clean_notes,
50 | ))
51 | return row
52 |
53 |
54 | # region Auxiliary Methods
55 |
56 | @classmethod
57 | def _parse_jobs(cls, row: dict[str, Any]) -> None:
58 | """Read the possible multiple job parameters of an NPC's page and put them together in a list."""
59 | raw_attributes = row["_raw_attributes"]
60 | row["jobs"] = [
61 | clean_links(value)
62 | for key, value in raw_attributes.items()
63 | if key.startswith("job")
64 | ]
65 |
66 | @classmethod
67 | def _parse_races(cls, row: dict[str, Any]) -> None:
68 | """Read the possible multiple race parameters of an NPC's page and put them together in a list."""
69 | raw_attributes = row["_raw_attributes"]
70 | row["races"] = [
71 | clean_links(value)
72 | for key, value in raw_attributes.items()
73 | if key.startswith("race")
74 | ]
75 |
76 |
77 | @classmethod
78 | def _parse_destinations(cls, value: str) -> list[tuple[str, int, str]]:
79 | """Parse an NPC destinations into a list of tuples.
80 |
81 | The tuple contains the destination's name, price and notes.
82 | Price and notes may not be present.
83 |
84 | Args:
85 | value: A string containing the Transport template with destinations.
86 |
87 | Returns:
88 | A list of tuples, where each element is the name of the destination, the price and additional notes.
89 | """
90 | template = find_template(value, "Transport", partial=True)
91 | if not template:
92 | return []
93 | result = []
94 | for param in template.params:
95 | if param.showkey:
96 | continue
97 | data, *notes = strip_code(param).split(";", 1)
98 | notes = notes[0] if notes else ""
99 | destination, price_str = data.split(",")
100 | try:
101 | price = int(price_str)
102 | except ValueError:
103 | price = 0
104 | result.append((destination, price, notes))
105 | return result
106 |
107 | # endregion
108 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/outfit.py:
--------------------------------------------------------------------------------
1 | from typing import Any, ClassVar
2 |
3 | import tibiawikisql.schema
4 | from tibiawikisql.api import Article
5 | from tibiawikisql.models.outfit import Outfit, UnlockQuest
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.parsers.base import AttributeParser
8 | from tibiawikisql.parsers.quest import parse_links
9 | from tibiawikisql.utils import parse_boolean, parse_integer
10 |
11 |
12 | class OutfitParser(BaseParser):
13 | """Parser for outfits."""
14 |
15 | model = Outfit
16 | table = tibiawikisql.schema.OutfitTable
17 | template_name = "Infobox_Outfit"
18 | attribute_map: ClassVar = {
19 | "name": AttributeParser.required("name"),
20 | "outfit_type": AttributeParser.required("primarytype"),
21 | "is_premium": AttributeParser.optional("premium", parse_boolean, False),
22 | "is_tournament": AttributeParser.optional("tournament", parse_boolean, False),
23 | "is_bought": AttributeParser.optional("bought", parse_boolean, False),
24 | "full_price": AttributeParser.optional("fulloutfitprice", parse_integer),
25 | "achievement": AttributeParser.optional("achievement"),
26 | "status": AttributeParser.status(),
27 | "version": AttributeParser.version(),
28 | }
29 |
30 | @classmethod
31 | def parse_attributes(cls, article: Article) -> dict[str, Any]:
32 | row = super().parse_attributes(article)
33 | if not row:
34 | return row
35 | raw_attributes = row["_raw_attributes"]
36 | row["quests"] = []
37 | if "outfit" in raw_attributes:
38 | quests = parse_links(raw_attributes["outfit"])
39 | for quest in quests:
40 | row["quests"].append(UnlockQuest(
41 | quest_title=quest.strip(),
42 | unlock_type="outfit",
43 | ))
44 | if "addons" in raw_attributes:
45 | quests = parse_links(raw_attributes["addons"])
46 | for quest in quests:
47 | row["quests"].append(UnlockQuest(
48 | quest_title=quest.strip(),
49 | unlock_type="addons",
50 | ))
51 | return row
52 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/quest.py:
--------------------------------------------------------------------------------
1 | import html
2 | import re
3 | from typing import Any, ClassVar
4 |
5 | from tibiawikisql.api import Article
6 | from tibiawikisql.models.quest import ItemReward, Quest, QuestCreature
7 | from tibiawikisql.parsers.base import AttributeParser
8 | from tibiawikisql.parsers import BaseParser
9 | from tibiawikisql.schema import QuestTable
10 | from tibiawikisql.utils import clean_links, parse_boolean, parse_integer
11 |
12 | link_pattern = re.compile(r"\[\[([^|\]]+)")
13 |
14 |
15 | def parse_links(value: str) -> list[str]:
16 | """Find all the links in a string and returns a list of them.
17 |
18 | Args:
19 | value: A string containing links.
20 |
21 | Returns:
22 | The links found in the string.
23 |
24 | """
25 | return list(link_pattern.findall(value))
26 |
27 |
28 | class QuestParser(BaseParser):
29 | """Parser for quests."""
30 |
31 | model = Quest
32 | table = QuestTable
33 | template_name = "Infobox_Quest"
34 | attribute_map: ClassVar = {
35 | "name": AttributeParser.required("name", html.unescape),
36 | "location": AttributeParser.optional("location", clean_links),
37 | "is_rookgaard_quest": AttributeParser.optional("rookgaardquest", parse_boolean, False),
38 | "type": AttributeParser.optional("type"),
39 | "quest_log": AttributeParser.optional("log", parse_boolean),
40 | "legend": AttributeParser.optional("legend", clean_links),
41 | "level_required": AttributeParser.optional("lvl", parse_integer),
42 | "level_recommended": AttributeParser.optional("lvlrec", parse_integer),
43 | "active_time": AttributeParser.optional("time"),
44 | "estimated_time": AttributeParser.optional("timealloc"),
45 | "is_premium": AttributeParser.required("premium", parse_boolean),
46 | "version": AttributeParser.optional("implemented"),
47 | "status": AttributeParser.status(),
48 | }
49 |
50 | @classmethod
51 | def parse_attributes(cls, article: Article) -> dict[str, Any]:
52 | row = super().parse_attributes(article)
53 | if not row:
54 | return row
55 | cls._parse_quest_rewards(row)
56 | cls._parse_quest_dangers(row)
57 | return row
58 |
59 | # region Auxiliary Functions
60 |
61 | @classmethod
62 | def _parse_quest_rewards(cls, row: dict[str, Any]) -> None:
63 | raw_attributes = row["_raw_attributes"]
64 | if not raw_attributes.get("reward"):
65 | return
66 | rewards = parse_links(raw_attributes["reward"])
67 | row["rewards"] = [ItemReward(
68 | item_title=reward.strip(),
69 | ) for reward in rewards]
70 |
71 | @classmethod
72 | def _parse_quest_dangers(cls, row: dict[str, Any]) -> None:
73 | raw_attributes = row["_raw_attributes"]
74 | if not raw_attributes.get("dangers"):
75 | return
76 | dangers = parse_links(raw_attributes["dangers"])
77 | row["dangers"] = [QuestCreature(creature_title=danger.strip()) for danger in dangers]
78 |
79 | # endregion
80 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/spell.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from tibiawikisql.api import Article
4 | from tibiawikisql.models.spell import Spell
5 | import tibiawikisql.schema
6 | from tibiawikisql.parsers.base import AttributeParser
7 | from tibiawikisql.parsers import BaseParser
8 | from tibiawikisql.utils import clean_links, parse_boolean, parse_integer
9 |
10 |
11 | class SpellParser(BaseParser):
12 | """Parser for spells."""
13 |
14 | model = Spell
15 | table = tibiawikisql.schema.SpellTable
16 | template_name = "Infobox_Spell"
17 | attribute_map: ClassVar = {
18 | "name": AttributeParser.required("name"),
19 | "effect": AttributeParser.required("effect", clean_links),
20 | "words": AttributeParser.optional("words"),
21 | "spell_type": AttributeParser.required("type"),
22 | "group_spell": AttributeParser.required("subclass"),
23 | "group_secondary": AttributeParser.optional("secondarygroup"),
24 | "group_rune": AttributeParser.optional("runegroup"),
25 | "element": AttributeParser.optional("damagetype"),
26 | "mana": AttributeParser.optional("mana", parse_integer),
27 | "soul": AttributeParser.optional("soul", parse_integer, 0),
28 | "price": AttributeParser.optional("spellcost", parse_integer),
29 | "cooldown": AttributeParser.required("cooldown"),
30 | "cooldown2": AttributeParser.optional("cooldown2"),
31 | "cooldown3": AttributeParser.optional("cooldown3"),
32 | "cooldown_group": AttributeParser.optional("cooldowngroup"),
33 | "cooldown_group_secondary": AttributeParser.optional("cooldowngroup2"),
34 | "level": AttributeParser.optional("levelrequired", parse_integer),
35 | "is_premium": AttributeParser.optional("premium",parse_boolean, False),
36 | "is_promotion": AttributeParser.optional("promotion", parse_boolean, False),
37 | "is_wheel_spell": AttributeParser.optional("wheelspell", parse_boolean, False),
38 | "is_passive": AttributeParser.optional("passivespell", parse_boolean, False),
39 | "version": AttributeParser.optional("implemented"),
40 | "status": AttributeParser.status(),
41 | }
42 |
43 | @classmethod
44 | def parse_attributes(cls, article: Article):
45 | row = super().parse_attributes(article)
46 | for vocation in ["knight", "sorcerer", "druid", "paladin", "monk"]:
47 | if vocation in row["_raw_attributes"].get("voc", "").lower():
48 | row[vocation] = True
49 | return row
50 |
51 |
52 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/update.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from tibiawikisql.models.update import Update
4 | from tibiawikisql.parsers import BaseParser
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.schema import UpdateTable
7 | from tibiawikisql.utils import clean_links, parse_date, parse_integer
8 |
9 |
10 | class UpdateParser(BaseParser):
11 | """Parser for game updates."""
12 | model = Update
13 | table = UpdateTable
14 | template_name = "Infobox_Update"
15 | attribute_map: ClassVar = {
16 | "name": AttributeParser.optional("name"),
17 | "type_primary": AttributeParser.required("primarytype"),
18 | "type_secondary": AttributeParser.optional("secondarytype"),
19 | "release_date": AttributeParser.required("date", parse_date),
20 | "news_id": AttributeParser.optional("newsid", parse_integer),
21 | "previous": AttributeParser.optional("previous"),
22 | "next": AttributeParser.optional("next"),
23 | "summary": AttributeParser.optional("summary", clean_links),
24 | "changes": AttributeParser.optional("changelist", clean_links),
25 | "version": AttributeParser.version(),
26 | }
27 |
--------------------------------------------------------------------------------
/tibiawikisql/parsers/world.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | import tibiawikisql.schema
4 | from tibiawikisql.models.world import World
5 | from tibiawikisql.parsers.base import AttributeParser
6 | from tibiawikisql.parsers import BaseParser
7 | from tibiawikisql.utils import parse_boolean, parse_date, parse_integer
8 |
9 |
10 | class WorldParser(BaseParser):
11 | """Parser for game worlds (servers)."""
12 |
13 | table = tibiawikisql.schema.WorldTable
14 | model = World
15 | template_name = "Infobox_World"
16 | attribute_map: ClassVar = {
17 | "name": AttributeParser.required("name"),
18 | "location": AttributeParser.required("location"),
19 | "pvp_type": AttributeParser.required("type"),
20 | "is_preview": AttributeParser.optional("preview", parse_boolean, False),
21 | "is_experimental": AttributeParser.optional("experimental", parse_boolean, False),
22 | "online_since": AttributeParser.required("online", parse_date),
23 | "offline_since": AttributeParser.optional("offline", parse_date),
24 | "merged_into": AttributeParser.optional("mergedinto"),
25 | "battleye": AttributeParser.optional("battleye", parse_boolean, False),
26 | "battleye_type": AttributeParser.optional("battleyetype"),
27 | "protected_since": AttributeParser.optional("protectedsince", parse_date),
28 | "world_board": AttributeParser.optional("worldboardid", parse_integer),
29 | "trade_board": AttributeParser.optional("tradeboardid", parse_integer),
30 | }
31 |
--------------------------------------------------------------------------------
/tibiawikisql/server.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import logging
4 | import sqlite3
5 | from typing import Annotated, TYPE_CHECKING
6 |
7 | from fastapi import APIRouter, Depends, FastAPI
8 | from starlette.requests import Request
9 | from starlette.responses import JSONResponse
10 |
11 | from tibiawikisql.api import WikiClient
12 | from tibiawikisql.models import Achievement, Book, Charm, Creature, House, Imbuement, Item, Key, Mount, Npc, Outfit, \
13 | Quest, \
14 | Spell, \
15 | Update, \
16 | World
17 | from tibiawikisql.parsers import AchievementParser
18 |
19 | if TYPE_CHECKING:
20 | from collections.abc import Generator
21 |
22 | logging.basicConfig(level=logging.DEBUG)
23 |
24 | sql_logger = logging.getLogger("sqlite3")
25 |
26 | wiki_client = WikiClient()
27 |
28 | app = FastAPI(
29 | title="TibiaWikiSQL",
30 | )
31 | db_router = APIRouter(
32 | prefix="/db",
33 | tags=["db"],
34 | )
35 | wiki_router = APIRouter(
36 | prefix="/wiki",
37 | tags=["wiki"],
38 | )
39 |
40 |
41 | @app.exception_handler(Exception)
42 | async def exception_handler(request: Request, exc: Exception):
43 | return JSONResponse(
44 | status_code=500,
45 | content={
46 | "title": exc.__class__.__name__,
47 | "message": str(exc),
48 | },
49 | )
50 |
51 |
52 | def get_db_connection() -> Generator[sqlite3.Connection]:
53 | conn = sqlite3.connect("tibiawiki.db")
54 | conn.set_trace_callback(sql_logger.info)
55 | try:
56 | yield conn
57 | finally:
58 | conn.close()
59 |
60 |
61 | Conn = Annotated[sqlite3.Connection, Depends(get_db_connection)]
62 |
63 |
64 | @app.get("/healthcheck", tags=["General"])
65 | def healthcheck() -> bool:
66 | return True
67 |
68 |
69 | @db_router.get("/achievements/{title}")
70 | def get_achievement(
71 | conn: Conn,
72 | title: str,
73 | ) -> Achievement | None:
74 | return Achievement.get_by_title(conn, title)
75 |
76 |
77 | @wiki_router.get("/achievements/{title}")
78 | def get_wiki_achievement(
79 | title: str,
80 | ) -> Achievement | None:
81 | article = wiki_client.get_article(title)
82 | if not article:
83 | return None
84 | return AchievementParser.from_article(article)
85 |
86 | @db_router.get("/books/{title}")
87 | def get_book(
88 | conn: Conn,
89 | title: str,
90 | ) -> Book | None:
91 | return Book.get_by_title(conn, title)
92 |
93 |
94 |
95 | @db_router.get("/charms/{title}")
96 | def get_charm(
97 | conn: Conn,
98 | title: str,
99 | ) -> Charm | None:
100 | return Charm.get_by_title(conn, title)
101 |
102 |
103 | @db_router.get("/creatures/{title}")
104 | def get_creature(
105 | conn: Conn,
106 | title: str,
107 | ) -> Creature | None:
108 | return Creature.get_by_title(conn, title)
109 |
110 |
111 | @db_router.get("/houses/{title}")
112 | def get_house(
113 | conn: Conn,
114 | title: str,
115 | ) -> House | None:
116 | return House.get_by_title(conn, title)
117 |
118 |
119 | @db_router.get("/imbuements/{title}")
120 | def get_imbuement(
121 | conn: Conn,
122 | title: str,
123 | ) -> Imbuement | None:
124 | return Imbuement.get_by_title(conn, title)
125 |
126 |
127 | @db_router.get("/items/{title}")
128 | def get_item(
129 | conn: Conn,
130 | title: str,
131 | ) -> Item | None:
132 | return Item.get_by_title(conn, title)
133 |
134 |
135 | @db_router.get("/keys/{title}")
136 | def get_key(
137 | conn: Conn,
138 | title: str,
139 | ) -> Key | None:
140 | return Key.get_by_title(conn, title)
141 |
142 |
143 | @db_router.get("/mounts/{title}")
144 | def get_mount(
145 | conn: Conn,
146 | title: str,
147 | ) -> Mount | None:
148 | return Mount.get_by_title(conn, title)
149 |
150 |
151 | @db_router.get("/npcs/{title}")
152 | def get_npc(
153 | conn: Conn,
154 | title: str,
155 | ) -> Npc | None:
156 | return Npc.get_by_title(conn, title)
157 |
158 |
159 | @db_router.get("/outfits/{title}")
160 | def get_outfit(
161 | conn: Conn,
162 | title: str,
163 | ) -> Outfit | None:
164 | return Outfit.get_by_title(conn, title)
165 |
166 |
167 | @db_router.get("/quests/{title}")
168 | def get_quest(
169 | conn: Conn,
170 | title: str,
171 | ) -> Quest | None:
172 | return Quest.get_by_title(conn, title)
173 |
174 |
175 | @db_router.get("/spells/{title}")
176 | def get_spell(
177 | conn: Conn,
178 | title: str,
179 | ) -> Spell | None:
180 | return Spell.get_by_title(conn, title)
181 |
182 |
183 | @db_router.get("/updates/byVersion/{version}")
184 | def get_update_by_version(
185 | conn: Conn,
186 | version: str,
187 | ) -> Update | None:
188 | return Update.get_one_by_field(conn, "version", version)
189 |
190 |
191 | @db_router.get("/updates/{title:path}")
192 | def get_update(
193 | conn: Conn,
194 | title: str,
195 | ) -> Update | None:
196 | return Update.get_by_title(conn, title)
197 |
198 |
199 | @db_router.get("/worlds/{title}")
200 | def get_world(
201 | conn: Conn,
202 | title: str,
203 | ) -> World | None:
204 | return World.get_by_title(conn, title)
205 |
206 |
207 | app.include_router(db_router)
208 | app.include_router(wiki_router)
209 |
--------------------------------------------------------------------------------