Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
<!-- Changed by tibia.sh in 2026. See "About this copy" in README.md. -->
# Changelog

## Unreleased

- Read numbers with commas as thousands separators, like `50,000`, as the whole number instead of the digits before
the first comma. This fixes creature `hitpoints` and `experience`, item `value_buy` and `value_sell`, outfit
`full_price` and other quantities. A comma not followed by exactly three digits still ends the number, and an ID list
like `itemid = 629,630,631` or `mount_id = 421,437,438,747` still gives its first ID. A creature's max damage
without a `{{Max Damage}}` template reads `1,300+` as 1300, and a range like `600-1000?` by its larger number.
- Keep the first number of every ID field, like the client IDs, so `achievementid = 12,345` gives 12, not 12345. This
covers `house.house_id`, `achievement.achievement_id`, `world.world_board`, `world.trade_board`, `game_update.news_id`
and `item_key.number`. No current value changes.
- Add the `item.value_buy_currency` column. It holds the article's `pricecurrency`, otherwise a currency written after
the price in `npcprice`, like `50 [[Gold Token]]s`, otherwise `Gold Coin`, and `NULL` when `value_buy` is `NULL` or 0.
A linked currency gives the page it links to, so `[[Silver Token]]s` gives `Silver Token`, and `mount.price_currency`
now reads links the same way. 25 Years Backpack's price of 7197 is now in `Theons`, not gold.

## 9.0.0+tibiash.5

- Store `spell.effect`, `spell.spell_type`, `spell.group_spell`, `spell.level` and `spell.cooldown` as `NULL` when a
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ The copy adds this data to the database:
comment.
* The `mount.price_currency` column. It holds the currency of a mount's price, like `Tibia Coins` or `Event Points`,
`NULL` when the mount has no price.
* The `item.value_buy_currency` column. It holds the currency of an item's NPC price, like `Gold Coin` or `Theons`,
`NULL` when NPCs do not sell the item.

The [database schema](docs/schema.md) describes each of them.

Expand Down
53 changes: 27 additions & 26 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,32 +318,33 @@ The generated database has the following tables.

### item

| Column | Type | Description |
| -------------- | --------------------- | ------------------------------------------------------------------- |
| article_id | `INTEGER` / `PRIMARY` | The id of the article containing this item. |
| title | `TEXT` | The title of the article containing this item. |
| name | `TEXT` | The actual name of the item in-game. |
| actual_name | `TEXT` | The name of the item as it appears in game when looked at. |
| plural | `TEXT` | The plural of the item's name. |
| article | `TEXT` | The article that goes before the name when looking at the item. |
| is_marketable | `BOOLEAN` | Whether this item can be traded in the market or not. |
| is_stackable | `BOOLEAN` | Whether this item is stackable or not. |
| is_pickupable | `BOOLEAN` | Whether this item can be picked up or not. |
| is_immobile | `BOOLEAN` | Whether the item can be moved around the map or not. |
| value_sell | `INTEGER` | The maximum value of this item when sold to NPCs |
| value_buy | `INTEGER` | The maximum price of this item when bought from NPCs. |
| weight | `REAL` | The weight of this item in ounces. |
| item_class | `TEXT` | The class this item belongs to (e.g. `Body Equipment` , `Weapons`). |
| item_type | `TEXT` | The category this item belongs to (e.g. `Helmets`, `Club Weapons`). |
| type_secondary | `TEXT` | A secondary type this item belongs to, if any. |
| flavor_text | `TEXT` | The extra text that is displayed when some items are looked at. |
| light_color | `INTEGER` | The color of the light emitted by this item, if any. |
| light_radius | `INTEGER` | The radius of the light emitted by this item, if any. |
| version | `TEXT` | The client version this item was introduced to the game. |
| client_id | `INTEGER` | The client id of the item. |
| image | `BLOB` | The item’s image bytes. |
| status | `TEXT` | The status of the item in game. |
| timestamp | `TIMESTAMP` | ISO 8601 timestamp of the article's last edit. |
| Column | Type | Description |
| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| article_id | `INTEGER` / `PRIMARY` | The id of the article containing this item. |
| title | `TEXT` | The title of the article containing this item. |
| name | `TEXT` | The actual name of the item in-game. |
| actual_name | `TEXT` | The name of the item as it appears in game when looked at. |
| plural | `TEXT` | The plural of the item's name. |
| article | `TEXT` | The article that goes before the name when looking at the item. |
| is_marketable | `BOOLEAN` | Whether this item can be traded in the market or not. |
| is_stackable | `BOOLEAN` | Whether this item is stackable or not. |
| is_pickupable | `BOOLEAN` | Whether this item can be picked up or not. |
| is_immobile | `BOOLEAN` | Whether the item can be moved around the map or not. |
| value_sell | `INTEGER` | The maximum value of this item when sold to NPCs |
| value_buy | `INTEGER` | The maximum price of this item when bought from NPCs, in `value_buy_currency`. |
| value_buy_currency | `TEXT` | The currency of `value_buy`: the article's `pricecurrency`, otherwise a currency written after the price in `npcprice`, otherwise Gold Coin. A linked currency gives the page it links to, like `Silver Token` for `[[Silver Token]]s`. `NULL` when `value_buy` is `NULL` or 0. |
| weight | `REAL` | The weight of this item in ounces. |
| item_class | `TEXT` | The class this item belongs to (e.g. `Body Equipment` , `Weapons`). |
| item_type | `TEXT` | The category this item belongs to (e.g. `Helmets`, `Club Weapons`). |
| type_secondary | `TEXT` | A secondary type this item belongs to, if any. |
| flavor_text | `TEXT` | The extra text that is displayed when some items are looked at. |
| light_color | `INTEGER` | The color of the light emitted by this item, if any. |
| light_radius | `INTEGER` | The radius of the light emitted by this item, if any. |
| version | `TEXT` | The client version this item was introduced to the game. |
| client_id | `INTEGER` | The client id of the item. |
| image | `BLOB` | The item’s image bytes. |
| status | `TEXT` | The status of the item in game. |
| timestamp | `TIMESTAMP` | ISO 8601 timestamp of the article's last edit. |



