diff --git a/.gitignore b/.gitignore
index a65d046..a8b8dc8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
+venv/
build/
develop-eggs/
dist/
@@ -56,3 +57,6 @@ docs/_build/
# PyBuilder
target/
+
+# PyCharm
+.idea/
\ No newline at end of file
diff --git a/Bootstrap.py b/Bootstrap.py
new file mode 100644
index 0000000..d43f980
--- /dev/null
+++ b/Bootstrap.py
@@ -0,0 +1,11 @@
+import asyncio
+import sys
+from Houdini.HoudiniFactory import HoudiniFactory
+
+if __name__ == '__main__':
+ if sys.platform == 'win32':
+ loop = asyncio.ProactorEventLoop()
+ asyncio.set_event_loop(loop)
+
+ factory_instance = HoudiniFactory(server='Login')
+ asyncio.run(factory_instance.start())
diff --git a/Houdini/Converters.py b/Houdini/Converters.py
new file mode 100644
index 0000000..72e4535
--- /dev/null
+++ b/Houdini/Converters.py
@@ -0,0 +1,155 @@
+import zope.interface
+from zope.interface import implementer
+
+
+class IConverter(zope.interface.Interface):
+
+ description = zope.interface.Attribute("""A short description of the purpose of the converter""")
+
+ async def convert(self):
+ raise NotImplementedError('Converter must derive this class!')
+
+
+class Converter:
+
+ __slots__ = ['p', 'argument']
+
+ def __init__(self, p, argument):
+ self.p = p
+ self.argument = argument
+
+
+@implementer(IConverter)
+class CredentialsConverter(Converter):
+
+ description = """Used for obtaining login credentials from XML login data"""
+
+ async def convert(self):
+ username = self.argument[0][0].text
+ password = self.argument[0][1].text
+ return username, password
+
+
+@implementer(IConverter)
+class VersionChkConverter(Converter):
+
+ description = """Used for checking the verChk version number"""
+
+ async def convert(self):
+ return self.argument[0].get('v')
+
+
+@implementer(IConverter)
+class ConnectedPenguinConverter(Converter):
+
+ description = """Converts a penguin ID into a live penguin instance
+ or none if the player is offline"""
+
+ async def convert(self):
+ penguin_id = int(self.argument)
+ if penguin_id in self.p.server.penguins_by_id:
+ return self.p.server.penguins_by_id[penguin_id]
+ return None
+
+
+@implementer(IConverter)
+class ConnectedIglooConverter(Converter):
+
+ description = """Converts a penguin ID into a live igloo instance or
+ none if it's not available"""
+
+ async def convert(self):
+ igloo_id = int(self.argument)
+ if igloo_id in self.p.server.igloo_map:
+ return self.p.server.igloo_map[igloo_id]
+ return None
+
+
+@implementer(IConverter)
+class RoomConverter(Converter):
+
+ description = """Converts a room ID into a Houdini.Data.Room instance"""
+
+ async def convert(self):
+ room_id = int(self.argument)
+ if room_id in self.p.server.rooms:
+ return self.p.server.rooms[room_id]
+ return None
+
+
+@implementer(IConverter)
+class ItemConverter(Converter):
+
+ description = """Converts an item ID into a Houdini.Data.Item instance"""
+
+ async def convert(self):
+ item_id = int(self.argument)
+ if item_id in self.p.server.items:
+ return self.p.server.items[item_id]
+ return None
+
+
+@implementer(IConverter)
+class IglooConverter(Converter):
+
+ description = """Converts an igloo ID into a Houdini.Data.Igloo instance"""
+
+ async def convert(self):
+ igloo_id = int(self.argument)
+ if igloo_id in self.p.server.igloos:
+ return self.p.server.igloos[igloo_id]
+ return None
+
+
+@implementer(IConverter)
+class FurnitureConverter(Converter):
+
+ description = """Converts a furniture ID into a Houdini.Data.Furniture instance"""
+
+ async def convert(self):
+ furniture_id = int(self.argument)
+ if furniture_id in self.p.server.furniture:
+ return self.p.server.furniture[furniture_id]
+ return None
+
+
+@implementer(IConverter)
+class FlooringConverter(Converter):
+
+ description = """Converts a flooring ID into a Houdini.Data.Flooring instance"""
+
+ async def convert(self):
+ flooring_id = int(self.argument)
+ if flooring_id in self.p.server.flooring:
+ return self.p.server.flooring[flooring_id]
+ return None
+
+
+@implementer(IConverter)
+class StampConverter(Converter):
+
+ description = """Converts a stamp ID into a Houdini.Data.Stamp instance"""
+
+ async def convert(self):
+ stamp_id = int(self.argument)
+ if stamp_id in self.p.server.stamps:
+ return self.p.server.stamps[stamp_id]
+ return None
+
+
+@implementer(IConverter)
+class VerticalConverter(Converter):
+
+ description = """Converts vertically separated values into an int list"""
+
+ async def convert(self):
+ return map(int, self.argument.split('|'))
+
+
+@implementer(IConverter)
+class CommaConverter(Converter):
+
+ description = """Converts comma separated values into an int list"""
+
+ async def convert(self):
+ return map(int, self.argument.split(','))
diff --git a/Houdini/Crypto.py b/Houdini/Crypto.py
new file mode 100644
index 0000000..dfc2831
--- /dev/null
+++ b/Houdini/Crypto.py
@@ -0,0 +1,38 @@
+import hashlib
+from random import choice
+from string import ascii_letters, digits
+
+
+class Crypto:
+
+ @staticmethod
+ def hash(string):
+ if isinstance(string, int):
+ string = str(string)
+
+ return hashlib.md5(string.encode("utf-8")).hexdigest()
+
+ @staticmethod
+ def generate_random_key():
+ character_selection = ascii_letters + digits
+
+ return "".join(choice(character_selection) for _ in range(16))
+
+ @staticmethod
+ def encrypt_password(password, digest=True):
+ if digest:
+ password = Crypto.hash(password)
+
+ swapped_hash = password[16:32] + password[0:16]
+
+ return swapped_hash
+
+ @staticmethod
+ def get_login_hash(password, rndk):
+ key = Crypto.encrypt_password(password, False)
+ key += rndk
+ key += "Y(02.>'H}t\":E1"
+
+ login_hash = Crypto.encrypt_password(key)
+
+ return login_hash
diff --git a/Houdini/Data/__init__.py b/Houdini/Data/__init__.py
new file mode 100644
index 0000000..0a70952
--- /dev/null
+++ b/Houdini/Data/__init__.py
@@ -0,0 +1,4 @@
+from gino import Gino
+
+db = Gino()
+
diff --git a/Houdini/Events/HandlerFileEvent.py b/Houdini/Events/HandlerFileEvent.py
new file mode 100644
index 0000000..99f48f6
--- /dev/null
+++ b/Houdini/Events/HandlerFileEvent.py
@@ -0,0 +1,79 @@
+import sys
+import importlib
+from watchdog.events import FileSystemEventHandler
+
+from Houdini.Handlers import listeners_from_module
+from Houdini.Events import evaluate_handler_file_event, remove_handlers_by_module
+
+
+class HandlerFileEventHandler(FileSystemEventHandler):
+
+ def __init__(self, server):
+ self.logger = server.logger
+ self.server = server
+
+ def on_created(self, event):
+ handler_module_details = evaluate_handler_file_event(event)
+
+ if not handler_module_details:
+ return
+
+ handler_module_path, handler_module = handler_module_details
+
+ if "__init__.py" in handler_module_path:
+ return
+
+ self.logger.debug("New handler module detected %s", handler_module)
+
+ try:
+ module = importlib.import_module(handler_module)
+ listeners_from_module(self.server.xt_listeners, self.server.xml_listeners, module)
+ except Exception as import_error:
+ self.logger.error("%s detected in %s, not importing.", import_error.__class__.__name__, handler_module)
+
+ def on_deleted(self, event):
+ handler_module_details = evaluate_handler_file_event(event)
+
+ if not handler_module_details:
+ return
+
+ handler_module_path, handler_module = handler_module_details
+
+ if handler_module not in sys.modules:
+ return
+
+ self.logger.debug("Deleting listeners registered by %s..", handler_module)
+
+ remove_handlers_by_module(self.server.xt_listeners, self.server.xml_listeners, handler_module_path)
+
+ def on_modified(self, event):
+ handler_module_details = evaluate_handler_file_event(event)
+
+ if not handler_module_details:
+ return
+
+ handler_module_path, handler_module = handler_module_details
+
+ if handler_module not in sys.modules:
+ return False
+
+ self.logger.info("Reloading %s", handler_module)
+
+ xt_listeners, xml_listeners = remove_handlers_by_module(self.server.xt_listeners, self.server.xml_listeners,
+ handler_module_path)
+
+ handler_module_object = sys.modules[handler_module]
+
+ try:
+ module = importlib.reload(handler_module_object)
+ listeners_from_module(self.server.xt_listeners, self.server.xml_listeners, module)
+
+ self.logger.info("Successfully reloaded %s!", handler_module)
+ except Exception as rebuild_error:
+ self.logger.error("%s detected in %s, not reloading.", rebuild_error.__class__.__name__, handler_module)
+ self.logger.info("Restoring handler references...")
+
+ self.server.xt_listeners = xt_listeners
+ self.server.xml_listeners = xml_listeners
+
+ self.logger.info("Handler references restored. Phew!")
diff --git a/Houdini/Events/__init__.py b/Houdini/Events/__init__.py
new file mode 100644
index 0000000..3a7dcc3
--- /dev/null
+++ b/Houdini/Events/__init__.py
@@ -0,0 +1,54 @@
+import os
+import copy
+
+
+def evaluate_handler_file_event(handler_file_event):
+ # Ignore all directory events
+ if handler_file_event.is_directory:
+ return False
+
+ handler_module_path = handler_file_event.src_path[2:]
+
+ # Ignore non-Python files
+ if handler_module_path[-3:] != ".py":
+ return False
+
+ handler_module = handler_module_path.replace(os.path.sep, ".")[:-3]
+
+ return handler_module_path, handler_module
+
+
+def evaluate_plugin_file_event(plugin_file_event):
+ # Ignore all directory events
+ if plugin_file_event.is_directory:
+ return False
+
+ handler_module_path = plugin_file_event.src_path[2:]
+
+ # Ignore non-Python files
+ if handler_module_path[-3:] != ".py":
+ return False
+
+ # Remove file extension and replace path separator with dots. Then make like a banana.. and split.
+ handler_module_tokens = handler_module_path.replace(os.path.sep, ".")[:-3].split(".")
+
+ if handler_module_tokens.pop() == "__init__":
+ return handler_module_path, ".".join(handler_module_tokens)
+
+ return False
+
+
+def remove_handlers_by_module(xt_listeners, xml_listeners, handler_module_path):
+ def remove_handlers(remove_handler_items):
+ for handler_id, handler_listeners in remove_handler_items:
+ for handler_listener in handler_listeners:
+ if handler_listener.handler_file == handler_module_path:
+ handler_listeners.remove(handler_listener)
+
+ xt_handler_collection = copy.copy(xt_listeners)
+ remove_handlers(xt_listeners.items())
+
+ xml_handler_collection = copy.copy(xml_listeners)
+ remove_handlers(xml_listeners.items())
+
+ return xt_handler_collection, xml_handler_collection
diff --git a/Houdini/Handlers/Login/Login.py b/Houdini/Handlers/Login/Login.py
new file mode 100644
index 0000000..5a916d4
--- /dev/null
+++ b/Houdini/Handlers/Login/Login.py
@@ -0,0 +1,15 @@
+from Houdini import Handlers
+from Houdini.Handlers import XTPacket, XMLPacket
+from Houdini.Converters import CredentialsConverter, CommaConverter
+
+
+@Handlers.handler(XMLPacket('login'))
+async def handle_login(p, credentials: CredentialsConverter):
+ username, password = credentials
+ p.logger.info('{}:{} is logging in!'.format(username, password))
+
+
+@Handlers.handler(XTPacket('t', 'c'), pre_login=True)
+@Handlers.player_in_room(100)
+async def handle_test(p, numbers: CommaConverter):
+ print(list(numbers))
diff --git a/Houdini/Handlers/Login/__init__.py b/Houdini/Handlers/Login/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Houdini/Handlers/Play/Navigation.py b/Houdini/Handlers/Play/Navigation.py
new file mode 100644
index 0000000..43ca83b
--- /dev/null
+++ b/Houdini/Handlers/Play/Navigation.py
@@ -0,0 +1,12 @@
+from Houdini.Handlers import Handlers, XTPacket
+from Houdini.Converters import RoomConverter
+
+
+@Handlers.handler(XTPacket('j', 'js'))
+async def handle_join_world(p, is_moderator: bool, is_mascot: bool, is_member: bool):
+ print(p, is_moderator, is_mascot, is_member)
+
+
+@Handlers.handler(XTPacket('j', 'jr'))
+async def handle_join_room(p, room: RoomConverter):
+ print(room)
diff --git a/Houdini/Handlers/Play/__init__.py b/Houdini/Handlers/Play/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Houdini/Handlers/__init__.py b/Houdini/Handlers/__init__.py
new file mode 100644
index 0000000..71b322b
--- /dev/null
+++ b/Houdini/Handlers/__init__.py
@@ -0,0 +1,353 @@
+import inspect
+import time
+import enum
+import os
+import asyncio
+from types import FunctionType
+
+from Houdini.Converters import IConverter
+
+
+def get_relative_function_path(function_obj):
+ abs_function_file = inspect.getfile(function_obj)
+ rel_function_file = os.path.relpath(abs_function_file)
+
+ return rel_function_file
+
+
+def get_converter(component):
+ if component.annotation is component.empty:
+ return str
+ return component.annotation
+
+
+async def do_conversion(converter, p, component_data):
+ if IConverter.implementedBy(converter):
+ converter_instance = converter(p, component_data)
+ if asyncio.iscoroutinefunction(converter_instance.convert):
+ return await converter_instance.convert()
+ return converter_instance.convert()
+ return converter(component_data)
+
+
+class _Packet:
+ __slots__ = ['id']
+
+ def __init__(self):
+ self.id = None
+
+ def __eq__(self, other):
+ return self.id == other.id
+
+ def __hash__(self):
+ return hash(self.id)
+
+
+class XTPacket(_Packet):
+ def __init__(self, *packet_id):
+ super().__init__()
+ self.id = '#'.join(packet_id)
+
+ def __hash__(self):
+ return hash(self.id)
+
+
+class XMLPacket(_Packet):
+ def __init__(self, packet_id):
+ super().__init__()
+ self.id = packet_id
+
+
+class Priority(enum.Enum):
+ Override = 3
+ High = 2
+ Low = 1
+
+
+class BucketType(enum.Enum):
+ Default = 1
+ Penguin = 1
+ Server = 2
+
+
+class _Cooldown:
+
+ __slots__ = ['rate', 'per', 'bucket_type', 'last',
+ '_window', '_tokens']
+
+ def __init__(self, per, rate, bucket_type):
+ self.per = float(per)
+ self.rate = int(rate)
+ self.bucket_type = bucket_type
+ self.last = 0.0
+
+ self._window = 0.0
+ self._tokens = self.rate
+
+ @property
+ def is_cooling(self):
+ current = time.time()
+ self.last = current
+
+ if self._tokens == self.rate:
+ self._window = current
+
+ if current > self._window + self.per:
+ self._tokens = self.rate
+ self._window = current
+
+ if self._tokens == 0:
+ return self.per - (current - self._window)
+
+ self._tokens -= 1
+ if self._tokens == 0:
+ self._window = current
+
+ def reset(self):
+ self._tokens = self.rate
+ self.last = 0.0
+
+ def copy(self):
+ return _Cooldown(self.per, self.rate, self.bucket_type)
+
+
+class _CooldownMapping:
+
+ __slots__ = ['_cooldown', '_cache']
+
+ def __init__(self, cooldown_object):
+ self._cooldown = cooldown_object
+
+ self._cache = {}
+
+ def _get_bucket_key(self, p):
+ if self._cooldown.bucket_type == BucketType.Default:
+ return p
+ return p.server
+
+ def _verify_cache_integrity(self):
+ current = time.time()
+ self._cache = {cache_key: bucket for cache_key, bucket in
+ self._cache.items() if current < bucket.last + bucket.per}
+
+ def get_bucket(self, p):
+ self._verify_cache_integrity()
+ cache_key = self._get_bucket_key(p)
+ if cache_key not in self._cache:
+ bucket = self._cooldown.copy()
+ self._cache[cache_key] = bucket
+ else:
+ bucket = self._cache[cache_key]
+ return bucket
+
+
+class _Listener:
+
+ __slots__ = ['packet', 'components', 'handler', 'priority',
+ 'cooldown', 'pass_packet', 'handler_file',
+ 'overrides', 'pre_login', 'checklist', 'instance']
+
+ def __init__(self, packet, components, handler_function, **kwargs):
+ self.packet = packet
+ self.components = components
+ self.handler = handler_function
+
+ self.priority = kwargs.get('priority', Priority.Low)
+ self.overrides = kwargs.get('overrides', [])
+ self.cooldown = kwargs.get('cooldown')
+ self.pass_packet = kwargs.get('pass_packet', False)
+ self.checklist = kwargs.get('checklist', [])
+
+ self.instance = None
+
+ if type(self.overrides) is not list:
+ self.overrides = [self.overrides]
+
+ self.handler_file = get_relative_function_path(handler_function)
+
+ def _can_run(self, p):
+ return True if not self.checklist else all(predicate(self.packet, p) for predicate in self.checklist)
+
+ def __hash__(self):
+ return hash(self.__name__())
+
+ def __name__(self):
+ return "{}.{}".format(self.handler.__module__, self.handler.__name__)
+
+ def __call__(self, p, packet_data):
+ if self.cooldown is not None:
+ bucket = self.cooldown.get_bucket(p)
+ if bucket.is_cooling:
+ raise RuntimeError('{} sent packet during cooldown'.format(p.peer_name))
+
+ if not self._can_run(p):
+ raise RuntimeError('Could not handle packet due to checklist failure')
+
+
+class _XTListener(_Listener):
+
+ __slots__ = ['pre_login']
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.pre_login = kwargs.get('pre_login')
+
+ async def __call__(self, p, packet_data):
+ if not self.pre_login and not p.joined_world:
+ p.logger.warn('{} tried sending XT packet before authentication!'.format(p.peer_name))
+ await p.close()
+ return
+
+ super().__call__(p, packet_data)
+
+ handler_call_arguments = [self.instance] if self.instance is not None else []
+ handler_call_arguments += [self.packet, p] if self.pass_packet else [p]
+
+ arguments = iter(packet_data)
+ for index, component in enumerate(self.components):
+ if component.default is not component.empty:
+ handler_call_arguments.append(component.default)
+ next(arguments)
+ elif component.kind == component.POSITIONAL_OR_KEYWORD:
+ component_data = next(arguments)
+ converter = get_converter(component)
+ handler_call_arguments.append(await do_conversion(converter, p, component_data))
+ elif component.kind == component.VAR_POSITIONAL:
+ for component_data in arguments:
+ converter = get_converter(component)
+ handler_call_arguments.append(await do_conversion(converter, p, component_data))
+ break
+ return await self.handler(*handler_call_arguments)
+
+
+class _XMLListener(_Listener):
+ __slots__ = ['pre_login']
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ async def __call__(self, p, packet_data):
+ super().__call__(p, packet_data)
+
+ handler_call_arguments = [self.instance] if self.instance is not None else []
+ handler_call_arguments += [self.packet, p] if self.pass_packet else [p]
+
+ for index, component in enumerate(self.components):
+ if component.default is not component.empty:
+ handler_call_arguments.append(component.default)
+ elif component.kind == component.POSITIONAL_OR_KEYWORD:
+ converter = get_converter(component)
+ handler_call_arguments.append(await do_conversion(converter, p, packet_data))
+ return await self.handler(*handler_call_arguments)
+
+
+def handler(packet, **kwargs):
+ def decorator(handler_function):
+ if not asyncio.iscoroutinefunction(handler_function):
+ raise TypeError('All handlers must be a coroutine.')
+
+ components = list(inspect.signature(handler_function).parameters.values())[1:]
+
+ if not issubclass(type(packet), _Packet):
+ raise TypeError('All handlers can only listen for either XMLPacket or XTPacket.')
+
+ listener_class = _XTListener if isinstance(packet, XTPacket) else _XMLListener
+
+ try:
+ cooldown_object = handler_function.cooldown
+ del handler_function.cooldown
+ except AttributeError:
+ cooldown_object = None
+
+ try:
+ checklist = handler_function.checks
+ del handler_function.checks
+ except AttributeError:
+ checklist = []
+
+ listener_object = listener_class(packet, components, handler_function,
+ cooldown=cooldown_object, checklist=checklist,
+ **kwargs)
+ return listener_object
+ return decorator
+
+
+def listener_exists(xt_listeners, xml_listeners, packet):
+ listener_collection = xt_listeners if isinstance(packet, XTPacket) else xml_listeners
+ return packet in listener_collection
+
+
+def is_listener(listener):
+ return issubclass(type(listener), _Listener)
+
+
+def listeners_from_module(xt_listeners, xml_listeners, module):
+ listener_objects = inspect.getmembers(module, is_listener)
+ for listener_name, listener_object in listener_objects:
+ listener_collection = xt_listeners if type(listener_object) == _XTListener else xml_listeners
+ if listener_object.packet not in listener_collection:
+ listener_collection[listener_object.packet] = []
+
+ if listener_object not in listener_collection[listener_object.packet]:
+ if listener_object.priority == Priority.High:
+ listener_collection[listener_object.packet].insert(0, listener_object)
+ elif listener_object.priority == Priority.Override:
+ listener_collection[listener_object.packet] = [listener_object]
+ else:
+ listener_collection[listener_object.packet].append(listener_object)
+
+ for listener_name, listener_object in listener_objects:
+ listener_collection = xt_listeners if type(listener_object) == _XTListener else xml_listeners
+ for override in listener_object.overrides:
+ listener_collection[override.packet].remove(override)
+
+
+def cooldown(per=1.0, rate=1, bucket_type=BucketType.Default):
+ def decorator(handler_function):
+ handler_function.cooldown = _CooldownMapping(_Cooldown(per, rate, bucket_type))
+ return handler_function
+ return decorator
+
+
+def check(predicate):
+ def decorator(handler_function):
+ if not hasattr(handler_function, 'checks'):
+ handler_function.checks = []
+
+ if not type(predicate) == FunctionType:
+ raise TypeError('All handler checks must be a function')
+
+ handler_function.checks.append(predicate)
+ return handler_function
+ return decorator
+
+
+def allow_once():
+ def check_for_packet(packet, p):
+ return packet not in p.received_packets
+ return check(check_for_packet)
+
+
+def player_attribute(**attrs):
+ def check_for_attributes(_, p):
+ for attr, value in attrs.items():
+ if not getattr(p, attr) == value:
+ return False
+ return True
+ return check(check_for_attributes)
+
+
+def player_data_attribute(**attrs):
+ def check_for_attributes(_, p):
+ for attr, value in attrs.items():
+ if not getattr(p.data, attr) == value:
+ return False
+ return True
+ return check(check_for_attributes)
+
+
+def player_in_room(*room_ids):
+ def check_room_id(_, p):
+ return p.room.ID in room_ids
+ return check(check_room_id)
diff --git a/Houdini/HoudiniFactory.py b/Houdini/HoudiniFactory.py
new file mode 100644
index 0000000..1187740
--- /dev/null
+++ b/Houdini/HoudiniFactory.py
@@ -0,0 +1,181 @@
+import asyncio
+import os
+import sys
+import pkgutil
+import importlib
+
+from Houdini.Spheniscidae import Spheniscidae
+from Houdini.Penguin import Penguin
+from Houdini import PenguinStringCompiler
+import config
+
+from aiologger import Logger
+from aiologger.handlers.files import AsyncTimedRotatingFileHandler, RolloverInterval, AsyncFileHandler
+from aiologger.handlers.streams import AsyncStreamHandler
+
+import logging
+from logging.handlers import RotatingFileHandler
+
+import aioredis
+from aiocache import SimpleMemoryCache
+from watchdog.observers import Observer
+
+from gino import Gino
+
+try:
+ import uvloop
+ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
+except ImportError:
+ uvloop = None
+
+import Houdini.Handlers
+from Houdini.Handlers import listeners_from_module
+from Houdini.Events.HandlerFileEvent import HandlerFileEventHandler
+
+
+class HoudiniFactory:
+
+ def __init__(self, **kwargs):
+ self.server = None
+ self.redis = None
+ self.config = None
+ self.cache = None
+ self.db = Gino()
+ self.peers_by_ip = {}
+
+ self.server_name = kwargs['server']
+ self.server_config = None
+
+ self.logger = None
+
+ self.client_class = Spheniscidae
+ self.penguin_string_compiler = None
+
+ self.penguins_by_id = {}
+ self.penguins_by_username = {}
+
+ self.xt_listeners, self.xml_listeners = {}, {}
+
+ async def start(self):
+ self.config = config
+
+ self.server_config = self.config.servers[self.server_name]
+
+ self.server = await asyncio.start_server(
+ self.client_connected, self.server_config['Address'],
+ self.server_config['Port']
+ )
+
+ await self.db.set_bind('postgresql://{}:{}@{}/{}'.format(
+ self.config.database['Username'], self.config.database['Password'],
+ self.config.database['Address'],
+ self.config.database['Name']))
+
+ general_log_directory = os.path.dirname(self.server_config["Logging"]["General"])
+ errors_log_directory = os.path.dirname(self.server_config["Logging"]["Errors"])
+
+ if not os.path.exists(general_log_directory):
+ os.mkdir(general_log_directory)
+
+ if not os.path.exists(errors_log_directory):
+ os.mkdir(errors_log_directory)
+
+ if sys.platform != 'win32':
+ self.logger = Logger.with_default_handlers(name='Houdini')
+ universal_handler = AsyncTimedRotatingFileHandler(
+ filename=self.server_config['Logging']['General'],
+ backup_count=3,
+ when=RolloverInterval.HOURS
+ )
+ error_handler = AsyncFileHandler(filename=self.server_config['Logging']['General'])
+ console_handler = AsyncStreamHandler(stream=sys.stdout)
+ else:
+ self.logger = logging.getLogger('Houdini')
+ universal_handler = RotatingFileHandler(self.server_config['Logging']['General'],
+ maxBytes=2097152, backupCount=3, encoding='utf-8')
+
+ error_handler = logging.FileHandler(self.server_config['Logging']['Errors'])
+ console_handler = logging.StreamHandler(stream=sys.stdout)
+
+ log_formatter = logging.Formatter('%(asctime)s [%(levelname)-5.5s] %(message)s')
+ error_handler.setLevel(logging.ERROR)
+
+ universal_handler.setFormatter(log_formatter)
+ console_handler.setFormatter(log_formatter)
+
+ self.logger.addHandler(universal_handler)
+ self.logger.addHandler(console_handler)
+
+ level = logging.getLevelName(self.server_config['Logging']['Level'])
+ self.logger.setLevel(level)
+
+ self.logger.info('Houdini module instantiated')
+
+ if self.server_config['World']:
+ self.redis = await aioredis.create_redis_pool('redis://{}:{}'.format(
+ self.config.redis['Address'], self.redis['Port']),
+ minsize=5, maxsize=10)
+
+ await self.redis.delete('{}.players'.format(self.server_name))
+ await self.redis.delete('{}.population'.format(self.server_name))
+
+ self.cache = SimpleMemoryCache(namespace='houdini', ttl=self.server_config['CacheExpiry'])
+
+ self.client_class = Penguin
+ self.penguin_string_compiler = PenguinStringCompiler()
+
+ self.load_handler_modules(exclude_load="Houdini.Handlers.Login.Login")
+ self.logger.info('World server started')
+ else:
+ self.load_handler_modules("Houdini.Handlers.Login.Login")
+ self.logger.info('Login server started')
+
+ handlers_path = './Houdini{}Handlers'.format(os.path.sep)
+ plugins_path = './Houdini{}Plugins'.format(os.path.sep)
+ self.configure_obvservers([handlers_path, HandlerFileEventHandler])
+
+ self.logger.info('Listening on {}:{}'.format(self.server_config['Address'], self.server_config['Port']))
+
+ async with self.server:
+ await self.server.serve_forever()
+
+ async def client_connected(self, reader, writer):
+ client_object = self.client_class(self, reader, writer)
+ await client_object.run()
+
+ def load_handler_modules(self, strict_load=None, exclude_load=None):
+ for handler_module in self.get_package_modules(Houdini.Handlers):
+ if not (strict_load and handler_module not in strict_load or exclude_load and handler_module in exclude_load):
+ if handler_module not in sys.modules.keys():
+ module = importlib.import_module(handler_module)
+ listeners_from_module(self.xt_listeners, self.xml_listeners, module)
+
+ self.logger.info("Handler modules loaded")
+
+ def get_package_modules(self, package):
+ package_modules = []
+
+ for importer, module_name, is_package in pkgutil.iter_modules(package.__path__):
+ full_module_name = "{0}.{1}".format(package.__name__, module_name)
+
+ if is_package:
+ subpackage_object = importlib.import_module(full_module_name, package=package.__path__)
+ subpackage_object_directory = dir(subpackage_object)
+
+ if "Plugin" in subpackage_object_directory:
+ package_modules.append((subpackage_object, module_name))
+ continue
+
+ sub_package_modules = self.get_package_modules(subpackage_object)
+
+ package_modules = package_modules + sub_package_modules
+ else:
+ package_modules.append(full_module_name)
+
+ return package_modules
+
+ def configure_obvservers(self, *observer_settings):
+ for observer_path, observer_class in observer_settings:
+ event_observer = Observer()
+ event_observer.schedule(observer_class(self), observer_path, recursive=True)
+ event_observer.start()
diff --git a/Houdini/Penguin.py b/Houdini/Penguin.py
new file mode 100644
index 0000000..2594815
--- /dev/null
+++ b/Houdini/Penguin.py
@@ -0,0 +1,36 @@
+from Houdini.Spheniscidae import Spheniscidae
+
+
+class Penguin(Spheniscidae):
+
+ __slots__ = ['x', 'y', 'room', 'waddle', 'table', 'data']
+
+ def __init__(self, *args):
+ super().__init__(*args)
+
+ self.x, self.y = (0, 0)
+ self.room = None
+ self.waddle = None
+ self.table = None
+
+ self.data = None
+
+ self.logger.debug('New penguin created')
+
+ async def add_inventory(self, item):
+ pass
+
+ async def add_igloo(self, igloo):
+ pass
+
+ async def add_furniture(self, furniture):
+ pass
+
+ async def add_flooring(self, flooring):
+ pass
+
+ async def add_buddy(self, buddy):
+ pass
+
+ async def add_inbox(self, postcard):
+ pass
diff --git a/Houdini/Plugins/Commands/__init__.py b/Houdini/Plugins/Commands/__init__.py
new file mode 100644
index 0000000..5999eb8
--- /dev/null
+++ b/Houdini/Plugins/Commands/__init__.py
@@ -0,0 +1,11 @@
+from Houdini.Handlers import Handlers, XTPacket
+
+
+class Commands:
+
+ def __init__(self, server):
+ self.server = server
+
+ @Handlers.handler(XTPacket('s', 'sm'))
+ async def handle_send_message(self, message: str):
+ print('Do stuff with {}'.format(message))
diff --git a/Houdini/Plugins/__init__.py b/Houdini/Plugins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Houdini/Spheniscidae.py b/Houdini/Spheniscidae.py
new file mode 100644
index 0000000..ca89a8f
--- /dev/null
+++ b/Houdini/Spheniscidae.py
@@ -0,0 +1,159 @@
+from Houdini import Handlers
+from Houdini.Handlers import XMLPacket, XTPacket
+
+from asyncio import IncompleteReadError
+
+import defusedxml.cElementTree as Et
+from xml.etree.cElementTree import Element, SubElement, tostring
+
+
+class Spheniscidae:
+
+ __slots__ = ['__reader', '__writer', 'server', 'logger',
+ 'peer_name', 'received_packets', 'joined_world']
+
+ Delimiter = b'\x00'
+
+ def __init__(self, server, reader, writer):
+ self.__reader = reader
+ self.__writer = writer
+
+ self.server = server
+ self.logger = server.logger
+
+ self.peer_name = writer.get_extra_info('peername')
+ self.server.peers_by_ip[self.peer_name] = self
+
+ self.joined_world = False
+
+ self.received_packets = set()
+
+ async def send_error_and_disconnect(self, error):
+ await self.send_xt('e', error)
+ await self.close()
+
+ async def send_error(self, error):
+ await self.send_xt('e', error)
+
+ async def send_policy_file(self):
+ await self.send_line(''
+ .format(self.server.server_config['Port']))
+ await self.close()
+
+ async def send_xt(self, *data):
+ data = list(data)
+
+ handler_id = data.pop(0)
+ internal_id = -1
+
+ mapped_data = map(str, data)
+
+ xt_data = '%'.join(mapped_data)
+ line = '%xt%{0}%{1}%{2}%'.format(handler_id, internal_id, xt_data)
+ await self.send_line(line)
+
+ async def send_xml(self, xml_dict):
+ data_root = Element('msg')
+ data_root.set('t', 'sys')
+
+ sub_element_parent = data_root
+ for sub_element, sub_element_attribute in xml_dict.iteritems():
+ sub_element_object = SubElement(sub_element_parent, sub_element)
+
+ if type(xml_dict[sub_element]) is dict:
+ for sub_element_attribute_key, sub_element_attribute_value in xml_dict[sub_element].iteritems():
+ sub_element_object.set(sub_element_attribute_key, sub_element_attribute_value)
+ else:
+ sub_element_object.text = xml_dict[sub_element]
+
+ sub_element_parent = sub_element_object
+
+ xml_data = tostring(data_root)
+ await self.send_line(xml_data)
+
+ async def send_line(self, data):
+ self.logger.debug('Outgoing data: %s', data)
+ self.__writer.write(data.encode() + Spheniscidae.Delimiter)
+
+ async def close(self):
+ self.__writer.close()
+
+ async def __handle_xt_data(self, data):
+ self.logger.debug("Received XT data: {0}".format(data))
+ parsed_data = data.split("%")[1:-1]
+
+ packet_id = parsed_data[2]
+ packet = XTPacket(packet_id)
+
+ if Handlers.listener_exists(self.server.xt_listeners, self.server.xml_listeners, packet):
+ xt_listeners = self.server.xt_listeners[packet]
+ packet_data = parsed_data[4:]
+
+ for listener in xt_listeners:
+ await listener(self, packet_data)
+ self.received_packets.add(packet)
+ else:
+ self.logger.debug("Handler for {0} doesn't exist!".format(packet_id))
+
+ async def __handle_xml_data(self, data):
+ self.logger.debug("Received XML data: {0}".format(data))
+
+ element_tree = Et.fromstring(data)
+
+ if element_tree.tag == "policy-file-request":
+ await self.send_policy_file()
+
+ elif element_tree.tag == "msg":
+ self.logger.debug("Received valid XML data")
+
+ try:
+ body_tag = element_tree[0]
+ action = body_tag.get("action")
+ packet = XMLPacket(action)
+
+ if Handlers.listener_exists(self.server.xt_listeners, self.server.xml_listeners, packet):
+ xml_listeners = self.server.xml_listeners[packet]
+
+ for listener in xml_listeners:
+ await listener(self, body_tag)
+
+ self.received_packets.add(packet)
+ else:
+ self.logger.warn("Packet did not contain a valid action attribute!")
+
+ except IndexError:
+ self.logger.warn("Received invalid XML data (didn't contain a body tag)")
+ else:
+ self.logger.warn("Received invalid XML data!")
+
+ async def __client_connected(self):
+ self.logger.info('Client %s connected', self.peer_name)
+
+ async def __client_disconnected(self):
+ del self.server.peers_by_ip[self.peer_name]
+
+ self.logger.info('Client %s disconnected', self.peer_name)
+
+ async def __data_received(self, data):
+ data = data.decode()[:-1]
+ if data.startswith('<'):
+ await self.__handle_xml_data(data)
+ else:
+ await self.__handle_xt_data(data)
+
+ async def run(self):
+ await self.__client_connected()
+ while not self.__writer.is_closing():
+ try:
+ data = await self.__reader.readuntil(
+ separator=Spheniscidae.Delimiter)
+ if data:
+ await self.__data_received(data)
+ else:
+ self.__writer.close()
+ await self.__writer.drain()
+ except IncompleteReadError:
+ self.__writer.close()
+ except ConnectionResetError:
+ self.__writer.close()
+ await self.__client_disconnected()
diff --git a/Houdini/__init__.py b/Houdini/__init__.py
new file mode 100644
index 0000000..4ae8ce1
--- /dev/null
+++ b/Houdini/__init__.py
@@ -0,0 +1,40 @@
+from collections import OrderedDict
+from aiocache import cached
+from types import FunctionType
+import asyncio
+
+
+class PenguinStringCompiler(OrderedDict):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def __setitem__(self, key, compiler_method):
+ assert type(compiler_method) == FunctionType
+ super().__setitem__(key, compiler_method)
+
+ @cached(namespace='houdini')
+ async def compile(self, p):
+ compiler_method_results = []
+
+ for compiler_method in self.values():
+ if asyncio.iscoroutinefunction(compiler_method):
+ compiler_method_result = await compiler_method(p)
+ else:
+ compiler_method_result = compiler_method(p)
+ compiler_method_results.append(str(compiler_method_result))
+
+ compiler_result = '|'.join(compiler_method_results)
+ return compiler_result
+
+ @classmethod
+ def attribute_by_name(cls, attribute_name):
+ async def attribute_method(p):
+ return getattr(p, attribute_name)
+ return attribute_method
+
+ @classmethod
+ def data_attribute_by_name(cls, attribute_name):
+ async def attribute_method(p):
+ return getattr(p.data, attribute_name)
+ return attribute_method
diff --git a/Test.py b/Test.py
new file mode 100644
index 0000000..c50b4a9
--- /dev/null
+++ b/Test.py
@@ -0,0 +1,25 @@
+import asyncio
+
+
+async def tcp_echo_client(message):
+ reader, writer = await asyncio.open_connection(
+ '127.0.0.1', 6112)
+
+ print(f'Send: {message!r}')
+ writer.write(message.encode())
+
+ data = await reader.read(100)
+ print(f'Received: {data.decode()!r}')
+
+ print('Close the connection')
+ writer.close()
+ await writer.wait_closed()
+
+loop = asyncio.ProactorEventLoop()
+asyncio.set_event_loop(loop)
+
+for x in range(1):
+ # asyncio.ensure_future(tcp_echo_client('\0'))
+ asyncio.ensure_future(tcp_echo_client('%xt%s%t#c%-1%1,2,3,4%\0%xt%s%t#c%-1%1,2,3,4%\0'))
+
+loop.run_forever()
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..3b7fb97
--- /dev/null
+++ b/config.py
@@ -0,0 +1,87 @@
+database = {
+ "Address": "localhost",
+ "Username": "postgres",
+ "Password": "password",
+ "Name": "houdini",
+}
+
+redis = {
+ "Address": "127.0.0.1",
+ "Port": 6379
+}
+
+servers = {
+ "Login": {
+ "Address": "0.0.0.0",
+ "Port": 6112,
+ "World": False,
+ "Plugins": [
+ "Example"
+ ],
+ "Logging": {
+ "General": "logs/login.log",
+ "Errors": "logs/login-errors.log",
+ "Level": "DEBUG"
+ },
+ "LoginFailureLimit": 5,
+ "LoginFailureTimer": 3600
+ },
+ "Wind": {
+ "Id": "100",
+ "Address": "0.0.0.0",
+ "Port": 9875,
+ "World": True,
+ "Capacity": 200,
+ "CacheExpiry": 3600,
+ "Plugins": [
+ "Commands",
+ "Bot",
+ "Rank"
+ ],
+ "Logging": {
+ "General": "logs/wind.log",
+ "Errors": "logs/wind-errors.log",
+ "Level": "INFO"
+ }
+ }
+}
+
+tables = {
+ "Four": [
+ {"RoomId": 220, "Tables": [205, 206, 207]},
+ {"RoomId": 221, "Tables": [200, 201, 202, 203, 204]}
+ ],
+ "Mancala": [
+ {"RoomId": 111, "Tables": [100, 101, 102, 103, 104]}
+ ],
+ "Treasure": [
+ {"RoomId": 422, "Tables": [300, 301, 302, 303, 304, 305, 306, 307]}
+ ]
+}
+
+waddles = {
+ "Sled": [
+ {"RoomId": 230, "Waddles": [
+ {"Id": 100, "Seats": 4},
+ {"Id": 101, "Seats": 3},
+ {"Id": 102, "Seats": 2},
+ {"Id": 103, "Seats": 2}
+ ]}
+ ],
+ "Card": [
+ {"RoomId": 320, "Waddles": [
+ {"Id": 200, "Seats": 2},
+ {"Id": 201, "Seats": 2},
+ {"Id": 202, "Seats": 2},
+ {"Id": 203, "Seats": 2}
+ ]}
+ ],
+ "CardFire": [
+ {"RoomId": 812, "Waddles": [
+ {"Id": 300, "Seats": 2},
+ {"Id": 301, "Seats": 2},
+ {"Id": 302, "Seats": 3},
+ {"Id": 303, "Seats": 4}
+ ]}
+ ]
+}
diff --git a/houdini.sql b/houdini.sql
new file mode 100644
index 0000000..d06c44d
--- /dev/null
+++ b/houdini.sql
@@ -0,0 +1,762 @@
+DROP TABLE IF EXISTS item;
+CREATE TABLE item (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30),
+ "Type" SMALLINT NOT NULL DEFAULT 1,
+ "Cost" SMALLINT NOT NULL DEFAULT 0,
+ "Member" BOOLEAN NOT NULL DEFAULT FALSE,
+ "Bait" BOOLEAN NOT NULL DEFAULT FALSE,
+ "Patched" BOOLEAN NOT NULL DEFAULT FALSE,
+ "EPF" BOOLEAN NOT NULL DEFAULT FALSE,
+ "Tour" BOOLEAN NOT NULL DEFAULT FALSE,
+ "ReleaseDate" DATE NOT NULL,
+ PRIMARY KEY ("ID")
+);
+
+ALTER TABLE item ALTER COLUMN "ReleaseDate" SET DEFAULT now();
+
+COMMENT ON TABLE item IS 'Server item crumbs';
+
+COMMENT ON COLUMN item."ID" IS 'Unique item ID';
+COMMENT ON COLUMN item."Name" IS 'Item name';
+COMMENT ON COLUMN item."Type" IS 'Item clothing type';
+COMMENT ON COLUMN item."Cost" IS 'Cost of item';
+COMMENT ON COLUMN item."Member" IS 'Is member-only?';
+COMMENT ON COLUMN item."Bait" IS 'Is bait item?';
+COMMENT ON COLUMN item."Patched" IS 'Is item patched?';
+COMMENT ON COLUMN item."EPF" IS 'Is EPF item?';
+COMMENT ON COLUMN item."Tour" IS 'Gives tour status?';
+
+DROP TABLE IF EXISTS igloo;
+CREATE TABLE igloo (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL,
+ "Cost" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY("ID")
+);
+
+COMMENT ON TABLE igloo IS 'Server igloo crumbs';
+
+COMMENT ON COLUMN igloo."ID" IS 'Unique igloo ID';
+COMMENT ON COLUMN igloo."Name" IS 'Igloo name';
+COMMENT ON COLUMN igloo."Cost" IS 'Cost of igloo';
+
+DROP TABLE IF EXISTS location;
+CREATE TABLE location (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL,
+ "Cost" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("ID")
+);
+
+COMMENT ON TABLE location IS 'Server location crumbs';
+
+COMMENT ON COLUMN location."ID" IS 'Unique location ID';
+COMMENT ON COLUMN location."Name" IS 'Location name';
+COMMENT ON COLUMN location."Cost" IS 'Cost of location';
+
+DROP TABLE IF EXISTS furniture;
+CREATE TABLE furniture (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL,
+ "Type" SMALLINT NOT NULL DEFAULT 1,
+ "Sort" SMALLINT NOT NULL DEFAULT 1,
+ "Cost" SMALLINT NOT NULL DEFAULT 0,
+ "Member" BOOLEAN NOT NULL DEFAULT FALSE,
+ "MaxQuantity" SMALLINT NOT NULL DEFAULT 100,
+ PRIMARY KEY("ID")
+);
+
+COMMENT ON TABLE furniture IS 'Server furniture crumbs';
+
+COMMENT ON COLUMN furniture."ID" IS 'Unique furniture ID';
+COMMENT ON COLUMN furniture."Type" IS 'Furniture type ID';
+COMMENT ON COLUMN furniture."Sort" IS 'Furniture sort ID';
+COMMENT ON COLUMN furniture."Cost" IS 'Cost of furniture';
+COMMENT ON COLUMN furniture."Member" IS 'Is member-only?';
+COMMENT ON COLUMN furniture."MaxQuantity" IS 'Max inventory quantity';
+
+DROP TABLE IF EXISTS flooring;
+CREATE TABLE flooring (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30),
+ "Cost" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("ID")
+);
+
+COMMENT ON TABLE flooring IS 'Server flooring crumbs';
+
+COMMENT ON COLUMN flooring."ID" IS 'Unique flooring ID';
+COMMENT ON COLUMN flooring."Name" IS 'Flooring name';
+COMMENT ON COLUMN flooring."Cost" IS 'Cost of flooring';
+
+CREATE TYPE card_element AS ENUM ('s', 'w', 'f');
+CREATE TYPE card_color AS ENUM ('b', 'g', 'o', 'p', 'r', 'y');
+
+DROP TABLE IF EXISTS card;
+CREATE TABLE card (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL,
+ "SetID" SMALLINT NOT NULL DEFAULT 1,
+ "PowerID" SMALLINT NOT NULL DEFAULT 0,
+ "Element" card_element NOT NULL DEFAULT 's',
+ "Color" card_color NOT NULL DEFAULT 'b',
+ "Value" SMALLINT NOT NULL DEFAULT 2,
+ "Description" VARCHAR(50) NOT NULL DEFAULT '',
+ PRIMARY KEY ("ID")
+);
+
+COMMENT ON TABLE card IS 'Server jitsu card crumbs';
+
+COMMENT ON COLUMN card."ID" IS 'Unique card ID';
+COMMENT ON COLUMN card."Name" IS 'Card name';
+COMMENT ON COLUMN card."SetID" IS 'Card set ID';
+COMMENT ON COLUMN card."PowerID" IS 'Card power ID';
+COMMENT ON COLUMN card."Element" IS 'Card element';
+COMMENT ON COLUMN card."Color" IS 'Card color';
+COMMENT ON COLUMN card."Value" IS 'Value of card';
+COMMENT ON COLUMN card."Description" IS 'Play description';
+
+DROP TABLE IF EXISTS room;
+CREATE TABLE room (
+ "ID" SMALLINT NOT NULL,
+ "InternalID" SERIAL NOT NULL,
+ "Name" VARCHAR(30) NOT NULL,
+ "Member" BOOLEAN NOT NULL DEFAULT FALSE,
+ "MaxUsers" SMALLINT NOT NULL DEFAULT 80,
+ "RequiredItem" SMALLINT,
+ PRIMARY KEY("ID", "InternalID"),
+ CONSTRAINT room_ibfk_1 FOREIGN KEY ("RequiredItem") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE room IS 'Server room crumbs';
+
+COMMENT ON COLUMN room."ID" IS 'Unique room ID';
+COMMENT ON COLUMN room."InternalID" IS 'Internal room key';
+COMMENT ON COLUMN room."Name" IS 'Room name';
+COMMENT ON COLUMN room."Member" IS 'Is member-only?';
+COMMENT ON COLUMN room."MaxUsers" IS 'Maximum room users';
+COMMENT ON COLUMN room."RequiredItem" IS 'Required inventory item';
+
+DROP TABLE IF EXISTS stamp;
+CREATE TABLE stamp (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL,
+ "GroupID" SMALLINT NOT NULL DEFAULT 0,
+ "Member" BOOLEAN NOT NULL DEFAULT FALSE,
+ "Rank" SMALLINT NOT NULL DEFAULT 1,
+ "Description" VARCHAR(50) NOT NULL DEFAULT '',
+ PRIMARY KEY("ID")
+);
+
+COMMENT ON TABLE stamp IS 'Server stamp crumbs';
+
+COMMENT ON COLUMN stamp."ID" IS 'Unique stamp ID';
+COMMENT ON COLUMN stamp."Name" IS 'Stamp name';
+COMMENT ON COLUMN stamp."GroupID" IS 'Stamp group ID';
+COMMENT ON COLUMN stamp."Member" IS 'Is member-only?';
+COMMENT ON COLUMN stamp."Rank" IS 'Stamp difficulty ranking';
+COMMENT ON COLUMN stamp."Description" IS 'Stamp description';
+
+DROP TABLE IF EXISTS puffle_care_item;
+CREATE TABLE puffle_care_item (
+ "ID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL DEFAULT '',
+ "Cost" SMALLINT NOT NULL DEFAULT 0,
+ "Quantity" SMALLINT NOT NULL DEFAULT 1,
+ "Member" BOOLEAN NOT NULL DEFAULT FALSE,
+ "FoodEffect" SMALLINT NOT NULL DEFAULT 0,
+ "RestEffect" SMALLINT NOT NULL DEFAULT 0,
+ "PlayEffect" SMALLINT NOT NULL DEFAULT 0,
+ "CleanEffect" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("ID")
+);
+
+COMMENT ON TABLE puffle_care_item IS 'Server puffle care item crumbs';
+
+COMMENT ON COLUMN puffle_care_item."ID" IS 'Unique care item ID';
+COMMENT ON COLUMN puffle_care_item."Name" IS 'Care item name';
+COMMENT ON COLUMN puffle_care_item."Cost" IS 'Cost of care item';
+COMMENT ON COLUMN puffle_care_item."Quantity" IS 'Base quantity of purchase';
+COMMENT ON COLUMN puffle_care_item."Member" IS 'Is member-only?';
+COMMENT ON COLUMN puffle_care_item."FoodEffect" IS 'Effect on puffle food level';
+COMMENT ON COLUMN puffle_care_item."RestEffect" IS 'Effect on puffle rest level';
+COMMENT ON COLUMN puffle_care_item."PlayEffect" IS 'Effect on puffle play level';
+COMMENT ON COLUMN puffle_care_item."CleanEffect" IS 'Effect on puffle clean level';
+
+DROP TABLE IF EXISTS puffle;
+CREATE TABLE puffle (
+ "ID" SMALLINT NOT NULL,
+ "ParentID" SMALLINT NOT NULL,
+ "Name" VARCHAR(30) NOT NULL DEFAULT '',
+ "Member" BOOLEAN NOT NULL DEFAULT FALSE,
+ "FavouriteFood" SMALLINT NOT NULL,
+ "RunawayPostcard" SMALLINT NOT NULL DEFAULT 100,
+ "MaxFood" SMALLINT NOT NULL DEFAULT 100,
+ "MaxRest" SMALLINT NOT NULL DEFAULT 100,
+ "MaxClean" SMALLINT NOT NULL DEFAULT 100,
+ PRIMARY KEY ("ID"),
+ CONSTRAINT puffle_ibfk_1 FOREIGN KEY ("ParentID") REFERENCES puffle ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT puffle_ibfk_2 FOREIGN KEY ("FavouriteFood") REFERENCES puffle_care_item ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE puffle IS 'Server puffle crumbs';
+
+COMMENT ON COLUMN puffle."ID" IS 'Unique puffle ID';
+COMMENT ON COLUMN puffle."ParentID" IS 'Base color puffle ID';
+COMMENT ON COLUMN puffle."Name" IS 'Puffle name';
+COMMENT ON COLUMN puffle."Member" IS 'Is member-only?';
+COMMENT ON COLUMN puffle."FavouriteFood" IS 'Favourite puffle-care item';
+COMMENT ON COLUMN puffle."RunawayPostcard" IS 'Runaway postcard ID';
+COMMENT ON COLUMN puffle."MaxFood" IS 'Maximum food level';
+COMMENT ON COLUMN puffle."MaxRest" IS 'Maximum rest level';
+COMMENT ON COLUMN puffle."MaxClean" IS 'Maximum clean level';
+
+DROP TABLE IF EXISTS penguin;
+CREATE TABLE penguin (
+ "ID" SERIAL,
+ "Username" VARCHAR(12) NOT NULL,
+ "Nickname" VARCHAR(30) NOT NULL,
+ "Approval" BOOLEAN NOT NULL DEFAULT FALSE,
+ "Password" CHAR(255) NOT NULL,
+ "LoginKey" CHAR(255) DEFAULT '',
+ "Email" VARCHAR(255) NOT NULL,
+ "RegistrationDate" TIMESTAMP NOT NULL,
+ "Active" BOOLEAN NOT NULL DEFAULT FALSE,
+ "LastPaycheck" TIMESTAMP NOT NULL,
+ "MinutesPlayed" INT NOT NULL DEFAULT 0,
+ "Moderator" BOOLEAN NOT NULL DEFAULT FALSE,
+ "Member" BOOLEAN NOT NULL DEFAULT TRUE,
+ "MascotStamp" SMALLINT DEFAULT NULL,
+ "Coins" INT NOT NULL DEFAULT 500,
+ "Color" SMALLINT DEFAULT NULL,
+ "Head" SMALLINT DEFAULT NULL,
+ "Face" SMALLINT DEFAULT NULL,
+ "Neck" SMALLINT DEFAULT NULL,
+ "Body" SMALLINT DEFAULT NULL,
+ "Hand" SMALLINT DEFAULT NULL,
+ "Feet" SMALLINT DEFAULT NULL,
+ "Photo" SMALLINT DEFAULT NULL,
+ "Flag" SMALLINT DEFAULT NULL,
+ "Permaban" SMALLINT NOT NULL DEFAULT 0,
+ "BookModified" SMALLINT NOT NULL DEFAULT 0,
+ "BookColor" SMALLINT NOT NULL DEFAULT 1,
+ "BookHighlight" SMALLINT NOT NULL DEFAULT 1,
+ "BookPattern" SMALLINT NOT NULL DEFAULT 0,
+ "BookIcon" SMALLINT NOT NULL DEFAULT 1,
+ "AgentStatus" SMALLINT NOT NULL DEFAULT 0,
+ "FieldOpStatus" SMALLINT NOT NULL DEFAULT 0,
+ "CareerMedals" INT NOT NULL DEFAULT 0,
+ "AgentMedals" INT NOT NULL DEFAULT 0,
+ "LastFieldOp" TIMESTAMP NOT NULL,
+ "NinjaRank" SMALLINT NOT NULL DEFAULT 0,
+ "NinjaProgress" SMALLINT NOT NULL DEFAULT 0,
+ "FireNinjaRank" SMALLINT NOT NULL DEFAULT 0,
+ "FireNinjaProgress" SMALLINT NOT NULL DEFAULT 0,
+ "WaterNinjaRank" SMALLINT NOT NULL DEFAULT 0,
+ "WaterNinjaProgress" SMALLINT NOT NULL DEFAULT 0,
+ "NinjaMatchesWon" INT NOT NULL DEFAULT 0,
+ "FireMatchesWon" INT NOT NULL DEFAULT 0,
+ "WaterMatchesWon" INT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("ID"),
+ CONSTRAINT penguin_ibfk_1 FOREIGN KEY ("Color") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_2 FOREIGN KEY ("Head") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_3 FOREIGN KEY ("Face") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_4 FOREIGN KEY ("Neck") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_5 FOREIGN KEY ("Body") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_6 FOREIGN KEY ("Hand") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_7 FOREIGN KEY ("Feet") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_8 FOREIGN KEY ("Photo") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_9 FOREIGN KEY ("Flag") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_ibfk_10 FOREIGN KEY ("MascotStamp") REFERENCES stamp ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX "Email" ON Penguin("Email");
+CREATE UNIQUE INDEX "Username" ON Penguin("Username");
+
+ALTER TABLE penguin ALTER COLUMN "RegistrationDate" SET DEFAULT now();
+ALTER TABLE penguin ALTER COLUMN "LastPaycheck" SET DEFAULT now();
+ALTER TABLE penguin ALTER COLUMN "LastFieldOp" SET DEFAULT now();
+
+COMMENT ON TABLE penguin IS 'Penguins';
+
+COMMENT ON COLUMN penguin."ID" IS 'Unique penguin ID';
+COMMENT ON COLUMN penguin."Username" IS 'Penguin login name';
+COMMENT ON COLUMN penguin."Nickname" IS 'Penguin display name';
+COMMENT ON COLUMN penguin."Approval" IS 'Username approval';
+COMMENT ON COLUMN penguin."Password" IS 'Password hash';
+COMMENT ON COLUMN penguin."LoginKey" IS 'Temporary login key';
+COMMENT ON COLUMN penguin."Email" IS 'User Email address';
+COMMENT ON COLUMN penguin."RegistrationDate" IS 'Date of registration';
+COMMENT ON COLUMN penguin."Active" IS '"Email" activated';
+COMMENT ON COLUMN penguin."LastPaycheck" IS 'EPF previous paycheck';
+COMMENT ON COLUMN penguin."MinutesPlayed" IS 'Total minutes connected';
+COMMENT ON COLUMN penguin."Moderator" IS 'Is user moderator?';
+COMMENT ON COLUMN penguin."Member" IS 'Is user member?';
+COMMENT ON COLUMN penguin."MascotStamp" IS 'Mascot stamp ID';
+COMMENT ON COLUMN penguin."Coins" IS 'Penguin coins';
+COMMENT ON COLUMN penguin."Color" IS 'Penguin color ID';
+COMMENT ON COLUMN penguin."Head" IS 'Penguin head item ID';
+COMMENT ON COLUMN penguin."Face" IS 'Penguin face item ID';
+COMMENT ON COLUMN penguin."Neck" IS 'Penguin neck item ID';
+COMMENT ON COLUMN penguin."Body" IS 'Penguin body item ID';
+COMMENT ON COLUMN penguin."Hand" IS 'Penguin hand item ID';
+COMMENT ON COLUMN penguin."Feet" IS 'Penguin feet item ID';
+COMMENT ON COLUMN penguin."Photo" IS 'Penguin background ID';
+COMMENT ON COLUMN penguin."Flag" IS 'Penguin pin ID';
+COMMENT ON COLUMN penguin."Permaban" IS 'Is penguin banned forever?';
+COMMENT ON COLUMN penguin."BookModified" IS 'Is book cover modified?';
+COMMENT ON COLUMN penguin."BookColor" IS 'Stampbook cover color';
+COMMENT ON COLUMN penguin."BookHighlight" IS 'Stampbook highlight color';
+COMMENT ON COLUMN penguin."BookPattern" IS 'Stampbook cover pattern';
+COMMENT ON COLUMN penguin."BookIcon" IS 'Stampbook cover icon';
+COMMENT ON COLUMN penguin."AgentStatus" IS 'Is penguin EPF agent?';
+COMMENT ON COLUMN penguin."FieldOpStatus" IS 'Is field op complete?';
+COMMENT ON COLUMN penguin."CareerMedals" IS 'Total career medals';
+COMMENT ON COLUMN penguin."AgentMedals" IS 'Current medals';
+COMMENT ON COLUMN penguin."LastFieldOp" IS 'Date of last field op';
+COMMENT ON COLUMN penguin."NinjaRank" IS 'Ninja rank';
+COMMENT ON COLUMN penguin."NinjaProgress" IS 'Ninja progress';
+COMMENT ON COLUMN penguin."FireNinjaRank" IS 'Fire ninja rank';
+COMMENT ON COLUMN penguin."FireNinjaProgress" IS 'Fire ninja progress';
+COMMENT ON COLUMN penguin."WaterNinjaRank" IS 'Water ninja rank';
+COMMENT ON COLUMN penguin."WaterNinjaProgress" IS 'Water ninja progress';
+COMMENT ON COLUMN penguin."NinjaMatchesWon" IS 'CardJitsu matches won';
+COMMENT ON COLUMN penguin."FireMatchesWon" IS 'JitsuFire matches won';
+COMMENT ON COLUMN penguin."WaterMatchesWon" IS 'JitsuWater matces won';
+
+
+DROP TABLE IF EXISTS activation_key;
+CREATE TABLE activation_key (
+ "PenguinID" INT NOT NULL,
+ "ActivationKey" CHAR(255) NOT NULL,
+ PRIMARY KEY ("PenguinID", "ActivationKey")
+);
+
+COMMENT ON TABLE activation_key IS 'Penguin activation keys';
+
+COMMENT ON COLUMN activation_key."PenguinID" IS 'Penguin ID';
+COMMENT ON COLUMN activation_key."ActivationKey" IS 'Penguin activation key';
+
+DROP TABLE IF EXISTS ban;
+CREATE TABLE ban (
+ "PenguinID" INT NOT NULL,
+ "Issued" TIMESTAMP NOT NULL,
+ "Expires" TIMESTAMP NOT NULL,
+ "ModeratorID" INT DEFAULT NULL,
+ "Reason" SMALLINT NOT NULL,
+ "Comment" text DEFAULT NULL,
+ PRIMARY KEY ("PenguinID", "Issued", "Expires"),
+ CONSTRAINT ban_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT ban_ibfk_2 FOREIGN KEY ("ModeratorID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX "ModeratorID" ON ban ("ModeratorID");
+
+ALTER TABLE ban ALTER COLUMN "Issued" SET DEFAULT now();
+ALTER TABLE ban ALTER COLUMN "Expires" SET DEFAULT now();
+
+COMMENT ON TABLE ban IS 'Penguin ban records';
+
+COMMENT ON COLUMN ban."PenguinID" IS 'Banned penguin ID';
+COMMENT ON COLUMN ban."Issued" IS 'Issue date';
+COMMENT ON COLUMN ban."Expires" IS 'Expiry date';
+COMMENT ON COLUMN ban."ModeratorID" IS 'Moderator penguin ID';
+COMMENT ON COLUMN ban."Reason" IS 'Ban reason';
+COMMENT ON COLUMN ban."Comment" IS 'Ban comment';
+
+DROP TABLE IF EXISTS buddy_list;
+CREATE TABLE buddy_list (
+ "PenguinID" INT NOT NULL,
+ "BuddyID" INT NOT NULL,
+ PRIMARY KEY ("PenguinID","BuddyID"),
+ CONSTRAINT buddy_list_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT buddy_list_ibfk_2 FOREIGN KEY ("BuddyID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX "BuddyID" ON buddy_list ("BuddyID");
+
+COMMENT ON TABLE buddy_list IS 'Penguin buddy relationships';
+
+DROP TABLE IF EXISTS buddy_request;
+CREATE TABLE buddy_request (
+ "PenguinID" INT NOT NULL,
+ "RequesterID" INT NOT NULL,
+ PRIMARY KEY ("PenguinID", "RequesterID"),
+ CONSTRAINT buddy_request_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT buddy_request_ibfk_2 FOREIGN KEY ("RequesterID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE buddy_request IS 'Penguin buddy requests';
+
+DROP TABLE IF EXISTS best_buddy;
+CREATE TABLE best_buddy (
+ "PenguinID" INT NOT NULL,
+ "BuddyID" INT NOT NULL,
+ PRIMARY KEY ("PenguinID", "BuddyID"),
+ CONSTRAINT best_buddy_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT best_buddy_ibfk_2 FOREIGN KEY ("BuddyID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE best_buddy IS 'Penguin best buddies';
+
+DROP TABLE IF EXISTS cover_stamps;
+CREATE TABLE cover_stamps (
+ "PenguinID" INT NOT NULL,
+ "StampID" SMALLINT NOT NULL,
+ "ItemID" SMALLINT NOT NULL,
+ "X" SMALLINT NOT NULL DEFAULT 0,
+ "Y" SMALLINT NOT NULL DEFAULT 0,
+ "Type" SMALLINT NOT NULL DEFAULT 0,
+ "Rotation" SMALLINT NOT NULL DEFAULT 0,
+ "Depth" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("PenguinID", "StampID"),
+ CONSTRAINT cover_stamps_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT cover_stamps_ibfk_2 FOREIGN KEY ("StampID") REFERENCES stamp ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT cover_stamps_ibfk_3 FOREIGN KEY ("ItemID") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE cover_stamps IS 'Stamps placed on book cover';
+
+COMMENT ON COLUMN cover_stamps."PenguinID" IS 'Unique penguin ID';
+COMMENT ON COLUMN cover_stamps."StampID" IS 'Cover stamp or item ID';
+COMMENT ON COLUMN cover_stamps."X" IS 'Cover X position';
+COMMENT ON COLUMN cover_stamps."Y" IS 'Cover Y position';
+COMMENT ON COLUMN cover_stamps."Type" IS 'Cover item type';
+COMMENT ON COLUMN cover_stamps."Rotation" IS 'Stamp cover rotation';
+COMMENT ON COLUMN cover_stamps."Depth" IS 'Stamp cover depth';
+
+DROP TABLE IF EXISTS penguin_card;
+CREATE TABLE penguin_card (
+ "PenguinID" INT NOT NULL,
+ "CardID" SMALLINT NOT NULL,
+ "Quantity" SMALLINT NOT NULL DEFAULT 1,
+ PRIMARY KEY ("PenguinID", "CardID"),
+ CONSTRAINT penguin_card_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_card_ibfk_2 FOREIGN KEY ("CardID") REFERENCES card ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX "PenguinID" ON penguin_card("PenguinID");
+
+COMMENT ON TABLE penguin_card IS 'Penguin Card Jitsu decks';
+
+COMMENT ON COLUMN penguin_card."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN penguin_card."CardID" IS 'Card type ID';
+COMMENT ON COLUMN penguin_card."Quantity" IS 'Quantity owned';
+
+DROP TABLE IF EXISTS penguin_furniture;
+CREATE TABLE penguin_furniture (
+ "PenguinID" INT NOT NULL,
+ "FurnitureID" SMALLINT NOT NULL,
+ "Quantity" SMALLINT NOT NULL DEFAULT 1,
+ PRIMARY KEY ("PenguinID", "FurnitureID"),
+ CONSTRAINT penguin_furniture_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_furniture_ibfk_2 FOREIGN KEY ("FurnitureID") REFERENCES furniture ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE penguin_furniture IS 'Penguin owned furniture';
+
+COMMENT ON COLUMN penguin_furniture."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN penguin_furniture."FurnitureID" IS 'Furniture item ID';
+COMMENT ON COLUMN penguin_furniture."Quantity" IS 'Quantity owned';
+
+DROP TABLE IF EXISTS penguin_igloo;
+CREATE TABLE penguin_igloo (
+ "ID" SERIAL,
+ "PenguinID" INT NOT NULL,
+ "Type" SMALLINT NOT NULL,
+ "Flooring" SMALLINT NOT NULL DEFAULT 0,
+ "Music" SMALLINT NOT NULL DEFAULT 0,
+ "Locked" BOOLEAN NOT NULL DEFAULT FALSE,
+ PRIMARY KEY ("ID"),
+ CONSTRAINT igloo_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT igloo_ibfk_2 FOREIGN KEY ("Type") REFERENCES igloo ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT igloo_ibfk_3 FOREIGN KEY ("Flooring") REFERENCES flooring ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE penguin_igloo IS 'Penguin igloo settings';
+
+COMMENT ON COLUMN penguin_igloo."ID" IS 'Unique igloo ID';
+COMMENT ON COLUMN penguin_igloo."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN penguin_igloo."Type" IS 'Igloo type ID';
+COMMENT ON COLUMN penguin_igloo."Floor" IS 'Igloo flooring ID';
+COMMENT ON COLUMN penguin_igloo."Music" IS 'Igloo music ID';
+COMMENT ON COLUMN penguin_igloo."Locked" IS 'Is igloo locked?';
+
+DROP TABLE IF EXISTS igloo_like;
+CREATE TABLE igloo_like (
+ "IglooID" INT NOT NULL,
+ "OwnerID" INT NOT NULL,
+ "PlayerID" INT NOT NULL,
+ "Count" SMALLiNT NOT NULL,
+ "Date" DATE NOT NULL,
+ PRIMARY KEY ("IglooID", "OwnerID", "PlayerID"),
+ CONSTRAINT igloo_like_ibfk_1 FOREIGN KEY ("IglooID") REFERENCES penguin_igloo ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT igloo_like_ibfk_2 FOREIGN KEY ("OwnerID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT igloo_like_ibfk_3 FOREIGN KEY ("PlayerID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+ALTER TABLE igloo_like ALTER COLUMN "Date" SET DEFAULT now();
+
+COMMENT ON TABLE igloo_like IS 'Player igloo likes';
+
+COMMENT ON COLUMN igloo_like."IglooID" IS 'Igloo unique ID';
+COMMENT ON COLUMN igloo_like."OwnerID" IS 'Owner unique ID';
+COMMENT ON COLUMN igloo_like."PlayerID" IS 'Liker unique ID';
+COMMENT ON COLUMN igloo_like."Count" IS 'Number of likes';
+COMMENT ON COLUMN igloo_like."Date" IS 'Date of like';
+
+
+DROP TABLE IF EXISTS penguin_location;
+CREATE TABLE penguin_location (
+ "PenguinID" INT NOT NULL,
+ "LocationID" SMALLINT NOT NULL,
+ PRIMARY KEY ("PenguinID", "LocationID"),
+ CONSTRAINT penguin_location_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_location_ibfk_2 FOREIGN KEY ("LocationID") REFERENCES location ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE penguin_location IS 'Penguin owned locations';
+
+COMMENT ON COLUMN penguin_location."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN penguin_location."LocationID" IS 'Location ID';
+
+DROP TABLE IF EXISTS igloo_furniture;
+CREATE TABLE igloo_furniture (
+ "IglooID" INT NOT NULL,
+ "FurnitureID" SMALLINT NOT NULL,
+ "X" SMALLINT NOT NULL DEFAULT 0,
+ "Y" SMALLINT NOT NULL DEFAULT 0,
+ "Frame" SMALLINT NOT NULL DEFAULT 0,
+ "Rotation" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("IglooID", "FurnitureID", "X", "Y", "Frame", "Rotation"),
+ CONSTRAINT igloo_furniture_ibfk_1 FOREIGN KEY ("IglooID") REFERENCES penguin_igloo ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT igloo_furniture_ibfk_2 FOREIGN KEY ("FurnitureID") REFERENCES furniture ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX IglooID ON igloo_furniture("IglooID");
+
+COMMENT ON TABLE igloo_furniture IS 'Furniture placed inside igloos';
+
+COMMENT ON COLUMN igloo_furniture."IglooID" IS 'Furniture igloo ID';
+COMMENT ON COLUMN igloo_furniture."FurnitureID" IS 'Furniture item ID';
+COMMENT ON COLUMN igloo_furniture."X" IS 'Igloo X position';
+COMMENT ON COLUMN igloo_furniture."Y" IS 'Igloo Y position';
+COMMENT ON COLUMN igloo_furniture."Frame" IS 'Furniture frame ID';
+COMMENT ON COLUMN igloo_furniture."Rotation" IS 'Furniture rotation ID';
+
+DROP TABLE IF EXISTS igloo_inventory;
+CREATE TABLE igloo_inventory (
+ "PenguinID" INT NOT NULL DEFAULT 0,
+ "IglooID" SMALLINT NOT NULL,
+ PRIMARY KEY ("PenguinID", "IglooID"),
+ CONSTRAINT igloo_inventory_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT igloo_inventory_ibfk_2 FOREIGN KEY ("IglooID") REFERENCES igloo ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE igloo_inventory IS 'Penguin owned igloos';
+
+COMMENT ON COLUMN igloo_inventory."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN igloo_inventory."IglooID" IS 'Igloo ID';
+
+DROP TABLE IF EXISTS ignore_list;
+CREATE TABLE ignore_list (
+ "PenguinID" INT NOT NULL,
+ "IgnoreID" INT NOT NULL,
+ PRIMARY KEY ("PenguinID", "IgnoreID"),
+ CONSTRAINT ignore_list_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT ignore_list_ibfk_2 FOREIGN KEY ("IgnoreID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX IgnoreID ON ignore_list("IgnoreID");
+
+COMMENT ON TABLE ignore_list IS 'Penguin ignore relationships';
+
+DROP TABLE IF EXISTS penguin_item;
+CREATE TABLE penguin_item (
+ "PenguinID" INT NOT NULL,
+ "ItemID" SMALLINT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("PenguinID", "ItemID"),
+ CONSTRAINT penguin_item_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE penguin_item IS 'Penguin owned clothing items';
+
+COMMENT ON COLUMN penguin_item."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN penguin_item."ItemID" IS 'Clothing item ID';
+
+DROP TABLE IF EXISTS login;
+CREATE TABLE login (
+ "ID" SERIAL,
+ "PenguinID" INT NOT NULL,
+ "Date" TIMESTAMP NOT NULL,
+ "IPAddress" char(255) NOT NULL,
+ PRIMARY KEY ("ID"),
+ CONSTRAINT login_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+ALTER TABLE login ALTER COLUMN "Date" SET DEFAULT now();
+
+COMMENT ON TABLE login IS 'Penguin login records';
+
+COMMENT ON COLUMN login."ID" IS 'Unique login ID';
+COMMENT ON COLUMN login."PenguinID" IS 'Login penguin ID';
+COMMENT ON COLUMN login."Date" IS 'Login date';
+COMMENT ON COLUMN login."IPAddress" IS 'Connection IP address';
+
+DROP TABLE IF EXISTS postcard;
+CREATE TABLE postcard (
+ "ID" SERIAL,
+ "SenderID" INT DEFAULT NULL,
+ "RecipientID" INT NOT NULL,
+ "Type" SMALLINT NOT NULL,
+ "SendDate" TIMESTAMP NOT NULL,
+ "Details" char(255) NOT NULL DEFAULT '',
+ "HasRead" BOOLEAN NOT NULL DEFAULT FALSE,
+ PRIMARY KEY ("ID"),
+ CONSTRAINT postcard_ibfk_1 FOREIGN KEY ("SenderID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT postcard_ibfk_2 FOREIGN KEY ("RecipientID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+ALTER TABLE postcard ALTER COLUMN "SendDate" SET DEFAULT now();
+
+CREATE INDEX "SenderID" ON postcard("SenderID");
+CREATE INDEX "RecipientID" ON postcard("RecipientID");
+
+COMMENT ON TABLE postcard IS 'Sent postcards';
+
+COMMENT ON COLUMN postcard."ID" IS 'Unique postcard ID';
+COMMENT ON COLUMN postcard."SenderID" IS 'Sender penguin ID';
+COMMENT ON COLUMN postcard."RecipientID" IS 'Postcard type ID';
+COMMENT ON COLUMN postcard."Type" IS 'Postcard type ID';
+COMMENT ON COLUMN postcard."SendDate" IS 'Postcard type ID';
+COMMENT ON COLUMN postcard."Details" IS 'Postcard details';
+COMMENT ON COLUMN postcard."HasRead" IS 'Is read?';
+
+DROP TABLE IF EXISTS penguin_puffle;
+CREATE TABLE penguin_puffle (
+ "ID" SERIAL,
+ "PenguinID" INT NOT NULL,
+ "Name" varchar(16) NOT NULL,
+ "AdoptionDate" TIMESTAMP NOT NULL,
+ "Type" SMALLINT NOT NULL,
+ "Food" SMALLINT NOT NULL DEFAULT 100,
+ "Play" SMALLINT NOT NULL DEFAULT 100,
+ "Rest" SMALLINT NOT NULL DEFAULT 100,
+ "Clean" SMALLINT NOT NULL DEFAULT 100,
+ "Walking" BOOLEAN DEFAULT FALSE,
+ "Hat" SMALLINT NOT NULL,
+ "Backyard" BOOLEAN DEFAULT FALSE,
+ "HasDug" BOOLEAN DEFAULT FALSE,
+ PRIMARY KEY ("ID"),
+ CONSTRAINT penguin_puffle_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_puffle_ibfk_2 FOREIGN KEY ("Type") REFERENCES puffle ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_puffle_ibfk_3 FOREIGN KEY ("Hat") REFERENCES puffle_care_item ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+ALTER TABLE penguin_puffle ALTER COLUMN "AdoptionDate" SET DEFAULT now();
+
+COMMENT ON TABLE penguin_puffle IS 'Adopted puffles';
+
+COMMENT ON COLUMN penguin_puffle."ID" IS 'Unique puffle ID';
+COMMENT ON COLUMN penguin_puffle."PenguinID" IS 'Owner penguin ID';
+COMMENT ON COLUMN penguin_puffle."Name" IS 'Puffle name';
+COMMENT ON COLUMN penguin_puffle."AdoptionDate" IS 'Date of adoption';
+COMMENT ON COLUMN penguin_puffle."Type" IS 'Puffle type ID';
+COMMENT ON COLUMN penguin_puffle."Food" IS 'Puffle health %';
+COMMENT ON COLUMN penguin_puffle."Play" IS 'Puffle hunger %';
+COMMENT ON COLUMN penguin_puffle."Rest" IS 'Puffle rest %';
+COMMENT ON COLUMN penguin_puffle."Clean" IS 'Puffle clean %';
+COMMENT ON COLUMN penguin_puffle."Walking" IS 'Is being walked?';
+COMMENT ON COLUMN penguin_puffle."Hat" IS 'Puffle hat item ID';
+COMMENT ON COLUMN penguin_puffle."Backyard" IS 'Is in backyard?';
+COMMENT ON COLUMN penguin_puffle."HasDug" IS 'Has dug?';
+
+DROP TABLE IF EXISTS puffle_quest;
+CREATE TABLE puffle_quest (
+ "PenguinID" SMALLINT NOT NULL,
+ "TaskID" SMALLINT NOT NULL,
+ "CompletionDate" TIMESTAMP DEFAULT NULL,
+ "ItemCollected" BOOLEAN NOT NULL DEFAULT FALSE,
+ "CoinsCollected" BOOLEAN NOT NULL DEFAULT FALSE,
+ PRIMARY KEY ("PenguinID", "TaskID"),
+ CONSTRAINT puffle_quest_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE puffle_quest IS 'Puffle quest progress';
+
+COMMENT ON COLUMN puffle_quest."PenguinID" IS 'Quest penguin ID';
+COMMENT ON COLUMN puffle_quest."TaskID" IS 'Quest task ID';
+COMMENT ON COLUMN puffle_quest."CompletionDate" IS 'Time of completion';
+COMMENT ON COLUMN puffle_quest."ItemCollected" IS 'Item collection status';
+COMMENT ON COLUMN puffle_quest."CoinsCollected" IS 'Coins collection status';
+
+CREATE TYPE redemption_type AS ENUM ('DS','BLANKET','CARD','GOLDEN','CAMPAIGN');
+
+DROP TABLE IF EXISTS redemption_code;
+CREATE TABLE redemption_code (
+ "ID" SERIAL,
+ "Code" varchar(16) NOT NULL,
+ "Type" redemption_type NOT NULL DEFAULT 'BLANKET',
+ "Coins" INT NOT NULL DEFAULT 0,
+ "Expires" TIMESTAMP DEFAULT NULL,
+ PRIMARY KEY ("ID")
+);
+
+COMMENT ON TABLE redemption_code IS 'Redemption codes';
+
+COMMENT ON COLUMN redemption_code."ID" IS 'Unique code ID';
+COMMENT ON COLUMN redemption_code."Code" IS 'Redemption code';
+COMMENT ON COLUMN redemption_code."Type" IS 'Code type';
+COMMENT ON COLUMN redemption_code."Coins" IS 'Code coins amount';
+COMMENT ON COLUMN redemption_code."Expires" IS 'Expiry date';
+
+DROP TABLE IF EXISTS penguin_redemption;
+CREATE TABLE penguin_redemption (
+ "PenguinID" INT NOT NULL DEFAULT 0,
+ "CodeID" INT NOT NULL DEFAULT 0,
+ PRIMARY KEY ("PenguinID", "CodeID"),
+ CONSTRAINT penguin_redemption_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT penguin_redemption_ibfk_2 FOREIGN KEY ("CodeID") REFERENCES redemption_code ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX "CodeID" ON penguin_redemption("CodeID");
+
+COMMENT ON TABLE penguin_redemption IS 'Redeemed codes';
+
+COMMENT ON COLUMN penguin_redemption."PenguinID" IS 'Unique penguin ID';
+COMMENT ON COLUMN penguin_redemption."CodeID" IS 'Unique code ID';
+
+
+DROP TABLE IF EXISTS redemption_award;
+CREATE TABLE redemption_award (
+ "CodeID" INT NOT NULL DEFAULT 0,
+ "CardID" SMALLINT DEFAULT NULL,
+ "ItemID" SMALLINT DEFAULT NULL,
+ PRIMARY KEY ("CodeID", "CardID", "ItemID"),
+ CONSTRAINT redemption_award_ibfk_1 FOREIGN KEY ("CodeID") REFERENCES redemption_code ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT redemption_award_ibfk_2 FOREIGN KEY ("CardID") REFERENCES card ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT redemption_award_ibfk_3 FOREIGN KEY ("ItemID") REFERENCES item ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE redemption_award IS 'Redemption code awards';
+
+COMMENT ON COLUMN redemption_award."CodeID" IS 'Unique code ID';
+COMMENT ON COLUMN redemption_award."CardID" IS 'Code card ID';
+COMMENT ON COLUMN redemption_award."ItemID" IS 'Code item ID';
+
+DROP TABLE IF EXISTS penguin_stamp;
+CREATE TABLE penguin_stamp (
+ "PenguinID" INT NOT NULL,
+ "StampID" SMALLINT NOT NULL,
+ "Recent" BOOLEAN NOT NULL DEFAULT TRUE,
+ PRIMARY KEY ("PenguinID", "StampID"),
+ CONSTRAINT stamp_ibfk_1 FOREIGN KEY ("PenguinID") REFERENCES penguin ("ID") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT stamp_ibfk_2 FOREIGN KEY ("StampID") REFERENCES stamp ("ID") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+COMMENT ON TABLE penguin_stamp IS 'Penguin earned stamps';
+
+COMMENT ON COLUMN penguin_stamp."PenguinID" IS 'Stamp penguin ID';
+COMMENT ON COLUMN penguin_stamp."StampID" IS 'Stamp ID';
+COMMENT ON COLUMN penguin_stamp."Recent" IS 'Is recently earned?';
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f92b62b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,11 @@
+asyncio
+aioredis
+gino
+aiologger==0.4.0rc1
+aiologger[aiofiles]
+aiocache
+ujson
+watchdog
+defusedxml
+zope.interface
+uvloop; sys_platform == 'linux2' or sys_platform == 'darwin'
\ No newline at end of file