Expand Down
24 changes: 24 additions & 0 deletions tests/models/test_item.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# Changed by tibia.sh in 2026. See "About this copy" in README.md.
import datetime
import sqlite3
import unittest

from tests import load_resource
from tibiawikisql import schema
from tibiawikisql.api import Article
from tibiawikisql.models import Item
from tibiawikisql.parsers import ItemParser
Expand Down Expand Up @@ -67,3 +70,24 @@ def test_item_parser_from_article_damage_reflection(self):

self.assertIsInstance(item, Item)
self.assertIn("damage_reflection", item.attributes_dict)


class TestItem(unittest.TestCase):
def setUp(self):
self.conn = sqlite3.connect(":memory:")
self.conn.row_factory = sqlite3.Row
schema.create_tables(self.conn)

def test_item_value_buy_currency_round_trip(self):
article = Article(
article_id=1,
title="25 Years Backpack",
timestamp=datetime.datetime.fromisoformat("2025-12-08T17:54:57+00:00"),
content=load_resource("content_item_25_years_backpack.txt"),
)
ItemParser.from_article(article).insert(self.conn)

loaded = Item.get_one_by_field(self.conn, "article_id", 1)

self.assertEqual(7197, loaded.value_buy)
self.assertEqual("Theons", loaded.value_buy_currency)
5 changes: 5 additions & 0 deletions tests/parsers/test_achievement.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ def test_achievement_name_from_actualname(self):

self.assertEqual("Demonic Barkeeper", achievement.name)

def test_achievement_id_keeps_first_number(self):
achievement = self._parse(self._edited("| achievementid = 111", "| achievementid = 12,345"))

self.assertEqual(12, achievement.achievement_id)

def test_achievement_name_without_actualname(self):
achievement = self._parse(load_resource("content_achievement.txt"))

Expand Down
28 changes: 28 additions & 0 deletions tests/parsers/test_creature.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ def test_parse_max_damage_no_template(self):
self.assertIsInstance(result, dict)
self.assertEqual(2000, result["total"])

def test_parse_max_damage_thousands_separator(self):
self.assertEqual({"total": 1000}, parse_maximum_damage("1,000"))
self.assertEqual({"total": 1300}, parse_maximum_damage("1,300+"))

def test_parse_max_damage_range(self):
self.assertEqual({"total": 1200}, parse_maximum_damage("500-1,200"))
self.assertEqual({"total": 100}, parse_maximum_damage("50-100"))
self.assertEqual({"total": 1000}, parse_maximum_damage("600-1000?"))

def test_parse_max_damage_leading_minus_is_a_sign(self):
self.assertEqual({"total": 1000}, parse_maximum_damage("-1500 or 1000"))

def test_parse_max_damage_no_template_no_number(self):
max_damage_content = "Unknown."

Expand All @@ -96,6 +108,22 @@ def test_parse_max_damage_empty(self):
self.assertEqual({}, result)


class TestCreatureParserNumbers(unittest.TestCase):
def test_creature_thousands_separator(self):
article = Article(
article_id=1,
title="Brain Head",
timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"),
content=load_resource("content_creature_brain_head.txt"),
)

creature = CreatureParser.from_article(article)

self.assertEqual(50000, creature.experience)
self.assertEqual(230000, creature.hitpoints)
self.assertEqual(1000, creature.max_damage.total)


class TestCreatureParserRaceId(unittest.TestCase):
def _parse(self, content: str) -> Creature:
article = Article(
Expand Down
Loading
Loading