├── frangiclave ├── __init__.py ├── bot │ ├── __init__.py │ ├── templates │ │ ├── __init__.py │ │ ├── recipe.py │ │ ├── verb.py │ │ ├── ending.py │ │ ├── legacy.py │ │ ├── deck.py │ │ ├── base.py │ │ ├── element.py │ │ └── search_results.py │ └── client.py ├── game │ ├── __init__.py │ └── importer.py ├── compendium │ ├── __init__.py │ ├── ending_flavour.py │ ├── base.py │ ├── file.py │ ├── utils.py │ ├── ending.py │ ├── linked_recipe_details.py │ ├── verb.py │ ├── game_content.py │ ├── slot_specification.py │ ├── legacy.py │ ├── deck.py │ ├── element.py │ └── recipe.py ├── static │ ├── img │ │ ├── felt.png │ │ └── knock.png │ ├── frangiclave.js │ └── style.css ├── config.py ├── templates │ ├── 404.tpl.html │ ├── search.tpl.html │ ├── verb.tpl.html │ ├── ending.tpl.html │ ├── index.tpl.html │ ├── deck.tpl.html │ ├── legacy.tpl.html │ ├── base.tpl.html │ ├── recipe.tpl.html │ ├── element.tpl.html │ └── macros.tpl.html ├── main_bot.py ├── main.py ├── search.py ├── csjson.py ├── server.py └── exporter.py ├── _version.py ├── patch ├── CultistSimulator │ ├── .gitignore │ └── README.txt ├── MonoMod │ ├── .gitignore │ └── README.txt ├── .gitignore ├── PatchCultistSimulator.cmd ├── CultistSimulator.Modding.sln ├── CultistSimulator.Modding.sln.DotSettings.user └── CultistSimulator.Modding.mm │ ├── Properties │ └── AssemblyInfo.cs │ ├── Texture2DExtensions.cs │ ├── ResourcesManager.cs │ ├── SplashAnimation.cs │ └── CultistSimulator.Modding.mm.csproj ├── MANIFEST.in ├── config.default.toml ├── README.md ├── Makefile ├── setup.py ├── .gitignore └── LICENSE.md /frangiclave/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frangiclave/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frangiclave/game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.0' 2 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frangiclave/compendium/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /patch/CultistSimulator/.gitignore: -------------------------------------------------------------------------------- 1 | *.dll 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE.md 2 | -------------------------------------------------------------------------------- /patch/MonoMod/.gitignore: -------------------------------------------------------------------------------- 1 | *.dll 2 | *.exe 3 | -------------------------------------------------------------------------------- /patch/MonoMod/README.txt: -------------------------------------------------------------------------------- 1 | Place all MonoMod files in this directory. 2 | -------------------------------------------------------------------------------- /patch/.gitignore: -------------------------------------------------------------------------------- 1 | CultistSimulator.Modding.mm/bin 2 | CultistSimulator.Modding.mm/obj 3 | .vs/ 4 | -------------------------------------------------------------------------------- /patch/CultistSimulator/README.txt: -------------------------------------------------------------------------------- 1 | Place all DLL files from Cultist Simulator in this directory. 2 | -------------------------------------------------------------------------------- /frangiclave/static/img/felt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frangiclave/frangiclave-compendium/HEAD/frangiclave/static/img/felt.png -------------------------------------------------------------------------------- /frangiclave/static/img/knock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frangiclave/frangiclave-compendium/HEAD/frangiclave/static/img/knock.png -------------------------------------------------------------------------------- /frangiclave/config.py: -------------------------------------------------------------------------------- 1 | import toml 2 | 3 | DEFAULT_CONFIG_FILE_NAME = 'config.toml' 4 | 5 | 6 | def load_config(config_file_name): 7 | with open(config_file_name) as config_file: 8 | return toml.load(config_file) 9 | -------------------------------------------------------------------------------- /frangiclave/compendium/ending_flavour.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EndingFlavour(Enum): 5 | NONE = 'None' 6 | GRAND = 'Grand' 7 | MELANCHOLY = 'Melancholy' 8 | PALE = 'Pale' 9 | VILE = 'Vile' 10 | XXX = 'XXX' 11 | -------------------------------------------------------------------------------- /frangiclave/templates/404.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% block title %}Page Not Found{% endblock %} 3 | {% block description %}Nothing? Nothing.{% endblock %} 4 | {% block content %} 5 |

6 | Nothing? 7 |

8 |

Nothing.

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /config.default.toml: -------------------------------------------------------------------------------- 1 | [frangiclave] 2 | 3 | BASE_URL = "http://localhost:5000" 4 | 5 | DEBUG = false 6 | 7 | GAME_DIRECTORY = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Cultist Simulator\\" 8 | 9 | IMAGES_DIRECTORY = "images" 10 | 11 | READ_ONLY = false 12 | 13 | SLACK_BOT_TOKEN = "" 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | frangiclave-compendium 2 | ====================== 3 | 4 | *A web-based tool for viewing Cultist Simulator data.* 5 | 6 | **frangiclave-compendium** is a Python web application for browsing game data from Cultist Simulator in a convenient web interface. 7 | 8 | License: ![CC0](https://licensebuttons.net/p/zero/1.0/88x15.png "CC0") 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: run 2 | 3 | run: 4 | #uwsgi --http-socket 127.0.0.1:5777 --master --processes 4 --enable-threads --virtualenv=./venv/ --mount /=frangiclave.main:init --manage-script-name 5 | uwsgi -s /tmp/frangiclave.sock --master --processes 4 --enable-threads --virtualenv=./venv/ --manage-script-name --mount /frangiclave=frangiclave.main:app --chmod-socket=777 6 | -------------------------------------------------------------------------------- /frangiclave/static/frangiclave.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('.section-title').click(function () { 3 | $(this).next('.section-list').slideToggle(); 4 | }); 5 | 6 | $('#action-load').click(function () { 7 | $.get('/load/'); 8 | }); 9 | 10 | var activeSectionItem = document.getElementById('section-item-active'); 11 | if (activeSectionItem != null) { 12 | activeSectionItem.scrollIntoView(); 13 | window.scrollTo(0, 0); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/recipe.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.templates.base import URL_FORMAT, make_section, DIVIDER 2 | from frangiclave.compendium.recipe import Recipe 3 | 4 | 5 | def make_recipe(recipe: Recipe): 6 | return [ 7 | make_section('*Recipe: {}*'.format(URL_FORMAT.format('recipe', recipe.recipe_id))), 8 | DIVIDER, 9 | make_section( 10 | f'*_Label:_* {recipe.label}\n' 11 | f'*_Start Description:_* {recipe.start_description}\n' 12 | f'*_Description:_* {recipe.description}' 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /frangiclave/main_bot.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.client import BotClient 2 | from frangiclave.config import DEFAULT_CONFIG_FILE_NAME, load_config 3 | 4 | 5 | def main(): 6 | # Load the configuration 7 | print('Loading configuration...') 8 | config = load_config(DEFAULT_CONFIG_FILE_NAME) 9 | 10 | # Set up the bot and keep listening for events forever 11 | print('Initializing bot client...') 12 | client = BotClient(config['frangiclave']['SLACK_BOT_TOKEN']) 13 | print('Bot client ready. Listening...') 14 | client.run() 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /patch/PatchCultistSimulator.cmd: -------------------------------------------------------------------------------- 1 | @set CS_DIR=C:\Program Files (x86)\Steam\steamapps\common\Cultist Simulator\cultistsimulator_Data\Managed\ 2 | 3 | @set OLD_CD=%CD% 4 | @set ASSEMBLY=Assembly-CSharp.dll 5 | @set ASSEMBLY_MOD=MONOMODDED_%ASSEMBLY% 6 | @set MONOMOD_DIR=MonoMod 7 | @set PATCH_DIR=%~dp0 8 | @set BUILD_DIR=%PATCH_DIR%\CultistSimulator.Modding.mm\bin\Release 9 | 10 | @copy /y "%PATCH_DIR%\%MONOMOD_DIR%\*" "%BUILD_DIR%" 11 | @cd %BUILD_DIR% 12 | @MonoMod "%ASSEMBLY%" 13 | 14 | @copy /y "%ASSEMBLY_MOD%" "%CS_DIR%\%ASSEMBLY%" 15 | @copy /y "%PATCH_DIR%\%MONOMOD_DIR%\*" "%CS_DIR%" 16 | 17 | @cd %OLD_CD% 18 | -------------------------------------------------------------------------------- /frangiclave/compendium/base.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from sqlalchemy import create_engine, Column, Integer 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | engine = create_engine('sqlite:///frangiclave.db') 8 | Session = sessionmaker(bind=engine) 9 | 10 | Base = declarative_base(engine) 11 | 12 | 13 | @contextmanager 14 | def get_session(): 15 | """Provide a transactional scope around a series of operations.""" 16 | session = Session() 17 | try: 18 | yield session 19 | session.commit() 20 | except: 21 | session.rollback() 22 | raise 23 | finally: 24 | session.close() 25 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/verb.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.templates.base import make_section, DIVIDER, IMAGE_FORMAT, \ 2 | URL_FORMAT, get_image_if_reachable 3 | from frangiclave.compendium.verb import Verb 4 | 5 | 6 | def make_verb(verb: Verb): 7 | image = get_image_if_reachable(get_verb_art(verb)) 8 | image_alt = None 9 | if image is not None: 10 | image_alt = verb.verb_id 11 | return [ 12 | make_section('*verb: {}*'.format(URL_FORMAT.format('verb', verb.verb_id))), 13 | DIVIDER, 14 | make_section( 15 | f'*_Label:_* {verb.label}\n' 16 | f'*_Description:_* {verb.description}', 17 | image, 18 | image_alt 19 | ) 20 | ] 21 | 22 | 23 | def get_verb_art(verb: Verb): 24 | return IMAGE_FORMAT.format('icons100/verbs', verb.verb_id) 25 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/ending.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.templates.base import make_section, DIVIDER, \ 2 | IMAGE_FORMAT, URL_FORMAT, get_image_if_reachable 3 | from frangiclave.compendium.ending import Ending 4 | 5 | 6 | def make_ending(ending: Ending): 7 | image = get_image_if_reachable(get_ending_art(ending)) 8 | image_alt = None 9 | if image is not None: 10 | image_alt = ending.ending_id 11 | return [ 12 | make_section('*ending: {}*'.format(URL_FORMAT.format('ending', ending.ending_id))), 13 | DIVIDER, 14 | make_section( 15 | f'*_Title:_* {ending.title}\n' 16 | f'*_Description:_* {ending.description}', 17 | image, 18 | image_alt 19 | ) 20 | ] 21 | 22 | 23 | def get_ending_art(ending: Ending): 24 | return IMAGE_FORMAT.format('endingArt', ending.image) 25 | -------------------------------------------------------------------------------- /frangiclave/main.py: -------------------------------------------------------------------------------- 1 | from frangiclave.compendium.base import Base 2 | from frangiclave.config import DEFAULT_CONFIG_FILE_NAME, load_config 3 | from frangiclave.server import app 4 | 5 | 6 | def init_db(): 7 | # Load all the models 8 | # noinspection PyUnresolvedReferences 9 | from frangiclave.compendium import deck, element, file, legacy, recipe, verb 10 | 11 | # Create the database tables 12 | Base.metadata.create_all() 13 | 14 | 15 | def init(): 16 | # Load the configuration 17 | config = load_config(DEFAULT_CONFIG_FILE_NAME) 18 | 19 | # Initialize the database connection 20 | init_db() 21 | 22 | # Set the global variables 23 | app.config.update(config['frangiclave']) 24 | 25 | return app 26 | 27 | 28 | def main(): 29 | # Run the app 30 | app.run() 31 | 32 | 33 | init() 34 | 35 | if __name__ == '__main__': 36 | main() 37 | -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultistSimulator.Modding.mm", "CultistSimulator.Modding.mm\CultistSimulator.Modding.mm.csproj", "{E77B3CEF-3B80-4748-A00A-A92659BB281D}" 4 | EndProject 5 | Global 6 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 7 | Debug|Any CPU = Debug|Any CPU 8 | Release|Any CPU = Release|Any CPU 9 | EndGlobalSection 10 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 11 | {E77B3CEF-3B80-4748-A00A-A92659BB281D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 12 | {E77B3CEF-3B80-4748-A00A-A92659BB281D}.Debug|Any CPU.Build.0 = Debug|Any CPU 13 | {E77B3CEF-3B80-4748-A00A-A92659BB281D}.Release|Any CPU.ActiveCfg = Release|Any CPU 14 | {E77B3CEF-3B80-4748-A00A-A92659BB281D}.Release|Any CPU.Build.0 = Release|Any CPU 15 | EndGlobalSection 16 | EndGlobal 17 | -------------------------------------------------------------------------------- /frangiclave/compendium/file.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from sqlalchemy import Column, Integer, String, Enum as EnumType 4 | 5 | from frangiclave.compendium.base import Base 6 | 7 | 8 | class FileCategory(Enum): 9 | DECKS = 'decks' 10 | ELEMENTS = 'elements' 11 | ENDINGS = 'endings' 12 | LEGACIES = 'legacies' 13 | RECIPES = 'recipes' 14 | VERBS = 'verbs' 15 | 16 | 17 | class FileGroup(Enum): 18 | CORE = 'core' 19 | MORE = 'more' 20 | 21 | 22 | class File(Base): 23 | __tablename__ = 'files' 24 | 25 | id = Column(Integer, primary_key=True) 26 | category = Column(EnumType(FileCategory, name='file_category')) 27 | group = Column(EnumType(FileGroup, name='file_group')) 28 | name = Column(String) 29 | 30 | def __init__(self, category: FileCategory, group: FileGroup, name: str): 31 | self.category = category 32 | self.group = group 33 | self.name = name 34 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/legacy.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.templates.base import make_section, DIVIDER, IMAGE_FORMAT, \ 2 | URL_FORMAT, get_image_if_reachable 3 | from frangiclave.compendium.legacy import Legacy 4 | 5 | 6 | def make_legacy(legacy: Legacy): 7 | image = get_image_if_reachable(get_legacy_art(legacy)) 8 | image_alt = None 9 | if image is not None: 10 | image_alt = legacy.legacy_id 11 | return [ 12 | make_section('*Legacy: {}*'.format(URL_FORMAT.format('legacy', legacy.legacy_id))), 13 | DIVIDER, 14 | make_section( 15 | f'*_Label:_* {legacy.label}\n' 16 | f'*_Description:_* {legacy.description}\n' 17 | f'*_Start Description:_* {legacy.start_description}', 18 | image, 19 | image_alt 20 | ) 21 | ] 22 | 23 | 24 | def get_legacy_art(legacy: Legacy): 25 | return IMAGE_FORMAT.format('icons100/legacies', legacy.image) 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import io 4 | import os 5 | 6 | from setuptools import find_packages, setup 7 | 8 | from _version import __version__ 9 | 10 | here = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 13 | long_description = '\n' + f.read() 14 | 15 | setup( 16 | name='frangiclave-compendium', 17 | version=__version__, 18 | description='A tool for viewing Cultist Simulator data.', 19 | long_description=long_description, 20 | author='Lyrositor', 21 | python_requires='>=3.6.0', 22 | url='https://github.com/frangiclave/frangiclave-compendium', 23 | packages=find_packages(exclude=('tests',)), 24 | install_requires=[ 25 | 'flask', 26 | 'slackclient', 27 | 'sqlalchemy', 28 | 'toml', 29 | 'jsom==0.0.5' 30 | ], 31 | extras_require={}, 32 | include_package_data=True, 33 | license='CC0' 34 | ) 35 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/deck.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.templates.base import make_section, DIVIDER, URL_FORMAT 2 | from frangiclave.compendium.deck import Deck 3 | 4 | 5 | def make_deck(deck: Deck): 6 | draw_messages = '\n'.join(f'• : {dm.message}' for dm in deck.draw_messages) 7 | cards = '\n'.join(f'• ' for card in deck.cards) 8 | default_card = f'' if deck.default_card else 'None' 9 | return [ 10 | make_section('*Deck: {}*'.format(URL_FORMAT.format('deck', deck.deck_id))), 11 | DIVIDER, 12 | make_section( 13 | f'*_Label:_* {deck.label}\n' 14 | f'*_Description:_* {deck.description}\n' 15 | f'*_Draw Messages:_* \n{draw_messages}\n' 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /frangiclave/compendium/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Callable, Union 2 | 3 | 4 | def to_bool(val: Union[bool, str]) -> bool: 5 | if isinstance(val, bool): 6 | return val 7 | return val == 'true' 8 | 9 | 10 | def to_int_dict(val: Dict[str, str]) -> Dict[str, int]: 11 | return {k: int(v) for k, v in val.items()} 12 | 13 | 14 | def get( 15 | data: Dict[str, Any], 16 | key: str, 17 | default: Any = None, 18 | conversion_function: Callable = None, 19 | translations: Dict[str, Dict[str, Any]] = None 20 | ) -> Any: 21 | if key in data: 22 | if conversion_function: 23 | return conversion_function(data[key]) 24 | string = data[key] 25 | if translations: 26 | for culture, translation in translations.items(): 27 | if key in translation and translation[key]: 28 | string += f'$${translation[key]}' 29 | return string 30 | return default 31 | -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | 2 3 | True 4 | True 5 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 6 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> -------------------------------------------------------------------------------- /frangiclave/bot/templates/base.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | from typing import Optional 3 | 4 | IMAGE_FORMAT = 'https://www.frangiclave.net/static/images/{}/{}.png' 5 | URL_FORMAT = '' 6 | 7 | DIVIDER = { 8 | 'type': 'divider' 9 | } 10 | 11 | 12 | def get_image_if_reachable(url: str): 13 | if url is None: 14 | return None 15 | try: 16 | urllib.request.urlopen(url) 17 | except urllib.error.HTTPError: 18 | return None 19 | return url 20 | 21 | 22 | def make_section( 23 | text: str, 24 | image: Optional[str] = None, 25 | image_alt: Optional[str] = None): 26 | section = { 27 | 'type': 'section', 28 | 'text': { 29 | 'type': 'mrkdwn', 30 | 'text': text 31 | } 32 | } 33 | if image: 34 | section['accessory'] = { 35 | 'type': 'image', 36 | 'image_url': image 37 | } 38 | if image_alt: 39 | section['accessory']['alt_text'] = image_alt 40 | return section 41 | -------------------------------------------------------------------------------- /frangiclave/templates/search.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Search: {{ keywords }}{% endblock %} 4 | {% block description %}{{ results|length }} result(s){% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Search: {{ keywords }} 18 |

19 | 20 | {% if not results %}

No results found.

{% endif %} 21 | {% for result in results %} 22 |
23 |

{{ m.optional_link('/' + result.type + '/', result.id) }}

24 |
    25 | {% for match in result.matches %} 26 |
  • {{ match }}
  • 27 | {% endfor %} 28 |
29 |
30 | {% endfor %} 31 | 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /frangiclave/templates/verb.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Verb: {{ verb.verb_id }}{% endblock %} 4 | {% block description %}{{ verb.description }}{% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Verb: {{ verb.verb_id }} 18 |

19 | 20 | 21 | 22 |

Label: {{ m.localised(verb.label) }}

23 | 24 |

Description: {{ m.localised(verb.description) }}

25 | 26 |

At Start? {{ m.yes_no(verb.at_start) }}

27 | 28 |

Comments: {{ m.multiline(m.optional(verb.comments)) }}

29 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/element.py: -------------------------------------------------------------------------------- 1 | from frangiclave.bot.templates.base import make_section, DIVIDER, IMAGE_FORMAT, \ 2 | URL_FORMAT, get_image_if_reachable 3 | from frangiclave.compendium.element import Element 4 | 5 | 6 | def make_element(element: Element): 7 | image = get_image_if_reachable(get_element_art(element)) 8 | image_alt = None 9 | if image is not None: 10 | image_alt = element.element_id 11 | decay_to = None 12 | if element.decay_to: 13 | decay_to = URL_FORMAT.format('element', element.decay_to.element_id) 14 | return [ 15 | make_section('*Element: {}*'.format(URL_FORMAT.format('element', element.element_id))), 16 | DIVIDER, 17 | make_section( 18 | f'*_Label:_* {element.label}\n' 19 | f'*_Description:_* {element.description}', 20 | image, 21 | image_alt 22 | ) 23 | ] 24 | 25 | 26 | def get_element_art(element: Element): 27 | if element.no_art_needed: 28 | image_id = '_x' 29 | else: 30 | image_id = element.icon if element.icon else element.element_id 31 | return IMAGE_FORMAT.format( 32 | 'icons40/aspects' if element.is_aspect else 'elementArt', 33 | image_id 34 | ) 35 | -------------------------------------------------------------------------------- /frangiclave/templates/ending.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Ending: {{ ending.ending_id }}{% endblock %} 4 | {% block description %}{{ ending.description }}{% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Ending: {{ ending.ending_id }} 18 |

19 | 20 | 21 |

Title: {{ m.localised(ending.title) }}

22 | 23 |

Description: {{ m.localised(ending.description, True) }}

24 | 25 |

Ending Flavour: {{ ending.flavour.value }}

26 | 27 |

Animation: {{ ending.animation }}

28 | 29 |

Achievement: {{ m.optional(ending.achievement) }}

30 | {% endblock %} 31 | 32 | -------------------------------------------------------------------------------- /frangiclave/templates/index.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% block title %}Home{% endblock %} 3 | {% block description %}Welcome, friend, to the Frangiclave Compendium, the premier source of forbidden knowledge for the discerning occultist.{% endblock %} 4 | {% block content %} 5 | 6 |

Welcome, friend, to the Frangiclave Compendium, the premier source of forbidden knowledge for the discerning occultist.

7 | 8 |

The Frangiclave Compendium is an open-source repository for information about the contents of the game Cultist Simulator, as extracted from the game's files. Here you can browse the decks, elements, legacies, recipes and verbs included in the game. All DLC content is also included.

9 | 10 |

Source: frangiclave/frangiclave-compendium 11 | 12 |

Hosted by Lyrositor.

13 | 14 |

If you'd like to donate, feel free to buy me a coffee.

15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.mm/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.InteropServices; 3 | 4 | // General Information about an assembly is controlled through the following 5 | // set of attributes. Change these attribute values to modify the information 6 | // associated with an assembly. 7 | [assembly: AssemblyTitle("CultistSimulator.Modding.mm")] 8 | [assembly: AssemblyDescription("")] 9 | [assembly: AssemblyConfiguration("")] 10 | [assembly: AssemblyCompany("")] 11 | [assembly: AssemblyProduct("CultistSimulator.Modding.mm")] 12 | [assembly: AssemblyCopyright("Copyright © 2018")] 13 | [assembly: AssemblyTrademark("")] 14 | [assembly: AssemblyCulture("")] 15 | 16 | // Setting ComVisible to false makes the types in this assembly not visible 17 | // to COM components. If you need to access a type in this assembly from 18 | // COM, set the ComVisible attribute to true on that type. 19 | [assembly: ComVisible(false)] 20 | 21 | // The following GUID is for the ID of the typelib if this project is exposed to COM 22 | [assembly: Guid("E77B3CEF-3B80-4748-A00A-A92659BB281D")] 23 | 24 | // Version information for an assembly consists of the following four values: 25 | // 26 | // Major Version 27 | // Minor Version 28 | // Build Number 29 | // Revision 30 | // 31 | // You can specify all the values or you can default the Build and Revision Numbers 32 | // by using the '*' as shown below: 33 | // [assembly: AssemblyVersion("1.0.*")] 34 | [assembly: AssemblyVersion("1.0.0.0")] 35 | [assembly: AssemblyFileVersion("1.0.0.0")] -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.mm/Texture2DExtensions.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | namespace CultistSimulator.Modding.mm 4 | { 5 | /** 6 | * Source: https://answers.unity.com/questions/683772/export-sprite-sheets.html 7 | */ 8 | static class Texture2DExtensions 9 | { 10 | public static Texture2D CropTexture(this Texture2D source, int left, int top, int width, int height) 11 | { 12 | if (left < 0) { 13 | width += left; 14 | left = 0; 15 | } 16 | if (top < 0) { 17 | height += top; 18 | top = 0; 19 | } 20 | if (left + width > source.width) { 21 | width = source.width - left; 22 | } 23 | if (top + height > source.height) { 24 | height = source.height - top; 25 | } 26 | 27 | if (width <= 0 || height <= 0) { 28 | return null; 29 | } 30 | 31 | Color[] sourceColor = source.GetPixels(0); 32 | Texture2D croppedTexture = new Texture2D(width, height, TextureFormat.RGBA32, false); 33 | 34 | int area = width * height; 35 | Color[] pixels = new Color[area]; 36 | 37 | int i = 0; 38 | for (int y = 0; y < height; y++) { 39 | int sourceIndex = (y + top) * source.width + left; 40 | for (int x = 0; x < width; x++) { 41 | pixels[i++] = sourceColor[sourceIndex++]; 42 | } 43 | } 44 | 45 | croppedTexture.SetPixels(pixels); 46 | croppedTexture.Apply(); 47 | 48 | return croppedTexture; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /frangiclave/templates/deck.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Deck: {{ deck.deck_id }}{% endblock %} 4 | {% block description %}{{ deck.description }}{% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Deck: {{ deck.deck_id }} 18 |

19 | 20 |

Label: {{ m.localised(deck.label) }}

21 | 22 |

Description: {{ m.localised(deck.description, True) }}

23 | 24 |

Draw Messages: {% if not deck.draw_messages %}None{% endif %}

25 |
    26 | {% for dm in deck.draw_messages %}
  • {{ m.element(dm.element.element_id)}}: {{ m.localised(dm.message, True) }}
  • {% endfor %} 27 |
28 | 29 |

Default Draw Messages: {% if not deck.default_draw_messages %}None{% endif %}

30 |
    31 | {% for dm in deck.default_draw_messages %}
  • {{ m.element(dm.element.element_id)}}: {{ m.localised(dm.message, True) }}
  • {% endfor %} 32 |
33 | 34 |

Cards:

35 |
    36 | {% for card in deck.cards %}
  • {{ m.element(card.element_id) }}
  • {% endfor %} 37 |
38 | 39 |

Default Card: {{ m.element(deck.default_card.element_id) }}

40 | 41 |

Reset on Exhaustion? {{ m.yes_no(deck.reset_on_exhaustion) }}

42 | 43 |

Comments: {{ m.multiline(m.optional(deck.comments)) }}

44 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # PyCharm 108 | .idea/ 109 | 110 | # Frangiclave 111 | config.toml 112 | frangiclave.db 113 | frangiclave/static/images 114 | -------------------------------------------------------------------------------- /frangiclave/templates/legacy.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Legacy: {{ legacy.legacy_id }}{% endblock %} 4 | {% block description %}{{ legacy.description }}{% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Legacy: {{ legacy.legacy_id }} 18 |

19 | 20 | 21 | 22 |

Label: {{ m.localised(legacy.label) }}

23 | 24 |

Start Description: {{ m.localised(legacy.start_description, True) }}

25 | 26 |

Description: {{ m.localised(legacy.description, True) }}

27 | 28 |

From Ending: {{ legacy.from_ending }}

29 | 30 |

Excludes on Ending: {% if not legacy.excludes_on_ending %}None{% endif %}

31 |
    {% for e in legacy.excludes_on_ending %}
  • {{ m.legacy(e.exclude.legacy_id) }}
  • {% endfor %}
32 | 33 |

Available Without Ending Match? {{ m.yes_no(legacy.available_without_ending_match) }}

34 | 35 |

Starting Verb ID: {{ m.optional(legacy.starting_verb_id) }}

36 | 37 |

Legacy Effect: {{ m.element_list(legacy.effects) }}

38 | 39 |

Comments: {{ m.multiline(m.optional(legacy.comments)) }}

40 | 41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /frangiclave/compendium/ending.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, Any 2 | 3 | from sqlalchemy import Column, Enum as EnumType, String, Integer 4 | 5 | from frangiclave.compendium.base import Base, Session 6 | from frangiclave.compendium.ending_flavour import EndingFlavour 7 | from frangiclave.compendium.file import File 8 | from frangiclave.compendium.game_content import GameContents 9 | from frangiclave.compendium.utils import get 10 | 11 | 12 | class Ending(Base): 13 | __tablename__ = 'endings' 14 | 15 | id = Column(Integer, primary_key=True) 16 | 17 | ending_id: int = Column(String, unique=True) 18 | title: Optional[str] = Column(String, nullable=True) 19 | description: str = Column(String) 20 | image: str = Column(String) 21 | flavour: EndingFlavour = Column(EnumType(EndingFlavour, name='flavour')) 22 | animation: str = Column(String) 23 | achievement: Optional[str] = Column(String, nullable=True) 24 | 25 | @classmethod 26 | def from_data( 27 | cls, 28 | file: File, 29 | data: Dict[str, Any], 30 | translations: Dict[str, Dict[str, Any]], 31 | game_contents: GameContents 32 | ) -> 'Ending': 33 | e = game_contents.get_ending(get(data, 'id')) 34 | e.file = file 35 | e.title = get(data, 'label', translations=translations) 36 | e.description = get(data, 'description', translations=translations) 37 | e.image = get(data, 'image') 38 | flavour = get(data, 'flavour', 'None') 39 | e.flavour = EndingFlavour( 40 | flavour[0].upper() + flavour[1:] # Workaround for a broken ending 41 | ) 42 | e.animation = get(data, 'anim') 43 | e.achievement = get(data, 'achievement') 44 | return e 45 | 46 | @classmethod 47 | def get_by_ending_id(cls, session: Session, ending_id: str) -> 'Ending': 48 | return session.query(cls).filter(cls.ending_id == ending_id).one() 49 | -------------------------------------------------------------------------------- /frangiclave/bot/templates/search_results.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | from typing import Any, Dict, List 3 | 4 | from frangiclave.bot.templates.base import make_section, DIVIDER, IMAGE_FORMAT, \ 5 | URL_FORMAT, get_image_if_reachable 6 | from frangiclave.bot.templates.element import get_element_art 7 | from frangiclave.bot.templates.ending import get_ending_art 8 | from frangiclave.bot.templates.legacy import get_legacy_art 9 | from frangiclave.bot.templates.verb import get_verb_art 10 | from frangiclave.compendium.base import get_session 11 | from frangiclave.compendium.element import Element 12 | from frangiclave.compendium.ending import Ending 13 | from frangiclave.compendium.legacy import Legacy 14 | from frangiclave.compendium.verb import Verb 15 | 16 | MAX_RESULTS = 5 17 | 18 | 19 | def make_search_results(keywords: str, results: List[Dict[str, Any]]): 20 | blocks = [ 21 | _make_header(len(results), keywords), 22 | ] 23 | if results: 24 | blocks.append(DIVIDER) 25 | for result in results[:MAX_RESULTS]: 26 | blocks += [_make_result(result), DIVIDER] 27 | return blocks 28 | 29 | 30 | def _make_header(num_results: int, keywords: str): 31 | text = 'Found *{} results* for *{}*.'.format(num_results, keywords) 32 | if num_results > MAX_RESULTS: 33 | text += ' Only showing first {} results.'.format(MAX_RESULTS) 34 | return make_section(text) 35 | 36 | 37 | def _make_result(result: Dict[str, Any]): 38 | text = "*{}: {}*\n{}".format( 39 | result['type'].capitalize(), 40 | URL_FORMAT.format(result['type'], result['id']), 41 | '\n'.join('• ' + m for m in result['matches']) 42 | ) 43 | image = None 44 | image_alt = None 45 | with get_session() as session: 46 | if result['type'] == 'element': 47 | element = Element.get_by_element_id(session, result['id']) 48 | image = get_element_art(element) 49 | elif result['type'] == 'legacy': 50 | legacy = Legacy.get_by_legacy_id(session, result['id']) 51 | image = get_legacy_art(legacy) 52 | elif result['type'] == 'verb': 53 | verb = Verb.get_by_verb_id(session, result['id']) 54 | image = get_verb_art(verb) 55 | elif result['type'] == 'ending': 56 | ending = Ending.get_by_ending_id(session, result['id']) 57 | image = get_ending_art(ending) 58 | 59 | image = get_image_if_reachable(image) 60 | if image is not None: 61 | image_alt = result['id'] 62 | 63 | return make_section(text, image, image_alt) 64 | -------------------------------------------------------------------------------- /frangiclave/compendium/linked_recipe_details.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, Type, List, Union 2 | 3 | from sqlalchemy import Column, ForeignKey, Integer, Boolean, String 4 | from sqlalchemy.ext.declarative import declared_attr 5 | from sqlalchemy.orm import relationship 6 | 7 | from frangiclave.compendium.game_content import GameContents 8 | from frangiclave.compendium.utils import get, to_bool 9 | 10 | 11 | if TYPE_CHECKING: 12 | from frangiclave.compendium.element import Element 13 | from frangiclave.compendium.recipe import Recipe 14 | 15 | 16 | class LinkedRecipeDetails: 17 | 18 | chance: int = Column(Integer) 19 | additional: bool = Column(Boolean) 20 | 21 | @declared_attr 22 | def recipe_id(self) -> Column: 23 | return Column(Integer, ForeignKey('recipes.id')) 24 | 25 | @declared_attr 26 | def recipe(self) -> 'Recipe': 27 | return relationship('Recipe', foreign_keys=self.recipe_id) 28 | 29 | @classmethod 30 | def from_data( 31 | cls, 32 | data: Dict[str, Any], 33 | challenge_cls: Type['LinkedRecipeChallengeRequirement'], 34 | game_contents: GameContents 35 | ) -> 'LinkedRecipeDetails': 36 | lr = cls() 37 | lr.recipe = game_contents.get_recipe(data['id']) 38 | lr.chance = int(data['chance']) if 'chance' in data else 100 39 | lr.additional = get(data, 'additional', False, to_bool) 40 | lr.challenges = challenge_cls.from_data( 41 | get(data, 'challenges', {}), game_contents 42 | ) 43 | return lr 44 | 45 | 46 | class LinkedRecipeChallengeRequirement: 47 | 48 | id = Column(Integer, primary_key=True) 49 | 50 | @declared_attr 51 | def element_id(self) -> Column: 52 | return Column(Integer, ForeignKey('elements.id')) 53 | 54 | @declared_attr 55 | def element(self) -> 'Element': 56 | return relationship('Element') 57 | 58 | convention = Column(String) 59 | 60 | @classmethod 61 | def from_data( 62 | cls, val: Union[str, Dict[str, str]], game_contents: GameContents 63 | ) -> List['LinkedRecipeChallengeRequirement']: 64 | return [ 65 | cls( 66 | element=game_contents.get_element(element_id), 67 | convention=convention 68 | ) 69 | for element_id, convention in val.items() 70 | ] if isinstance(val, dict) else [ 71 | cls( 72 | element=game_contents.get_element(val), 73 | convention='base' 74 | ) 75 | ] 76 | -------------------------------------------------------------------------------- /frangiclave/compendium/verb.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from sqlalchemy import Column, Integer, String, Boolean, ForeignKey 4 | from sqlalchemy.orm import relationship 5 | 6 | from frangiclave.compendium.base import Base, Session 7 | from frangiclave.compendium.file import File 8 | from frangiclave.compendium.game_content import GameContentMixin, GameContents 9 | from frangiclave.compendium.slot_specification import SlotSpecification 10 | from frangiclave.compendium.utils import to_bool, get 11 | 12 | 13 | class Verb(Base, GameContentMixin): 14 | __tablename__ = 'verbs' 15 | 16 | id = Column(Integer, primary_key=True) 17 | verb_id: str = Column(String, unique=True) 18 | 19 | label: Optional[str] = Column(String, nullable=True) 20 | description: Optional[str] = Column(String, nullable=True) 21 | at_start: bool = Column(Boolean) 22 | primary_slot_specification_id: int = Column( 23 | Integer, ForeignKey('verbs_slot_specifications.id') 24 | ) 25 | primary_slot_specification: Optional['VerbSlotSpecification'] = \ 26 | relationship( 27 | 'VerbSlotSpecification', 28 | foreign_keys=primary_slot_specification_id 29 | ) 30 | recipes = relationship('Recipe', back_populates='action') 31 | comments: Optional[str] = Column(String, nullable=True) 32 | 33 | @classmethod 34 | def from_data( 35 | cls, 36 | file: File, 37 | data: Dict[str, Any], 38 | translations: Dict[str, Dict[str, Any]], 39 | game_contents: GameContents 40 | ): 41 | r = game_contents.get_verb(data['id']) 42 | r.file = file 43 | r.label = get(data, 'label', translations=translations) 44 | r.description = get(data, 'description', translations=translations) 45 | r.at_start = get(data, 'atStart', False, to_bool) 46 | if 'slots' in data and data['slots']: 47 | r.primary_slot_specification = VerbSlotSpecification.from_data( 48 | data['slots'][0], 49 | { 50 | c: c_transformation["slots"][0] 51 | for c, c_transformation in translations.items() 52 | if "slots" in c_transformation 53 | }, 54 | game_contents 55 | ) 56 | r.comments = get(data, 'comments', None) 57 | return r 58 | 59 | @classmethod 60 | def get_by_verb_id(cls, session: Session, verb_id: str) -> 'Verb': 61 | return session.query(cls).filter(cls.verb_id == verb_id).one() 62 | 63 | 64 | class VerbSlotSpecification(Base, SlotSpecification): 65 | __tablename__ = 'verbs_slot_specifications' 66 | 67 | id = Column(Integer, primary_key=True) 68 | -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.mm/ResourcesManager.cs: -------------------------------------------------------------------------------- 1 | using MonoMod; 2 | using System.IO; 3 | using Noon; 4 | using UnityEngine; 5 | 6 | #pragma warning disable CS0626 7 | 8 | namespace CultistSimulator.Modding.mm 9 | { 10 | [MonoModPatch("global::ResourcesManager")] 11 | public class ResourcesManager 12 | { 13 | public static extern Sprite orig_GetSpriteForVerbLarge(string verbId); 14 | 15 | public static Sprite GetSpriteForVerbLarge(string verbId) 16 | { 17 | return GetSprite("icons100/verbs/", verbId); 18 | } 19 | 20 | public static extern Sprite orig_GetSpriteForElement(string imageName); 21 | 22 | public static Sprite GetSpriteForElement(string imageName) 23 | { 24 | return GetSprite("elementArt/", imageName); 25 | } 26 | 27 | public static extern Sprite orig_GetSpriteForElement(string imageName, int animFrame); 28 | 29 | public static Sprite GetSpriteForElement(string imageName, int animFrame) 30 | { 31 | return GetSprite("elementArt/anim/", string.Concat(imageName, "_", animFrame)); 32 | } 33 | 34 | public static extern Sprite orig_GetSpriteForCardBack(string backId); 35 | 36 | public static Sprite GetSpriteForCardBack(string backId) 37 | { 38 | return GetSprite("cardBacks/", backId); 39 | } 40 | 41 | public static extern Sprite orig_GetSpriteForAspect(string aspectId); 42 | 43 | public static Sprite GetSpriteForAspect(string aspectId) 44 | { 45 | return GetSprite("icons40/aspects/", aspectId); 46 | } 47 | 48 | public static extern Sprite orig_GetSpriteForLegacy(string legacyImage); 49 | 50 | public static Sprite GetSpriteForLegacy(string legacyImage) 51 | { 52 | return GetSprite("icons100/legacies/", legacyImage); 53 | } 54 | 55 | public static extern Sprite orig_GetSpriteForEnding(string endingImage); 56 | 57 | public static Sprite GetSpriteForEnding(string endingImage) 58 | { 59 | return GetSprite("endingArt/en/", endingImage); 60 | } 61 | 62 | private static Sprite GetSprite(string folder, string file) 63 | { 64 | NoonUtility.Log("Loading " + folder + file); 65 | // Check if a local image exists; if it does, load it first 66 | string localPath = Application.streamingAssetsPath + "/" + folder + file + ".png"; 67 | if (File.Exists(localPath)) 68 | { 69 | var fileData = File.ReadAllBytes(localPath); 70 | var texture = new Texture2D(2, 2); 71 | texture.LoadImage(fileData); 72 | return Sprite.Create( 73 | texture, new Rect(0.0f, 0.0f, texture.width, texture.height), new Vector2(0.5f, 0.5f)); 74 | } 75 | 76 | // Try to load the image from the packed resources next, and show the placeholder if not found 77 | Sprite sprite = Resources.Load(folder + file); 78 | return sprite != null ? sprite : Resources.Load(folder + PLACEHOLDER_IMAGE_NAME); 79 | } 80 | 81 | private const string PLACEHOLDER_IMAGE_NAME = "_x"; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /frangiclave/compendium/game_content.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from sqlalchemy import Column, Integer, ForeignKey 4 | from sqlalchemy.ext.declarative import declared_attr 5 | from sqlalchemy.orm import relationship 6 | 7 | from frangiclave.compendium.file import File 8 | 9 | 10 | class GameContents: 11 | 12 | def __init__(self): 13 | self.decks = {} 14 | self.elements = {} 15 | self.legacies = {} 16 | self.recipes = {} 17 | self.verbs = {} 18 | self.endings = {} 19 | 20 | def get_deck(self, deck_id): 21 | if not deck_id: 22 | return None 23 | if deck_id in self.decks: 24 | return self.decks[deck_id] 25 | from frangiclave.compendium.deck import Deck 26 | self.decks[deck_id] = Deck(deck_id=deck_id) 27 | return self.decks[deck_id] 28 | 29 | def get_element(self, element_id): 30 | if not element_id: 31 | return None 32 | if element_id in self.elements: 33 | return self.elements[element_id] 34 | from frangiclave.compendium.element import Element 35 | self.elements[element_id] = Element(element_id=element_id) 36 | return self.elements[element_id] 37 | 38 | def get_legacy(self, legacy_id): 39 | if not legacy_id: 40 | return None 41 | if legacy_id in self.legacies: 42 | return self.legacies[legacy_id] 43 | from frangiclave.compendium.legacy import Legacy 44 | self.legacies[legacy_id] = Legacy(legacy_id=legacy_id) 45 | return self.legacies[legacy_id] 46 | 47 | def get_recipe(self, recipe_id): 48 | if not recipe_id: 49 | return None 50 | if recipe_id in self.recipes: 51 | return self.recipes[recipe_id] 52 | from frangiclave.compendium.recipe import Recipe 53 | self.recipes[recipe_id] = Recipe(recipe_id=recipe_id) 54 | return self.recipes[recipe_id] 55 | 56 | def get_verb(self, verb_id): 57 | if not verb_id: 58 | return None 59 | if verb_id in self.verbs: 60 | return self.verbs[verb_id] 61 | from frangiclave.compendium.verb import Verb 62 | self.verbs[verb_id] = Verb(verb_id=verb_id) 63 | return self.verbs[verb_id] 64 | 65 | def get_ending(self, ending_id): 66 | if not ending_id: 67 | return None 68 | if ending_id in self.endings: 69 | return self.endings[ending_id] 70 | from frangiclave.compendium.ending import Ending 71 | self.endings[ending_id] = Ending(ending_id=ending_id) 72 | return self.endings[ending_id] 73 | 74 | 75 | class GameContentMixin: 76 | 77 | @declared_attr 78 | def file_id(self) -> Column: 79 | return Column(Integer, ForeignKey(File.id)) 80 | 81 | @declared_attr 82 | def file(self) -> File: 83 | return relationship(File) 84 | 85 | @classmethod 86 | def from_data( 87 | cls, 88 | file: File, 89 | data: Dict[str, Any], 90 | translations: Dict[str, Dict[str, Any]], 91 | game_contents: GameContents 92 | ): 93 | raise NotImplementedError 94 | -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.mm/SplashAnimation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using MonoMod; 4 | using System.IO; 5 | using System.Linq; 6 | using Assets.Core.Entities; 7 | using Noon; 8 | using UnityEngine; 9 | 10 | #pragma warning disable CS0626 11 | 12 | namespace CultistSimulator.Modding.mm 13 | { 14 | [MonoModPatch("global::SplashAnimation")] 15 | public class SplashAnimation 16 | { 17 | private extern void orig_Start(); 18 | 19 | private void Start() 20 | { 21 | orig_Start(); 22 | string path = Application.persistentDataPath + "/config.ini"; 23 | string text = File.ReadAllText(path); 24 | if (!text.Contains("export=1")) 25 | { 26 | return; 27 | } 28 | ExportAssetsToFileSystem(); 29 | } 30 | 31 | private static void ExportAssetsToFileSystem() 32 | { 33 | // Export art 34 | LanguageTable.LoadCulture("en"); 35 | ExportSpriteFolderToFileSystem("burnImages/", "png"); 36 | ExportSpriteFolderToFileSystem("cardBacks/", "png"); 37 | ExportSpriteFolderToFileSystem("elementArt/", "png"); 38 | ExportSpriteFolderToFileSystem("elementArt/anim/", "png"); 39 | ExportSpriteFolderToFileSystem("endingArt/", "png"); 40 | ExportSpriteFolderToFileSystem("icons40/aspects/", "png"); 41 | ExportSpriteFolderToFileSystem("icons100/legacies/", "png"); 42 | ExportSpriteFolderToFileSystem("icons100/verbs/", "png"); 43 | } 44 | 45 | private static void ExportSpriteFolderToFileSystem(string sourceFolder, string ext) 46 | { 47 | string exportFolder = Path.Combine(ExportDir, sourceFolder); 48 | Directory.CreateDirectory(exportFolder); 49 | var encounteredNames = new HashSet(); 50 | foreach (var asset in Resources.LoadAll(sourceFolder)) 51 | { 52 | NoonUtility.Log("Asset: " + asset.name); 53 | if (encounteredNames.Contains(asset.name)) 54 | { 55 | NoonUtility.Log("Encountered before!"); 56 | continue; 57 | } 58 | 59 | encounteredNames.Add(asset.name); 60 | string exportPath = Path.Combine(exportFolder, asset.name + "." + ext); 61 | File.WriteAllBytes(exportPath, GetSpriteAsPng(asset)); 62 | Resources.UnloadAsset(asset); 63 | } 64 | } 65 | 66 | private static byte[] GetSpriteAsPng(Sprite sprite) 67 | { 68 | // Copy the texture so that its data can be manipulated 69 | // Source: https://support.unity3d.com/hc/en-us/articles/206486626-How-can-I-get-pixels-from-unreadable-textures- 70 | RenderTexture tmp = RenderTexture.GetTemporary( 71 | sprite.texture.width, 72 | sprite.texture.height, 73 | 0, 74 | RenderTextureFormat.Default, 75 | RenderTextureReadWrite.Linear); 76 | Graphics.Blit(sprite.texture, tmp); 77 | RenderTexture previous = RenderTexture.active; 78 | RenderTexture.active = tmp; 79 | Texture2D spriteTexture = new Texture2D(sprite.texture.width, sprite.texture.height); 80 | spriteTexture.ReadPixels(new Rect(0, 0, tmp.width, tmp.height), 0, 0); 81 | spriteTexture.Apply(); 82 | RenderTexture.active = previous; 83 | RenderTexture.ReleaseTemporary(tmp); 84 | 85 | // Crop the texture to the sprite 86 | Rect r = sprite.textureRect; 87 | Texture2D croppedTextured = spriteTexture.CropTexture((int) r.x, (int) r.y, (int) r.width, (int) r.height); 88 | return croppedTextured.EncodeToPNG(); 89 | } 90 | 91 | private static readonly string ExportDir = Application.streamingAssetsPath; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /frangiclave/compendium/slot_specification.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, List 2 | 3 | from sqlalchemy import Column, String, Boolean, ForeignKey, Integer, Table 4 | from sqlalchemy.ext.declarative import declared_attr 5 | from sqlalchemy.orm import relationship 6 | 7 | from frangiclave.compendium.base import Base 8 | from frangiclave.compendium.game_content import GameContents 9 | from frangiclave.compendium.utils import get, to_bool 10 | 11 | if TYPE_CHECKING: 12 | from frangiclave.compendium.verb import Verb 13 | 14 | 15 | class SlotSpecification: 16 | __tablename__ = None 17 | 18 | label: str = Column(String) 19 | description: str = Column(String) 20 | 21 | @declared_attr 22 | def required(self) -> List['SlotSpecificationItem']: 23 | return relationship( 24 | 'SlotSpecificationItem', 25 | secondary=lambda: self.secondary('required') 26 | ) 27 | 28 | @declared_attr 29 | def forbidden(self) -> List['SlotSpecificationItem']: 30 | return relationship( 31 | 'SlotSpecificationItem', 32 | secondary=lambda: self.secondary('forbidden') 33 | ) 34 | 35 | @classmethod 36 | def secondary(cls, attr: str): 37 | return Table( 38 | cls.__tablename__ + '_' + attr + '_items_associations', 39 | Base.metadata, 40 | Column( 41 | 'slot_specification_id', 42 | Integer, 43 | ForeignKey(cls.__tablename__ + '.id') 44 | ), 45 | Column( 46 | 'item_id', 47 | Integer, 48 | ForeignKey('slot_specification_items.id') 49 | ) 50 | ) 51 | 52 | greedy: bool = Column(Boolean) 53 | consumes: bool = Column(Boolean) 54 | no_animation: bool = Column(Boolean) 55 | 56 | @declared_attr 57 | def for_verb_id(self) -> Column: 58 | return Column(Integer, ForeignKey('verbs.id')) 59 | 60 | @declared_attr 61 | def for_verb(self) -> 'Verb': 62 | return relationship('Verb', foreign_keys=self.for_verb_id) 63 | 64 | @classmethod 65 | def from_data( 66 | cls, 67 | data: Dict[str, Any], 68 | translations: Dict[str, Dict[str, Any]], 69 | game_contents: GameContents 70 | ) -> 'SlotSpecification': 71 | s = cls() 72 | s.element = game_contents.get_element(data['id']) 73 | s.label = get( 74 | data, 'label', s.element.element_id, translations=translations 75 | ) 76 | s.description = get(data, 'description', '', translations=translations) 77 | s.required = [ 78 | SlotSpecificationItem( 79 | element=game_contents.get_element(element_id), 80 | quantity=quantity 81 | ) for element_id, quantity in get(data, 'required', {}).items() 82 | ] 83 | s.forbidden = [ 84 | SlotSpecificationItem( 85 | element=game_contents.get_element(element_id), 86 | quantity=quantity 87 | ) for element_id, quantity in get(data, 'forbidden', {}).items() 88 | ] 89 | s.greedy = get(data, 'greedy', False, to_bool) 90 | s.consumes = get(data, 'consumes', False, to_bool) 91 | s.no_animation = get(data, 'noanim', False, to_bool) 92 | s.for_verb = game_contents.get_verb(get(data, 'actionId', None)) 93 | return s 94 | 95 | 96 | class SlotSpecificationItem(Base): 97 | __tablename__ = 'slot_specification_items' 98 | 99 | id = Column(Integer, primary_key=True) 100 | element_id = Column(Integer, ForeignKey('elements.id')) 101 | element = relationship('Element') 102 | quantity = Column(Integer) 103 | -------------------------------------------------------------------------------- /frangiclave/compendium/legacy.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, List, Optional 2 | 3 | from sqlalchemy import Column, Integer, String, Boolean, ForeignKey 4 | from sqlalchemy.orm import Session, relationship 5 | 6 | from frangiclave.compendium.base import Base 7 | from frangiclave.compendium.file import File 8 | from frangiclave.compendium.game_content import GameContents, GameContentMixin 9 | from frangiclave.compendium.utils import to_bool, get 10 | 11 | if TYPE_CHECKING: 12 | from frangiclave.compendium.element import Element 13 | 14 | 15 | class Legacy(Base, GameContentMixin): 16 | __tablename__ = 'legacies' 17 | 18 | id = Column(Integer, primary_key=True) 19 | legacy_id: str = Column(String, unique=True) 20 | 21 | label: str = Column(String) 22 | description: str = Column(String) 23 | start_description: str = Column(String) 24 | image: str = Column(String) 25 | from_ending: str = Column(String) 26 | excludes_on_ending: List['LegacyExcludes'] = relationship( 27 | 'LegacyExcludes', 28 | back_populates='legacy', 29 | foreign_keys='LegacyExcludes.legacy_id' 30 | ) 31 | available_without_ending_match: bool = Column(Boolean) 32 | starting_verb_id: str = Column(String) 33 | effects: List['LegacyEffect'] = relationship('LegacyEffect') 34 | comments: Optional[str] = Column(String, nullable=True) 35 | 36 | @classmethod 37 | def from_data( 38 | cls, 39 | file: File, 40 | data: Dict[str, Any], 41 | translations: Dict[str, Dict[str, Any]], 42 | game_contents: GameContents 43 | ) -> 'Legacy': 44 | lg = game_contents.get_legacy(data['id']) 45 | lg.file = file 46 | lg.label = get(data, 'label', translations=translations) 47 | lg.description = get(data, 'description', translations=translations) 48 | lg.start_description = get( 49 | data, 'startdescription', translations=translations 50 | ) 51 | lg.image = get(data, 'image') 52 | lg.from_ending = get(data, 'fromEnding') 53 | lg.excludes_on_ending = [ 54 | LegacyExcludes( 55 | legacy=lg, 56 | exclude=game_contents.get_legacy(l) 57 | ) for l in get(data, 'excludesOnEnding', []) 58 | ] 59 | lg.available_without_ending_match = get( 60 | data, 'availableWithoutEndingMatch', False, to_bool 61 | ) 62 | lg.starting_verb_id = get(data, 'startingVerbId') 63 | lg.effects = LegacyEffect.from_data( 64 | get(data, 'effects', {}), game_contents 65 | ) 66 | return lg 67 | 68 | @classmethod 69 | def get_by_legacy_id(cls, session: Session, legacy_id: str) -> 'Legacy': 70 | return session.query(cls).filter(cls.legacy_id == legacy_id).one() 71 | 72 | 73 | class LegacyEffect(Base): 74 | __tablename__ = 'legacies_effects' 75 | 76 | id = Column(Integer, primary_key=True) 77 | legacy_id = Column(Integer, ForeignKey(Legacy.id)) 78 | 79 | element_id: int = Column(Integer, ForeignKey('elements.id')) 80 | element: 'Element' = relationship('Element') 81 | 82 | quantity = Column(Integer) 83 | 84 | @classmethod 85 | def from_data(cls, val: Dict[str, str], game_contents: GameContents): 86 | return [ 87 | cls( 88 | element=game_contents.get_element(element_id), 89 | quantity=int(quantity) 90 | ) 91 | for element_id, quantity in val.items() 92 | ] 93 | 94 | 95 | class LegacyExcludes(Base): 96 | __tablename__ = 'legacies_excludes' 97 | 98 | id = Column(Integer, primary_key=True) 99 | legacy_id = Column(Integer, ForeignKey(Legacy.id)) 100 | legacy = relationship( 101 | Legacy, back_populates='excludes_on_ending', foreign_keys=legacy_id 102 | ) 103 | 104 | exclude_id: int = Column(Integer, ForeignKey(Legacy.id)) 105 | exclude: Legacy = relationship(Legacy, foreign_keys=exclude_id) 106 | -------------------------------------------------------------------------------- /frangiclave/templates/base.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | {% import "macros.tpl.html" as m with context %} 3 | 4 | 5 | 6 | 7 | {% block title %}{% endblock %} - Frangiclave Compendium 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 27 | 31 | 32 | 33 | 34 |
35 | 38 | 39 | 40 | 41 |

Frangiclave

42 |
43 |
44 |
45 | 79 |
80 | {% block content %}Content{% endblock %} 81 |
82 |
83 |
Cultist Simulator is the sole property of Weather Factory. All rights reserved.
All game content on this website, including images and text, is used with permission.
84 | 85 | 86 | -------------------------------------------------------------------------------- /frangiclave/templates/recipe.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Recipe: {{ recipe.recipe_id }}{% endblock %} 4 | {% block description %}{{ recipe.description }}{% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Recipe: {{ recipe.recipe_id }} 18 |

19 | 20 | {% if recipe.burn_image %} 21 | 22 | {% endif %} 23 | 24 |

Label: {{ m.localised(recipe.label) }}

25 | 26 |

Start Description: {{ m.localised(recipe.start_description) }}

27 | 28 |

Description: {{ m.localised(recipe.description, True) }}

29 | 30 |

Action: {% if recipe.action_id %}{{ m.verb(recipe.action.verb_id) }}{% else %}None{% endif %}

31 | 32 |

Requirements: {{ m.element_list(recipe.requirements) }}

33 | 34 |

Table Requirements: {{ m.element_list(recipe.table_requirements) }}

35 | 36 |

Extant Requirements: {{ m.element_list(recipe.extant_requirements) }}

37 | 38 |

Effects: {{ m.element_list(recipe.effects) }}

39 | 40 |

Aspects: {{ m.element_list(recipe.aspects) }}

41 | 42 |

Mutation Effects: {% if not recipe.mutation_effects %}None{% endif %}

43 |
    44 | {% for me in recipe.mutation_effects %} 45 |
  • 46 | Filter on Aspect: {{ m.element(me.filter_on_aspect.element_id) }} -> {{ m.element(me.mutate_aspect.element_id) }} ({{ me.mutation_level }}{% if me.additive %}, additive{% endif %}) 47 |
  • 48 | {% endfor %} 49 |
50 | 51 |

Purge: {{ m.element_list(recipe.purge) }}

52 | 53 |

Halt Verbs: {% if not recipe.halt_verb %}None{% endif %}

54 |
    {% for halt_verb in recipe.halt_verb %}
  • {{halt_verb.wildcard}}: {{halt_verb.quantity}}
  • {% endfor %}
55 | 56 |

Delete Verbs: {% if not recipe.delete_verb %}None{% endif %}

57 |
    {% for delete_verb in recipe.delete_verb %}
  • {{delete_verb.wildcard}}: {{delete_verb.quantity}}
  • {% endfor %}
58 | 59 |

Alternative Recipes: {% if not recipe.alternative_recipes %}Nothing{% endif %}

60 | {{ m.linked_recipe_details(recipe.alternative_recipes) }} 61 | 62 |

Linked Recipes: {% if not recipe.linked_recipes %}Nothing{% endif %}

63 | {{ m.linked_recipe_details(recipe.linked_recipes) }} 64 | 65 |

From Recipes: {% if not recipe.from_recipes %}Nothing{% endif %}

66 |
    {% for r in recipe.from_recipes %}
  • {{ m.recipe(r.recipe_id) }}
  • {% endfor %}
67 | 68 |

Slots: {% if not recipe.slot_specifications %}None{% endif %}

69 | {{ m.slot_specifications(recipe.slot_specifications) }} 70 | 71 |

Warmup: {{ recipe.warmup }}

72 | 73 |

Maximum Executions: {{ recipe.max_executions }}

74 | 75 |

Deck Effect: {% if recipe.deck_effect %}

    {% for d in recipe.deck_effect %}
  • {{ m.deck(d.deck.deck_id) }}: {{ d.quantity }}
  • {% endfor %}
{% else %}None{% endif %}

76 | 77 |

Internal Deck: {{ m.deck(recipe.internal_deck.deck_id) }}

78 | 79 |

Ending Flag: {{ m.ending(recipe.ending_flag) }}

80 | 81 |

Signal Ending Flavour: {{ recipe.signal_ending_flavour.value }}

82 | 83 |

Portal Effect: {{ recipe.portal_effect.value }}

84 | 85 |

Craftable? {{ m.yes_no(recipe.craftable) }}

86 | 87 |

Hint Only? {{ m.yes_no(recipe.hint_only) }}

88 | 89 |

Signal Important Loop? {{ m.yes_no(recipe.signal_important_loop) }}

90 | 91 |

Comments: {{ m.multiline(m.optional(recipe.comments)) }}

92 | 93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /frangiclave/templates/element.tpl.html: -------------------------------------------------------------------------------- 1 | {% extends "base.tpl.html" %} 2 | {% import "macros.tpl.html" as m with context %} 3 | {% block title %}Element: {{ element.element_id }}{% endblock %} 4 | {% block description %}{{ element.description }}{% endblock %} 5 | {% block content %} 6 |

7 | {% if not read_only %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% endif %} 17 | Element: {{ element.element_id }} 18 |

19 | {% if element.is_aspect %} 20 | 21 | {% elif not element.no_art_needed %} 22 | 23 | {% endif %} 24 | 25 |

Label: {{ m.localised(element.label) }}

26 | 27 |

Description: {{ m.localised(element.description, True) }}

28 | 29 |

Aspects: {{ m.aspect_list(element.aspects) }}

30 | 31 |

Induces: {% if not element.induces %}Nothing{% endif %}

32 | {{ m.linked_recipe_details(element.induces) }} 33 | 34 |

Slots: {% if not element.child_slots %}None{% endif %}

35 | {{ m.slot_specifications(element.child_slots) }} 36 | 37 |

Cross Triggers: {% if not element.x_triggers %}None{% endif %}

38 |
    {% for x in element.x_triggers %}
  • {{ m.element(x.trigger.element_id) }} -> {{ m.element(x.result.element_id) }}{%if x.morph_effect %}} ({{ x.morph_effect }}{%if x.morph_effect_level %} {{ x.morph_effect_level }}{%endif %}){% endif %}
  • {% endfor %}
39 | 40 |

Triggered By: {% if not element.triggered_by %}None{% endif %}

41 |
    {% for x in element.triggered_by %}
  • {{ m.element(x.trigger.element_id) }} (from {{ m.element(x.element.element_id) }})
  • {% endfor %}
42 | 43 |

Triggered With: {% if not element.triggered_with %}None{% endif %}

44 |
    {% for x in element.triggered_with %}
  • {{ m.element(x.result.element_id) }} (from {{ m.element(x.element.element_id) }})
  • {% endfor %}
45 | 46 |

Requirement for Recipes: {% if not element.requirement_for_recipes %}None{% endif %}

47 |
    {% for recipe in element.requirement_for_recipes %}
  • {{ m.recipe(recipe.recipe_id) }}
  • {% endfor %}
48 | 49 |

Effect of Recipes: {% if not element.effect_of_recipes %}None{% endif %}

50 |
    {% for recipe in element.effect_of_recipes %}
  • {{ m.recipe(recipe.recipe_id) }}
  • {% endfor %}
51 | 52 |

Lifetime: {% if element.lifetime %}{{ element.lifetime }}{% else %}None{% endif %}

53 | 54 |

Decay To: {{ m.element(element.decay_to.element_id) }}

55 | 56 |

Decay From: {% if not element.decay_from %}None{% endif %}

57 |
    {% for e in element.decay_from %}
  • {{ m.element(e.element_id) }}
  • {% endfor %}
58 | 59 |

Aspect? {% if element.is_aspect %}Yes{% else %}No{% endif %}

60 |
    {% for e in element.aspect_for %}
  • {{ m.element(e.element_id) }}
  • {% endfor %}
61 | 62 |

Unique? {% if element.unique %}Yes{% else %}No{% endif %}

63 | 64 |

Uniqueness Group: {{ m.optional(element.uniqueness_group) }}

65 | 66 |

Hidden? {{ m.yes_no(element.is_hidden) }}

67 | 68 |

No Art Needed? {{ m.yes_no(element.no_art_needed) }}

69 | 70 |

Resaturate? {{ m.yes_no(element.resaturate) }}

71 | 72 |

Override Verb Icon: {{ m.optional(element.verb_icon) }}

73 | 74 |

Animation Frames: {{ element.animation_frames }}

75 | 76 |

In Decks: {% if not element.in_decks and not element.in_decks_default %}None{% endif %}

77 |
    {% for deck in element.in_decks %}
  • {{ m.deck(deck.deck_id) }}
  • {% endfor %}
78 | 79 |

Comments: {{ m.optional(element.comments) }}

80 | {% endblock %} 81 | 82 | -------------------------------------------------------------------------------- /frangiclave/game/importer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import platform 3 | from typing import Type, Iterable, Dict, Any, List, Tuple 4 | 5 | from jsom import JsomParser, ALL_WARNINGS 6 | 7 | from frangiclave.compendium.base import get_session, Base 8 | from frangiclave.compendium.deck import Deck 9 | from frangiclave.compendium.element import Element 10 | from frangiclave.compendium.ending import Ending 11 | from frangiclave.compendium.file import File, FileCategory, FileGroup 12 | from frangiclave.compendium.game_content import GameContentMixin, GameContents 13 | from frangiclave.compendium.legacy import Legacy 14 | 15 | from frangiclave.compendium.recipe import Recipe 16 | from frangiclave.compendium.verb import Verb 17 | 18 | DATA_DIR = ( 19 | 'cultistsimulator_Data' if platform.system() == 'Windows' else 'CS_Data' 20 | ) 21 | 22 | ADDITIONAL_CULTURES = ('ru', 'zh-hans') 23 | 24 | jsom = JsomParser(ignore_warnings=ALL_WARNINGS) 25 | 26 | 27 | def import_game_data(game_dir: str): 28 | assets_dir = Path(game_dir)/DATA_DIR/'StreamingAssets' 29 | content_dir = assets_dir/'content' 30 | with get_session() as session: 31 | 32 | # Load the content from the regular files 33 | game_contents = GameContents() 34 | for group in FileGroup: 35 | decks = _load_content( 36 | Deck, 37 | content_dir, 38 | group, 39 | FileCategory.DECKS, 40 | game_contents 41 | ) 42 | elements = _load_content( 43 | Element, 44 | content_dir, 45 | group, 46 | FileCategory.ELEMENTS, 47 | game_contents 48 | ) 49 | endings = _load_content( 50 | Ending, 51 | content_dir, 52 | group, 53 | FileCategory.ENDINGS, 54 | game_contents 55 | ) 56 | legacies = _load_content( 57 | Legacy, 58 | content_dir, 59 | group, 60 | FileCategory.LEGACIES, 61 | game_contents 62 | ) 63 | recipes = _load_content( 64 | Recipe, 65 | content_dir, 66 | group, 67 | FileCategory.RECIPES, 68 | game_contents 69 | ) 70 | verbs = _load_content( 71 | Verb, 72 | content_dir, 73 | group, 74 | FileCategory.VERBS, 75 | game_contents 76 | ) 77 | 78 | # Create the dynamically generated secondary tables 79 | Base.metadata.create_all() 80 | 81 | session.add_all(decks) 82 | session.add_all(elements) 83 | session.add_all(endings) 84 | session.add_all(legacies) 85 | session.add_all(recipes) 86 | session.add_all(verbs) 87 | 88 | 89 | def _load_content( 90 | content_class: Type[GameContentMixin], 91 | content_dir: Path, 92 | group: FileGroup, 93 | category: FileCategory, 94 | game_contents: GameContents 95 | ) -> List[GameContentMixin]: 96 | content = [] 97 | for file_name, file_data in _load_json_data( 98 | content_dir/group.value/category.value 99 | ): 100 | file = File(category, group, str(file_name)) 101 | 102 | translations = {} 103 | for culture in ADDITIONAL_CULTURES: 104 | localised_path = content_dir/f'{group.value}_{culture}'/category.value/str(file_name) 105 | if not localised_path.exists(): 106 | continue 107 | with localised_path.open(encoding='utf-8') as f: 108 | translations[culture] = { 109 | e["id"]: e for e in jsom.load(f)[category.value] 110 | } 111 | 112 | for i, data in enumerate(file_data[category.value]): 113 | entity_id = data["id"] 114 | entity_translations = {} 115 | for culture, entities in translations.items(): 116 | if entity_id in entities: 117 | entity_translations[culture] = entities[entity_id] 118 | content.append(content_class.from_data( 119 | file, data, entity_translations, game_contents 120 | )) 121 | return content 122 | 123 | 124 | def _load_json_data( 125 | category_dir: Path 126 | ) -> Iterable[Tuple[Path, Dict[str, Any]]]: 127 | for json_file_path in sorted(category_dir.glob('*.json')): 128 | with json_file_path.open(encoding='utf-8') as f: 129 | yield json_file_path.name, jsom.load(f) 130 | -------------------------------------------------------------------------------- /frangiclave/bot/client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import traceback 3 | from typing import Any, Dict, List, Optional 4 | 5 | from slackclient import SlackClient 6 | 7 | from frangiclave.bot.templates.deck import make_deck 8 | from frangiclave.bot.templates.element import make_element 9 | from frangiclave.bot.templates.ending import make_ending 10 | from frangiclave.bot.templates.legacy import make_legacy 11 | from frangiclave.bot.templates.recipe import make_recipe 12 | from frangiclave.bot.templates.verb import make_verb 13 | from frangiclave.bot.templates.search_results import make_search_results 14 | from frangiclave.compendium.base import get_session 15 | from frangiclave.compendium.deck import Deck 16 | from frangiclave.compendium.element import Element 17 | from frangiclave.compendium.ending import Ending 18 | from frangiclave.compendium.legacy import Legacy 19 | from frangiclave.compendium.recipe import Recipe 20 | from frangiclave.compendium.verb import Verb 21 | from frangiclave.search import search_compendium, search_compendium_by_id 22 | 23 | RTM_READ_DELAY = 0.5 24 | PROFILE_PICTURE = \ 25 | 'https://www.frangiclave.net/static/images/elementArt/toolknockf.png' 26 | 27 | 28 | class BotClient: 29 | 30 | def __init__(self, slack_bot_token: str): 31 | self.slack_client = SlackClient(slack_bot_token) 32 | connected = self.slack_client.rtm_connect(auto_reconnect=True) 33 | if not connected: 34 | raise Exception('Failed to connect to Slack workspace') 35 | self.bot_id = self.slack_client.api_call('auth.test')['user_id'] 36 | 37 | def process_event( 38 | self, 39 | event: Dict[str, Any] 40 | ): 41 | if 'bot_id' in event and event['bot_id'] == self.bot_id: 42 | return 43 | if event['type'] == 'message': 44 | if 'subtype' in event and event['subtype'] in ( 45 | 'bot_message', 'message_changed' 46 | ): 47 | return 48 | text = event['text'] 49 | if text.startswith('?'): 50 | bits = text.split(maxsplit=1) 51 | command = bits[0][1:] 52 | params = bits[1] if len(bits) > 1 else None 53 | self.process_command(event, command, params) 54 | 55 | def process_command( 56 | self, 57 | event: Dict[str, Any], 58 | command: str, 59 | params: str 60 | ): 61 | if command in ('', 'g', 'get'): 62 | with get_session() as session: 63 | result = search_compendium_by_id(session, params.strip()) 64 | if not result: 65 | self.reply(event, 'No results found.') 66 | elif isinstance(result, Deck): 67 | self.reply(event, None, make_deck(result)) 68 | elif isinstance(result, Element): 69 | self.reply(event, None, make_element(result)) 70 | elif isinstance(result, Legacy): 71 | self.reply(event, None, make_legacy(result)) 72 | elif isinstance(result, Recipe): 73 | self.reply(event, None, make_recipe(result)) 74 | elif isinstance(result, Verb): 75 | self.reply(event, None, make_verb(result)) 76 | elif isinstance(result, Ending): 77 | self.reply(event, None, make_ending(result)) 78 | elif command in ('s', 'search'): 79 | keywords = params.strip() 80 | with get_session() as session: 81 | results = search_compendium(session, keywords) 82 | self.reply(event, None, make_search_results(keywords, results)) 83 | 84 | def reply( 85 | self, 86 | event: Dict[str, Any], 87 | text: Optional[str] = None, 88 | blocks: List[Dict[str, Any]] = None 89 | ): 90 | result = self.slack_client.api_call( 91 | 'chat.postMessage', 92 | channel=event['channel'], 93 | text=text, 94 | icon_url=PROFILE_PICTURE, 95 | thread_ts=event['thread_ts'] if 'thread_ts' in event else None, 96 | blocks=blocks 97 | ) 98 | if not result['ok']: 99 | print('Failed to reply: {}'.format(result)) 100 | 101 | def run(self): 102 | while self.slack_client.server.connected: 103 | events = self.slack_client.rtm_read() 104 | for event in events: 105 | # noinspection PyBroadException 106 | try: 107 | self.process_event(event) 108 | except: 109 | print('Failed to process event:', event) 110 | traceback.print_exc() 111 | time.sleep(RTM_READ_DELAY) 112 | -------------------------------------------------------------------------------- /frangiclave/templates/macros.tpl.html: -------------------------------------------------------------------------------- 1 | {% macro deck(id) -%}{{ optional_link('/deck/', id) }}{%- endmacro %} 2 | {% macro element(id) -%}{{ optional_link('/element/', id) }}{%- endmacro %} 3 | {% macro ending(id) -%}{{ optional_link('/ending/', id) }}{%- endmacro %} 4 | {% macro legacy(id) -%}{{ optional_link('/legacy/', id) }}{%- endmacro %} 5 | {% macro recipe(id) -%}{{ optional_link('/recipe/', id) }}{%- endmacro %} 6 | {% macro verb(id) -%}{{ optional_link('/verb/', id) }}{%- endmacro %} 7 | 8 | {% macro element_quantity(element, quantity) -%}{{ quantity }}{% if element.is_aspect %}{{ icons40(element) }}{% else %}{{ elementArt(element) }}{% endif %}{%- endmacro %} 9 | 10 | 11 | {% macro image(folder, element) -%}{%- endmacro %} 12 | {% macro icons40(element) -%}{{ image("icons40/aspects", element) }}{%- endmacro %} 13 | {% macro elementArt(element) -%}{{ image("elementArt", element) }}{%- endmacro %} 14 | 15 | {% macro aspect(a) -%}{{ a.quantity }}{{ icons40(a.aspect) }}{%- endmacro %} 16 | {% macro item(i) -%}{{ i.quantity }}{{ icons40(i.element) }}{%- endmacro %} 17 | {% macro challenge(c) -%}{{ icons40(c.element) }}{% if c.convention == 'advance' %} (adv.){% endif %}{%- endmacro %} 18 | 19 | {% macro aspect_list(aspects) -%}{% if aspects %}{% for a in aspects %}{{ element_quantity(a.aspect, a.quantity) }}{% endfor %}{% else %}None{% endif %}{%- endmacro %} 20 | {% macro element_list(elements) -%}{% if elements %}{% for e in elements %}{{ element_quantity(e.element, e.quantity) }}{% endfor %}{% else %}None{% endif %}{%- endmacro %} 21 | 22 | {% macro linked_recipe_details(recipe_details_list) -%}
    {% for r in recipe_details_list %}
  • {{ m.recipe(r.recipe.recipe_id) }} (chance: {{ r.chance }}%{% if r.additional %}, additional{% endif %}{% if r.challenges %}, challenges:{% for req in r.challenges %}{{ challenge(req) }}{% endfor %}{% endif %})
  • {% endfor %}
23 | {%- endmacro %} 24 | 25 | {% macro multiline(string) -%}{{ string | sprite_replace | safe }}{%- endmacro %} 26 | {% macro yes_no(boolean) -%}{% if boolean %}Yes{% else %}No{% endif %}{%- endmacro %} 27 | {% macro optional(value) -%}{% if value %}{{ value }}{% else %}None{% endif %}{%- endmacro %} 28 | {% macro optional_link(link_base, id) -%}{% if id %}{{ id }}{% else %}None{% endif %}{%- endmacro %} 29 | 30 | {% macro slot_specifications(specs) -%} 31 |
    32 | {% for slot in specs %} 33 |
  • 34 | Label: {{ m.localised(slot.label) }}
    35 | Description: {{ m.localised(slot.description) }}
    36 | Aspects: {{ element_list(slot.required) }}
    37 | Forbidden: {{ element_list(slot.forbidden) }}
    38 | Greedy? {% if slot.greedy %}Yes{% else %}No{% endif %}
    39 | Consumes? {% if slot.consumes %}Yes{% else %}No{% endif %} 40 |
  • 41 | {% endfor %} 42 |
43 | {%- endmacro %} 44 | 45 | {% macro generic_list(entries, category, read_only, prefix, path) -%} 46 | {% for entry in entries %} 47 | {{ entry }} 48 | {% endfor %} 49 | {% endmacro %} 50 | 51 | {% macro file_list(files, category, read_only, prefix, path) -%} 52 | {% for file, entries in files[category].items()|sort(attribute='0.name') %} 53 |
54 |
55 | {% if not read_only %} 56 |
57 | 58 | 59 | 60 |
61 | {% endif %} 62 | {{ file.name }} ({{ file.group.value }}) 63 |
64 | {% for entry in entries %} 65 | {{ entry }} 66 | {% endfor %} 67 |
68 | {% endfor %} 69 | {%- endmacro %} 70 | 71 | 72 | {% macro localised(string, multi=False) -%} 73 | {% if string %} 74 |
    75 | {% for translation in string.split('$$') %} 76 | {% if multi %} 77 |
  • {{ multiline(translation) }}
  • 78 | {% else %} 79 |
  • {{ translation }}
  • 80 | {% endif %} 81 | {% endfor %} 82 |
83 | {% else %} 84 | None 85 | {% endif %} 86 | {% endmacro %} 87 | -------------------------------------------------------------------------------- /frangiclave/compendium/deck.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Dict, List, Optional, Any, Tuple 2 | 3 | from sqlalchemy import Column, String, Boolean, Integer, ForeignKey 4 | from sqlalchemy.ext.hybrid import hybrid_property 5 | from sqlalchemy.orm import relationship 6 | 7 | from frangiclave.compendium.base import Base, Session 8 | from frangiclave.compendium.file import File 9 | from frangiclave.compendium.game_content import GameContentMixin, GameContents 10 | from frangiclave.compendium.utils import to_bool, get 11 | 12 | if TYPE_CHECKING: 13 | from frangiclave.compendium.element import Element 14 | 15 | 16 | class DeckCard(Base): 17 | __tablename__ = 'deck_cards' 18 | 19 | id = Column(Integer, primary_key=True) 20 | deck_id: int = Column(Integer, ForeignKey('decks.id')) 21 | deck: 'Deck' = relationship( 22 | 'Deck', back_populates='_cards', foreign_keys=deck_id 23 | ) 24 | element_id: int = Column(Integer, ForeignKey('elements.id')) 25 | element: 'Element' = relationship( 26 | 'Element', back_populates='_in_decks', foreign_keys=element_id 27 | ) 28 | 29 | 30 | class Deck(Base, GameContentMixin): 31 | __tablename__ = 'decks' 32 | 33 | id = Column(Integer, primary_key=True) 34 | deck_id: str = Column(String, unique=True) 35 | 36 | _cards: List[DeckCard] = relationship( 37 | 'DeckCard', 38 | back_populates='deck', 39 | ) 40 | 41 | default_card_id = Column(Integer, ForeignKey('elements.id')) 42 | default_card = relationship('Element', back_populates='in_decks_default') 43 | reset_on_exhaustion = Column(Boolean) 44 | label = Column(String, nullable=True) 45 | description = Column(String, nullable=True) 46 | all_draw_messages: List['DeckDrawMessage'] = relationship( 47 | 'DeckDrawMessage', back_populates='deck' 48 | ) 49 | comments: Optional[str] = Column(String, nullable=True) 50 | 51 | @hybrid_property 52 | def cards(self) -> Tuple['Element']: 53 | return tuple(sorted( 54 | (dc.element for dc in self._cards), 55 | key=lambda e: e.element_id 56 | )) 57 | 58 | @hybrid_property 59 | def draw_messages(self) -> Tuple['DeckDrawMessage']: 60 | return tuple(dm for dm in self.all_draw_messages if not dm.default) 61 | 62 | @hybrid_property 63 | def default_draw_messages(self) -> Tuple['DeckDrawMessage']: 64 | return tuple(dm for dm in self.all_draw_messages if dm.default) 65 | 66 | @classmethod 67 | def from_data( 68 | cls, 69 | file: File, 70 | data: Dict[str, Any], 71 | translations: Dict[str, Dict[str, Any]], 72 | game_contents: GameContents 73 | ) -> 'Deck': 74 | d = game_contents.get_deck(data['id']) 75 | d.file = file 76 | d._cards = [ 77 | DeckCard(element=game_contents.get_element(c)) 78 | for c in get(data, 'spec', []) 79 | ] 80 | d.default_card = game_contents.get_element( 81 | get(data, 'defaultcard', None) 82 | ) 83 | d.reset_on_exhaustion = get(data, 'resetonexhaustion', False, to_bool) 84 | d.label = get(data, 'label', None, translations=translations) 85 | d.description = get( 86 | data, 'description', None, translations=translations 87 | ) 88 | d.all_draw_messages = [ 89 | DeckDrawMessage( 90 | element=game_contents.get_element(element_id), 91 | message=message + cls._get_draw_message_loc( 92 | translations, element_id 93 | ) 94 | ) for element_id, message in get(data, 'drawmessages', {}).items() 95 | ] + [ 96 | DeckDrawMessage( 97 | element=game_contents.get_element(element_id), 98 | message=message + cls._get_draw_message_loc( 99 | translations, element_id 100 | ), 101 | default=True 102 | ) for element_id, message in get( 103 | data, 'defaultdrawmessages', {} 104 | ).items() 105 | ] 106 | d.comments = get(data, 'comments', None) 107 | return d 108 | 109 | @classmethod 110 | def get_by_deck_id(cls, session: Session, deck_id: str) -> 'Deck': 111 | return session.query(cls).filter(cls.deck_id == deck_id).one() 112 | 113 | @staticmethod 114 | def _get_draw_message_loc(translations, element_id): 115 | string = '' 116 | for culture, translation in translations.items(): 117 | if 'drawmessages' in translation \ 118 | and element_id in translation['drawmessages']\ 119 | and translation['drawmessages'][element_id]: 120 | string += \ 121 | f'$${translation["drawmessages"][element_id]}' 122 | return string 123 | 124 | 125 | class DeckDrawMessage(Base): 126 | __tablename__ = 'decks_draw_messages' 127 | 128 | id = Column(Integer, primary_key=True) 129 | deck_id = Column(Integer, ForeignKey(Deck.id)) 130 | deck = relationship('Deck', back_populates='all_draw_messages') 131 | element_id = Column(Integer, ForeignKey('elements.id')) 132 | element = relationship('Element') 133 | message = Column(String) 134 | default = Column(Boolean, default=False) 135 | -------------------------------------------------------------------------------- /frangiclave/search.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Union, Dict, Any, Optional 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from frangiclave.compendium.deck import Deck, DeckDrawMessage 6 | from frangiclave.compendium.element import Element 7 | from frangiclave.compendium.ending import Ending 8 | from frangiclave.compendium.legacy import Legacy 9 | from frangiclave.compendium.recipe import Recipe 10 | from frangiclave.compendium.verb import Verb 11 | 12 | 13 | def search_compendium(session: Session, keywords: Optional[str]) -> List[Dict[str, Any]]: 14 | results = [] 15 | 16 | # Check if a set of keywords was provided 17 | if keywords is None: 18 | return results 19 | 20 | # Check if the processed string is empty 21 | search_keywords = keywords.lower().strip() 22 | if not search_keywords: 23 | return results 24 | 25 | deck_candidates = [ 26 | ( 27 | d.deck_id, 28 | d.label, 29 | d.description, 30 | *[dm.message for dm in d.all_draw_messages], 31 | d.comments 32 | ) 33 | for d in session.query(Deck).all() 34 | ] 35 | results += _find_results(search_keywords, 'deck', deck_candidates) 36 | element_candidates = session.query( 37 | Element.element_id, 38 | Element.label, 39 | Element.description, 40 | Element.uniqueness_group, 41 | Element.comments 42 | ).all() 43 | results += _find_results(search_keywords, 'element', element_candidates) 44 | ending_candidates = session.query( 45 | Ending.ending_id, 46 | Ending.description, 47 | Ending.title 48 | ).all() 49 | results += _find_results(search_keywords, 'ending', ending_candidates) 50 | legacy_candidates = session.query( 51 | Legacy.legacy_id, 52 | Legacy.label, 53 | Legacy.description, 54 | Legacy.start_description, 55 | Legacy.comments 56 | ).all() 57 | results += _find_results(search_keywords, 'legacy', legacy_candidates) 58 | recipe_candidates = session.query( 59 | Recipe.recipe_id, 60 | Recipe.label, 61 | Recipe.start_description, 62 | Recipe.description, 63 | Recipe.comments 64 | ).all() 65 | results += _find_results(search_keywords, 'recipe', recipe_candidates) 66 | verb_candidates = session.query( 67 | Verb.verb_id, 68 | Verb.label, 69 | Verb.description, 70 | Verb.comments, 71 | ).all() 72 | results += _find_results(search_keywords, 'verb', verb_candidates) 73 | return results 74 | 75 | 76 | def search_compendium_by_id(session: Session, item_id: str) -> Optional[Any]: 77 | results = [] 78 | results += session.query(Deck).filter(Deck.deck_id.contains(item_id)).all() 79 | results += session.query(Element).filter(Element.element_id.contains(item_id)).all() 80 | results += session.query(Ending).filter(Ending.ending_id.contains(item_id)).all() 81 | results += session.query(Legacy).filter(Legacy.legacy_id.contains(item_id)).all() 82 | results += session.query(Recipe).filter(Recipe.recipe_id.contains(item_id)).all() 83 | results += session.query(Verb).filter(Verb.verb_id.contains(item_id)).all() 84 | 85 | if not results: 86 | return None 87 | 88 | best_candidate = None 89 | best_candidate_id = None 90 | for result in results: 91 | if isinstance(result, Deck): 92 | result_id = result.deck_id 93 | elif isinstance(result, Element): 94 | result_id = result.element_id 95 | elif isinstance(result, Ending): 96 | result_id = result.ending_id 97 | elif isinstance(result, Legacy): 98 | result_id = result.legacy_id 99 | elif isinstance(result, Recipe): 100 | result_id = result.recipe_id 101 | elif isinstance(result, Verb): 102 | result_id = result.verb_id 103 | else: 104 | continue 105 | 106 | # Return exact matches immediately 107 | if result_id == item_id: 108 | return result 109 | 110 | # Return the closest match in terms of length otherwise 111 | if not best_candidate or len(result_id) < len(best_candidate_id): 112 | best_candidate = result 113 | best_candidate_id = result_id 114 | 115 | return best_candidate 116 | 117 | 118 | def _find_results( 119 | keywords: str, 120 | _type: str, 121 | candidates: List[Tuple[Union[int, str]]] 122 | ) -> List[Dict[str, Any]]: 123 | results = [] 124 | for candidate in candidates: 125 | matches = [] 126 | for field in candidate: 127 | if not field: 128 | continue 129 | start = field.lower().find(keywords) 130 | if start < 0: 131 | continue 132 | end = start + len(keywords) 133 | match = ( 134 | ('...' if start > 30 else '') 135 | + field[max(0, start - 30):start].lstrip() 136 | + field[start:end] 137 | + field[end:end+30].rstrip() 138 | + ('...' if len(field) - end > 30 else '') 139 | ) 140 | matches.append(match) 141 | if matches: 142 | results.append({ 143 | 'id': candidate[0], 144 | 'type': _type, 145 | 'matches': matches 146 | }) 147 | return results 148 | -------------------------------------------------------------------------------- /frangiclave/csjson.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Dict, Union, Tuple, TextIO 2 | 3 | # Left here for now; should I delete it? 4 | 5 | 6 | def load(fh: TextIO) -> Union[Dict[str, Any], List[Any]]: 7 | return loads(fh.read()) 8 | 9 | 10 | def loads(json: str) -> Union[Dict[str, Any], List[Any]]: 11 | length = len(json) 12 | num = 0 13 | num = _move_to_next_node(json, num, length) 14 | if num < length: 15 | end = _find_matching_end(json, num, length) 16 | if json[num] == '{': 17 | return _read_dictionary(json, num, end)[0] 18 | elif json[num] == '[': 19 | return _read_list(json, num, end)[0] 20 | 21 | 22 | def _move_to_next_node(json: str, begin: int, end: int) -> int: 23 | flag = False 24 | for i in range(begin, end): 25 | c = json[i] 26 | if c == '"' and i > 0 and json[i - 1] != '"': 27 | flag = not flag 28 | if not flag: 29 | if c == '{' or c == '[': 30 | return i 31 | return end 32 | 33 | 34 | def _find_matching_end(json: str, begin: int, end: int) -> int: 35 | num_braces = 0 36 | num_brackets = 0 37 | flag = False 38 | for i in range(begin, end): 39 | c = json[i] 40 | if i == 0 or json[i - 1] != '\\': 41 | if c == '"': 42 | flag = not flag 43 | elif not flag: 44 | if c == '{': 45 | num_braces += 1 46 | elif c == '[': 47 | num_brackets += 1 48 | elif c == '}': 49 | num_braces -= 1 50 | elif c == ']': 51 | num_brackets -= 1 52 | if num_braces == 0 and num_brackets == 0: 53 | return i 54 | return end 55 | 56 | 57 | def _read_dictionary( 58 | json: str, 59 | begin: int, 60 | end: int 61 | ) -> Tuple[Dict[str, Any], int]: 62 | dictionary = {} 63 | num = 1 64 | flag = False 65 | text = '\r\n\t ?"\'\\,:{}[]' 66 | text2 = r'' 67 | text3 = r'' 68 | i = begin + 1 69 | while i < end: 70 | flag2 = False 71 | c = json[i] 72 | if i == 0 or json[i - 1] != '\\': 73 | if c == '"': 74 | flag = not flag 75 | if not flag: 76 | if num != 1 and c == ',': 77 | text3 = _trim_property_value(text3) 78 | if len(text2) > 0 and text2 not in dictionary \ 79 | and len(text3) > 0: 80 | dictionary[text2] = _json_decode(text3) 81 | num = 1 82 | text2 = '' 83 | text3 = '' 84 | flag2 = True 85 | if num == 1 and c == ':': 86 | num = 2 87 | text3 = '' 88 | flag2 = True 89 | if num == 2 and c == '{': 90 | end2 = _find_matching_end(json, i, end) 91 | dictionary[text2], i = _read_dictionary(json, i, end2) 92 | text3 = '' 93 | num = 0 94 | flag2 = True 95 | if num == 2 and c == '[': 96 | end3 = _find_matching_end(json, i, end) 97 | dictionary[text2], i = _read_list(json, i, end3) 98 | text3 = '' 99 | num = 0 100 | flag2 = True 101 | if not flag2: 102 | if num == 1 and c not in text: 103 | text2 += c 104 | if num == 2: 105 | text3 += c 106 | i += 1 107 | if len(text2) > 0 and text2 not in dictionary: 108 | text3 = _trim_property_value(text3) 109 | if len(text3) > 0: 110 | dictionary[text2] = _json_decode(text3) 111 | return dictionary, i 112 | 113 | 114 | def _read_list( 115 | json: str, 116 | begin: int, 117 | end: int 118 | ) -> Tuple[List[Any], int]: 119 | _list = [] 120 | flag = False 121 | text = "" 122 | i = begin + 1 123 | while i < end: 124 | flag2 = False 125 | c = json[i] 126 | if i == 0 or json[i - 1] != '\\': 127 | if c == '"': 128 | flag = not flag 129 | if not flag: 130 | if c == '{': 131 | end2 = _find_matching_end(json, i, end) 132 | dictionary, i = _read_dictionary(json, i, end2) 133 | _list.append(dictionary) 134 | text = '' 135 | flag2 = True 136 | elif c == '[': 137 | end3 = _find_matching_end(json, i, end) 138 | dictionary, i = _read_list(json, i, end3) 139 | _list.append(dictionary) 140 | text = '' 141 | flag2 = True 142 | elif c == ',': 143 | text = _trim_property_value(text) 144 | if len(text): 145 | _list.append(_json_decode(text)) 146 | text = '' 147 | flag2 = True 148 | if not flag2: 149 | text += c 150 | i += 1 151 | text = _trim_property_value(text) 152 | if len(text) > 0: 153 | _list.append(_json_decode(text)) 154 | return _list, i 155 | 156 | 157 | def _trim_property_value(value: str) -> str: 158 | value = value.strip() 159 | if not value: 160 | result = '' 161 | else: 162 | while len(value) > 1 and value[0] == '\r'\ 163 | or value[0] == '\n' or value[0] == '\t' or value[0] == ' ': 164 | value = value[1:] 165 | while len(value) > 0 and value[-1] == '\r'\ 166 | or value[-1] == '\n' or value[-1] == '\t' or value[-1] == ' ': 167 | value = value[:-1] 168 | if len(value) >= 2 and value[0] == '"' and value[-1] == '"': 169 | result = value[1:-1] 170 | else: 171 | result = value 172 | return result 173 | 174 | 175 | def _json_decode(json_string: str) -> str: 176 | json_string = json_string.replace('\\/', '/') 177 | json_string = json_string.replace('\\n', '\n') 178 | json_string = json_string.replace('\\r', '\r') 179 | json_string = json_string.replace('\\t', '\t') 180 | json_string = json_string.replace('\\"', '"') 181 | json_string = json_string.replace('\\\\', '\\') 182 | return json_string 183 | -------------------------------------------------------------------------------- /frangiclave/server.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict, defaultdict 3 | from os.path import abspath, dirname, join 4 | 5 | from flask import Flask, render_template, abort, request 6 | from markupsafe import Markup 7 | from sqlalchemy.orm.exc import NoResultFound 8 | 9 | from frangiclave.compendium.base import get_session 10 | from frangiclave.compendium.deck import Deck 11 | from frangiclave.compendium.element import Element 12 | from frangiclave.compendium.ending import Ending 13 | from frangiclave.compendium.file import File 14 | from frangiclave.compendium.legacy import Legacy 15 | from frangiclave.compendium.recipe import Recipe 16 | from frangiclave.compendium.verb import Verb 17 | from frangiclave.game.importer import import_game_data 18 | from frangiclave.search import search_compendium 19 | 20 | ROOT_DIR = abspath(dirname(__file__)) 21 | STATIC_DIR = join(ROOT_DIR, 'static') 22 | TEMPLATE_DIR = join(ROOT_DIR, 'templates') 23 | SPRITE_PATTERN = re.compile(r'') 24 | 25 | app = Flask(__name__, template_folder=TEMPLATE_DIR) 26 | 27 | 28 | @app.route('/') 29 | def index(): 30 | return render_template('index.tpl.html') 31 | 32 | 33 | @app.route('/load/') 34 | def load(): 35 | if app.config['READ_ONLY']: 36 | abort(403) 37 | import_game_data(app.config['GAME_DIRECTORY']) 38 | return 'Game data loaded.' 39 | 40 | 41 | @app.route('/save/') 42 | def save(): 43 | if app.config['READ_ONLY']: 44 | abort(403) 45 | return 'Game data saved.' 46 | 47 | 48 | @app.route('/file_add/', methods=['POST']) 49 | def file_add(): 50 | pass 51 | 52 | 53 | @app.route('/file_rename/', methods=['POST']) 54 | def file_edit(): 55 | pass 56 | 57 | 58 | @app.route('/file_delete/', methods=['POST']) 59 | def file_delete(): 60 | pass 61 | 62 | 63 | @app.route('/search/') 64 | def search(): 65 | keywords = request.args.get('keywords', '') 66 | with get_session() as session: 67 | results = search_compendium(session, keywords) 68 | return render_template( 69 | 'search.tpl.html', 70 | keywords=keywords, 71 | results=results 72 | ) 73 | 74 | 75 | @app.route('/deck//') 76 | def deck(deck_id: str): 77 | with get_session() as session: 78 | return render_template( 79 | 'deck.tpl.html', 80 | deck=Deck.get_by_deck_id(session, deck_id), 81 | show_decks=True 82 | ) 83 | 84 | 85 | @app.route('/element//') 86 | def element(element_id: str): 87 | with get_session() as session: 88 | return render_template( 89 | 'element.tpl.html', 90 | element=Element.get_by_element_id(session, element_id), 91 | show_elements=True 92 | ) 93 | 94 | 95 | @app.route('/ending//') 96 | def ending(ending_id: str): 97 | with get_session() as session: 98 | return render_template( 99 | 'ending.tpl.html', 100 | ending=Ending.get_by_ending_id(session, ending_id), 101 | show_endings=True 102 | ) 103 | 104 | 105 | @app.route('/legacy//') 106 | def legacy(legacy_id: str): 107 | with get_session() as session: 108 | return render_template( 109 | 'legacy.tpl.html', 110 | legacy=Legacy.get_by_legacy_id(session, legacy_id), 111 | show_legacies=True 112 | ) 113 | 114 | 115 | @app.route('/recipe//') 116 | def recipe(recipe_id: str): 117 | with get_session() as session: 118 | return render_template( 119 | 'recipe.tpl.html', 120 | recipe=Recipe.get_by_recipe_id(session, recipe_id), 121 | show_recipes=True 122 | ) 123 | 124 | 125 | @app.route('/verb//') 126 | def verb(verb_id: str): 127 | with get_session() as session: 128 | return render_template( 129 | 'verb.tpl.html', 130 | verb=Verb.get_by_verb_id(session, verb_id), 131 | show_verbs=True 132 | ) 133 | 134 | 135 | @app.errorhandler(NoResultFound) 136 | def handle_invalid_usage(error): 137 | return page_not_found(error) 138 | 139 | 140 | @app.errorhandler(404) 141 | def page_not_found(error): 142 | return render_template('404.tpl.html'), 404 143 | 144 | 145 | @app.context_processor 146 | def add_global_variables(): 147 | with get_session() as session: 148 | file_list = session.query(File).order_by(File.name).all() 149 | files = defaultdict(lambda: OrderedDict()) 150 | decks = ( 151 | session.query(Deck.deck_id, Deck.file_id) 152 | .order_by(Deck.deck_id) 153 | .all() 154 | ) 155 | elements = ( 156 | session.query(Element.element_id, Element.file_id) 157 | .order_by(Element.element_id) 158 | .all() 159 | ) 160 | endings = [ 161 | e for e, in session 162 | .query(Ending.ending_id) 163 | .order_by(Ending.ending_id) 164 | .all() 165 | ] 166 | legacies = ( 167 | session.query(Legacy.legacy_id, Legacy.file_id) 168 | .order_by(Legacy.legacy_id) 169 | .all() 170 | ) 171 | recipes = ( 172 | session.query(Recipe.recipe_id, Recipe.file_id) 173 | .order_by(Recipe.recipe_id) 174 | .all() 175 | ) 176 | verbs = ( 177 | session.query(Verb.verb_id, Verb.file_id) 178 | .order_by(Verb.verb_id) 179 | .all() 180 | ) 181 | items = decks + elements + legacies + recipes + verbs 182 | for file in file_list: 183 | files[file.category.value][file] = [ 184 | item_id for item_id, file_id in items if file_id == file.id 185 | ] 186 | session.expunge_all() 187 | return dict( 188 | base_url=app.config['BASE_URL'], 189 | path=request.path, 190 | files=files, 191 | endings=endings, 192 | read_only=app.config['READ_ONLY'], 193 | decks_open=False, 194 | elements_open=False, 195 | endings_open=False, 196 | legacies_open=False, 197 | recipes_open=False, 198 | verbs_open=False 199 | ) 200 | 201 | 202 | @app.template_filter('sprite_replace') 203 | def sprite_replace(text): 204 | return Markup(SPRITE_PATTERN.sub( 205 | lambda m: r'', 210 | text)) 211 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## creative commons 2 | 3 | # CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. 6 | 7 | ### Statement of Purpose 8 | 9 | The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). 10 | 11 | Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. 12 | 13 | For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 14 | 15 | 1. __Copyright and Related Rights.__ A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: 16 | 17 | i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; 18 | 19 | ii. moral rights retained by the original author(s) and/or performer(s); 20 | 21 | iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; 22 | 23 | iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; 24 | 25 | v. rights protecting the extraction, dissemination, use and reuse of data in a Work; 26 | 27 | vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and 28 | 29 | vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 30 | 31 | 2. __Waiver.__ To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 32 | 33 | 3. __Public License Fallback.__ Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 34 | 35 | 4. __Limitations and Disclaimers.__ 36 | 37 | a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. 38 | 39 | b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. 40 | 41 | c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. 42 | 43 | d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. 44 | -------------------------------------------------------------------------------- /frangiclave/static/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | } 3 | 4 | body { 5 | background: #ded1b4 url('img/felt.png'); 6 | color: #233844; 7 | display: flex; 8 | flex-flow: column; 9 | font-family: Lato, Verdana, Arial, sans-serif; 10 | font-size: 1em; 11 | margin: 0; 12 | padding: 0; 13 | } 14 | 15 | header { 16 | background: #b956f4 url('img/felt.png'); 17 | flex: 0 1 auto; 18 | font-family: 'Forum', Lato, Verdana, Arial, sans-serif; 19 | font-size: 2em; 20 | padding: 0.4em 1.2em calc(0.4em + 3px); 21 | } 22 | 23 | header a { 24 | text-decoration: none; 25 | } 26 | 27 | #search-box { 28 | display: block; 29 | float: right; 30 | margin: 0.2em; 31 | text-align: right; 32 | } 33 | 34 | #search-text, #search-submit { 35 | border: none; 36 | box-sizing: content-box; 37 | display: inline-block; 38 | font-family: 'Forum', Lato, Verdana, Arial, sans-serif; 39 | font-size: 0.9em; 40 | height: 1em; 41 | line-height: 1; 42 | margin: 0.5em 0; 43 | outline: 0; 44 | padding: 0.2em; 45 | vertical-align: top; 46 | } 47 | #search-text { 48 | background: rgba(35, 35, 35, 0.5); 49 | border-top-left-radius: 3px; 50 | border-bottom-left-radius: 3px; 51 | box-shadow: inset 0 0 5px #4d1b6b; 52 | color: #ffffff; 53 | transition: background-color 1s; 54 | width: 300px; 55 | } 56 | 57 | #search-text:focus { 58 | background: rgba(75, 75, 75, 0.5); 59 | } 60 | 61 | #search-submit { 62 | background: linear-gradient(#924cb7, #603888); 63 | border-top-right-radius: 3px; 64 | border-bottom-right-radius: 3px; 65 | color: #ece0ec; 66 | cursor: pointer; 67 | font-weight: bold; 68 | letter-spacing: 1px; 69 | padding: 0.2em 0.4em; 70 | } 71 | 72 | h1 { 73 | -webkit-background-clip: text; 74 | -moz-background-clip: text; 75 | background-clip: text; 76 | background-color: #331544; 77 | color: transparent; 78 | display: inline; 79 | font-size: 2em; 80 | font-weight: bold; 81 | letter-spacing: 1px; 82 | margin: 0; 83 | padding: 0; 84 | text-shadow: 2px 2px 3px rgba(255,255,255,0.1); 85 | } 86 | 87 | #header-image { 88 | height: 1.8em; 89 | margin-right: 0.5em; 90 | vertical-align: text-bottom; 91 | width: 1.8em; 92 | } 93 | 94 | #container { 95 | display: flex; 96 | flex-flow: row; 97 | flex: 1 1 auto; 98 | } 99 | 100 | #sidebar { 101 | background: #b956f4 url('img/felt.png'); 102 | padding: 0; 103 | width: 300px; 104 | } 105 | 106 | #actions { 107 | background-color: #e2d0e4; 108 | box-shadow: inset 0 -4px 10px #cdafce; 109 | padding: 1em 0; 110 | text-align: center; 111 | } 112 | 113 | .action { 114 | cursor: pointer; 115 | display: inline-block; 116 | margin: 0 1em; 117 | padding: 0.4em 1em; 118 | } 119 | 120 | #sections { 121 | background: linear-gradient(to right, #decede, #ffffff); 122 | border: solid 1px #594a5a; 123 | border-radius: 3px; 124 | box-shadow: 0 0 10px #906da5; 125 | margin-bottom: 10px; 126 | margin-left: 10px; 127 | overflow: hidden; 128 | position: relative; 129 | z-index: 1; 130 | } 131 | 132 | .section-title:first-of-type { 133 | border-top: none; 134 | } 135 | 136 | .section-title { 137 | background: linear-gradient(to right, #937398, #ab80ab); 138 | border-top: solid 1px #594a5a; 139 | color: white; 140 | cursor: pointer; 141 | font-size: 1.3em; 142 | font-weight: bold; 143 | overflow-x: auto; 144 | padding: 0.6em; 145 | } 146 | 147 | .section-list { 148 | display: block; 149 | max-height: 600px; 150 | overflow-y: scroll; 151 | } 152 | 153 | .section-file { 154 | margin-bottom: 1em; 155 | } 156 | 157 | .section-file-title { 158 | color: #696969; 159 | font-size: 0.8em; 160 | font-style: italic; 161 | padding: 0.3em; 162 | word-break: break-all; 163 | } 164 | 165 | .section-file-actions { 166 | float: right; 167 | } 168 | 169 | .section-file-actions svg { 170 | cursor: pointer; 171 | display: inline-block; 172 | margin: 0 0.5em; 173 | } 174 | 175 | .section-file-actions svg:hover { 176 | color: #430043; 177 | } 178 | 179 | .section-item { 180 | color: #233844; 181 | display: block; 182 | font-size: 0.9em; 183 | padding: 0.2em 0.2em 0.2em 0.8em; 184 | text-decoration: none; 185 | word-break: break-all; 186 | } 187 | 188 | .section-item:hover { 189 | background-color: #ffffff; 190 | cursor: pointer; 191 | } 192 | 193 | #section-item-active { 194 | background-color: #cfb8d0; 195 | } 196 | 197 | #content { 198 | background-color: #ffffff; 199 | box-shadow: 0 0 10px 8px #ffffff; 200 | flex: 1 1 0; 201 | padding: 1em; 202 | } 203 | 204 | #content-title { 205 | border-bottom: solid 1px #6d556f; 206 | font-weight: bold; 207 | margin: 0; 208 | padding: 0 0 0.2em; 209 | word-break: break-all; 210 | } 211 | 212 | #content-title-prefix { 213 | color: #6d556f; 214 | font-weight: normal; 215 | } 216 | 217 | #content-actions { 218 | float: right; 219 | } 220 | 221 | .content-action { 222 | margin-left: 1em; 223 | } 224 | 225 | footer { 226 | box-shadow: inset 0 8px 10px #a7a99d; 227 | color: #ffffff; 228 | font-size: 0.8em; 229 | font-style: italic; 230 | padding: 1.6em; 231 | text-align: center; 232 | text-shadow: 0 0 15px #000000; 233 | } 234 | 235 | /* Content Styling */ 236 | #content a { 237 | color: #c56dd8; 238 | text-decoration: none; 239 | } 240 | 241 | #content a:hover { 242 | color: #7a637b; 243 | } 244 | 245 | #content .aspect { 246 | background-color: #4e8fc3; 247 | color: #dbe5e6; 248 | display: inline-block; 249 | margin: 0.2em 0.5em; 250 | padding-left: 10px; 251 | height: 30px; 252 | text-indent: 0; 253 | vertical-align: top; 254 | } 255 | 256 | #content .aspect:hover { 257 | background-color: #9a82cd; 258 | color: #dbe5e6; 259 | } 260 | 261 | #content .aspect-text { 262 | display: inline-block; 263 | font-size: 18px; 264 | padding: 3px 0; 265 | vertical-align: top; 266 | } 267 | 268 | #content .aspect img { 269 | margin-left: 10px; 270 | } 271 | 272 | #content .challenge img { 273 | height: 1em; 274 | margin-left: 5px; 275 | vertical-align: middle; 276 | width: 1em; 277 | } 278 | 279 | #content h3 { 280 | border-left: solid 0.2em #866a86; 281 | border-top: solid 0.1em #866a86; 282 | margin: 0.1em 0; 283 | padding: 0.2em 0.3em; 284 | } 285 | 286 | #content .multiline { 287 | white-space: pre-wrap; 288 | } 289 | 290 | #content .multiline img{ 291 | height: 1em; 292 | vertical-align: middle; 293 | width: 1em; 294 | } 295 | 296 | #content ul { 297 | list-style: none; 298 | padding: 0 1em; 299 | margin: 0; 300 | } 301 | 302 | #content li { 303 | padding-left: 1em; 304 | padding-bottom: 0.2em; 305 | text-indent: -1em; 306 | } 307 | 308 | #content li::before { 309 | color: #ad96ad; 310 | content: "▪ "; 311 | padding-right: 0.5em; 312 | } 313 | 314 | #content .content-image { 315 | float: right; 316 | margin-bottom: 1em; 317 | margin-left: 1em; 318 | margin-top: 1em; 319 | } 320 | 321 | /* Search results */ 322 | #content .search-result { 323 | background-color: #fffaff; 324 | border-left: solid 0.2em #866a86; 325 | margin: 1em 0; 326 | } 327 | 328 | #content .search-result-title { 329 | border: none; 330 | } 331 | -------------------------------------------------------------------------------- /frangiclave/compendium/element.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, Optional, List, Tuple 2 | 3 | from sqlalchemy import Column, Integer, String, Float, ForeignKey, Boolean 4 | from sqlalchemy.ext.hybrid import hybrid_property 5 | from sqlalchemy.orm import relationship 6 | 7 | from frangiclave.compendium.base import Base, Session 8 | from frangiclave.compendium.file import File 9 | from frangiclave.compendium.game_content import GameContentMixin, GameContents 10 | from frangiclave.compendium.linked_recipe_details import LinkedRecipeDetails, \ 11 | LinkedRecipeChallengeRequirement 12 | from frangiclave.compendium.slot_specification import SlotSpecification 13 | from frangiclave.compendium.utils import to_bool, get 14 | 15 | if TYPE_CHECKING: 16 | from frangiclave.compendium.deck import Deck, DeckCard 17 | from frangiclave.compendium.recipe import RecipeRequirement, Recipe, \ 18 | RecipeEffect 19 | 20 | 21 | class ElementAspect(Base): 22 | __tablename__ = 'elements_aspects' 23 | 24 | id = Column(Integer, primary_key=True) 25 | 26 | element_id: int = Column(Integer, ForeignKey('elements.id')) 27 | element: 'Element' = relationship( 28 | 'Element', back_populates='aspects', foreign_keys=element_id 29 | ) 30 | aspect_id: int = Column(Integer, ForeignKey('elements.id')) 31 | aspect: 'Element' = relationship( 32 | 'Element', back_populates='_aspect_for', foreign_keys=aspect_id 33 | ) 34 | quantity: int = Column(Integer) 35 | 36 | 37 | class ElementXTrigger(Base): 38 | __tablename__ = 'elements_x_triggers' 39 | 40 | id = Column(Integer, primary_key=True) 41 | 42 | element_id: int = Column(Integer, ForeignKey('elements.id')) 43 | element: 'Element' = relationship( 44 | 'Element', back_populates='x_triggers', foreign_keys=element_id 45 | ) 46 | trigger_id: int = Column(Integer, ForeignKey('elements.id')) 47 | trigger: 'Element' = relationship( 48 | 'Element', back_populates='triggered_with', foreign_keys=trigger_id 49 | ) 50 | result_id: int = Column(Integer, ForeignKey('elements.id')) 51 | result: 'Element' = relationship( 52 | 'Element', back_populates='triggered_by', foreign_keys=result_id 53 | ) 54 | morph_effect: Optional[str] = Column(String, nullable=True) 55 | morph_effect_level: Optional[int] = Column(Integer) 56 | morph_effect_chance: Optional[int] = Column(Integer) 57 | 58 | 59 | class Element(Base, GameContentMixin): 60 | __tablename__ = 'elements' 61 | 62 | id = Column(Integer, primary_key=True) 63 | element_id: str = Column(String, unique=True) 64 | 65 | label: Optional[str] = Column(String, nullable=True) 66 | description: Optional[str] = Column(String, nullable=True) 67 | animation_frames: int = Column(Integer, default=0) 68 | icon: Optional[str] = Column(String, nullable=True) 69 | lifetime: Optional[float] = Column(Float, nullable=True) 70 | decay_to_id: Optional[int] = Column(Integer, ForeignKey('elements.id')) 71 | decay_to = relationship( 72 | 'Element', 73 | back_populates='decay_from', 74 | remote_side=id 75 | ) 76 | decay_from = relationship( 77 | 'Element', 78 | back_populates='decay_to' 79 | ) 80 | is_aspect: bool = Column(Boolean) 81 | unique: bool = Column(Boolean) 82 | uniqueness_group: bool = Column(String, nullable=True) 83 | aspects: List[ElementAspect] = relationship( 84 | ElementAspect, 85 | back_populates='element', 86 | foreign_keys=ElementAspect.element_id 87 | ) 88 | _aspect_for: List[ElementAspect] = relationship( 89 | ElementAspect, 90 | back_populates='aspect', 91 | foreign_keys=ElementAspect.aspect_id 92 | ) 93 | induces: List['ElementLinkedRecipeDetails'] = relationship( 94 | 'ElementLinkedRecipeDetails', back_populates='element' 95 | ) 96 | child_slots: List['ElementSlotSpecification'] = relationship( 97 | 'ElementSlotSpecification', back_populates='element' 98 | ) 99 | x_triggers: List[ElementXTrigger] = relationship( 100 | ElementXTrigger, 101 | back_populates='element', 102 | foreign_keys=ElementXTrigger.element_id 103 | ) 104 | triggered_with: List[ElementXTrigger] = relationship( 105 | ElementXTrigger, 106 | back_populates='trigger', 107 | foreign_keys=ElementXTrigger.trigger_id 108 | ) 109 | triggered_by: List[ElementXTrigger] = relationship( 110 | ElementXTrigger, 111 | back_populates='result', 112 | foreign_keys=ElementXTrigger.result_id 113 | ) 114 | is_hidden: bool = Column(Boolean) 115 | no_art_needed: bool = Column(Boolean) 116 | resaturate: bool = Column(Boolean) 117 | verb_icon: str = Column(String) 118 | _in_decks: List['DeckCard'] = relationship( 119 | 'DeckCard', back_populates='element' 120 | ) 121 | in_decks_default: List['Deck'] = relationship( 122 | 'Deck', back_populates='default_card' 123 | ) 124 | _requirement_for_recipes: List['RecipeRequirement'] = relationship( 125 | 'RecipeRequirement', back_populates='element' 126 | ) 127 | _effect_of_recipes: List['RecipeEffect'] = relationship( 128 | 'RecipeEffect', back_populates='element' 129 | ) 130 | comments: Optional[str] = Column(String, nullable=True) 131 | 132 | @hybrid_property 133 | def aspect_for(self) -> Tuple['Element']: 134 | return tuple(sorted( 135 | set(ea.element for ea in self._aspect_for), 136 | key=lambda e: e.element_id 137 | )) 138 | 139 | @hybrid_property 140 | def in_decks(self) -> Tuple['Deck']: 141 | return tuple(sorted( 142 | list(dc.deck for dc in self._in_decks) + self.in_decks_default, 143 | key=lambda deck: deck.deck_id 144 | )) 145 | 146 | @hybrid_property 147 | def requirement_for_recipes(self) -> Tuple['Recipe']: 148 | return tuple(sorted( 149 | (rr.recipe for rr in self._requirement_for_recipes), 150 | key=lambda recipe: recipe.recipe_id 151 | )) 152 | 153 | @hybrid_property 154 | def effect_of_recipes(self) -> Tuple['Recipe']: 155 | return tuple(sorted( 156 | (rr.recipe for rr in self._effect_of_recipes), 157 | key=lambda recipe: recipe.recipe_id 158 | )) 159 | 160 | @classmethod 161 | def from_data( 162 | cls, 163 | file: File, 164 | data: Dict[str, Any], 165 | translations: Dict[str, Dict[str, Any]], 166 | game_contents: GameContents 167 | ) -> 'Element': 168 | e = game_contents.get_element(data['id']) 169 | e.file = file 170 | e.label = get(data, 'label', translations=translations) 171 | e.description = get(data, 'description', translations=translations) 172 | e.animation_frames = get(data, 'animFrames', 0, int) 173 | e.icon = get(data, 'icon') 174 | e.lifetime = get(data, 'lifetime', 0.0, float) 175 | e.decay_to = game_contents.get_element(get(data, 'decayTo', None)) 176 | e.is_aspect = get(data, 'isAspect', False, to_bool) 177 | e.unique = get(data, 'unique', False, to_bool) 178 | e.uniqueness_group = get(data, 'uniquenessgroup') 179 | e.aspects = [ 180 | ElementAspect( 181 | element=e, 182 | aspect=game_contents.get_element(aspect_id), 183 | quantity=int(quantity) 184 | ) for aspect_id, quantity in get(data, 'aspects', {}).items() 185 | ] 186 | e.induces = [ 187 | ElementLinkedRecipeDetails.from_data( 188 | v, 189 | ElementLinkedRecipeDetailsChallengeRequirement, 190 | game_contents 191 | ) 192 | for v in get(data, 'induces', []) 193 | ] 194 | e.child_slots = [ 195 | ElementSlotSpecification.from_data(v, { 196 | c: c_transformation["slots"][i] 197 | for c, c_transformation in translations.items() 198 | if "slots" in c_transformation 199 | }, game_contents) 200 | for i, v in enumerate(get(data, 'slots', [])) 201 | ] 202 | for trigger_id, result in get(data, 'xtriggers', {}).items(): 203 | if isinstance(result, str): 204 | e.x_triggers.append(ElementXTrigger( 205 | trigger=game_contents.get_element(trigger_id), 206 | result=game_contents.get_element(result) 207 | )) 208 | else: 209 | for x_trigger_def in result: 210 | e.x_triggers.append(ElementXTrigger( 211 | trigger=game_contents.get_element(trigger_id), 212 | result=game_contents.get_element(x_trigger_def['id']), 213 | morph_effect=x_trigger_def.get('morpheffect'), 214 | morph_effect_level=int(x_trigger_def['level']) if 'level' in x_trigger_def else None, 215 | morph_effect_chance=int(x_trigger_def['chance']) if 'chance' in x_trigger_def else None, 216 | )) 217 | e.is_hidden = get(data, 'isHidden', False, to_bool) 218 | e.no_art_needed = get(data, 'noartneeded', False, to_bool) 219 | e.resaturate = get(data, 'resaturate', False, to_bool) 220 | e.verb_icon = get(data, 'verbicon') 221 | e.comments = get(data, 'comments', None) 222 | return e 223 | 224 | @classmethod 225 | def get_by_element_id(cls, session: Session, element_id: str) -> 'Element': 226 | return session.query(cls).filter(cls.element_id == element_id).one() 227 | 228 | 229 | class ElementLinkedRecipeDetails(Base, LinkedRecipeDetails): 230 | __tablename__ = 'elements_linked_recipe_details' 231 | 232 | id = Column(Integer, primary_key=True) 233 | 234 | element_id: int = Column(Integer, ForeignKey(Element.id)) 235 | element: Element = relationship(Element, back_populates='induces') 236 | challenges = relationship('ElementLinkedRecipeDetailsChallengeRequirement') 237 | 238 | 239 | class ElementLinkedRecipeDetailsChallengeRequirement( 240 | Base, LinkedRecipeChallengeRequirement 241 | ): 242 | __tablename__ = 'elements_linked_recipe_details_challenge_requirement' 243 | 244 | id = Column(Integer, primary_key=True) 245 | 246 | linked_recipe_details_id: int = Column( 247 | Integer, ForeignKey(ElementLinkedRecipeDetails.id)) 248 | linked_recipe_details: ElementLinkedRecipeDetails = \ 249 | relationship( 250 | ElementLinkedRecipeDetails, 251 | back_populates='challenges', 252 | foreign_keys=linked_recipe_details_id 253 | ) 254 | 255 | 256 | class ElementSlotSpecification(Base, SlotSpecification): 257 | __tablename__ = 'elements_slot_specifications' 258 | 259 | id = Column(Integer, primary_key=True) 260 | 261 | element_id: int = Column(Integer, ForeignKey(Element.id)) 262 | element: Element = relationship(Element, back_populates='child_slots') 263 | -------------------------------------------------------------------------------- /frangiclave/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Union, Dict, Any, Optional 2 | 3 | from collections import OrderedDict, defaultdict 4 | 5 | from pathlib import Path 6 | import platform 7 | 8 | import json 9 | import toml 10 | 11 | from sqlalchemy.orm import Session 12 | 13 | from frangiclave.compendium.base import get_session 14 | from frangiclave.compendium.deck import Deck, DeckDrawMessage 15 | from frangiclave.compendium.element import Element 16 | from frangiclave.compendium.legacy import Legacy 17 | from frangiclave.compendium.recipe import Recipe 18 | from frangiclave.compendium.verb import Verb 19 | from frangiclave.compendium.file import File 20 | from frangiclave.compendium.slot_specification import SlotSpecification 21 | from frangiclave.server import app 22 | from frangiclave.main import CONFIG_FILE_NAME, load_config 23 | 24 | config = load_config(CONFIG_FILE_NAME) 25 | 26 | DATA_DIR = ( 27 | 'cultistsimulator_Data' if platform.system() == 'Windows' else 'CS_Data' 28 | ) 29 | 30 | 31 | def export_compendium(session: Session) -> Any: 32 | file_list = session.query(File).order_by(File.name).all() 33 | files = defaultdict(lambda: OrderedDict()) 34 | decks = ( 35 | session.query(Deck) 36 | .order_by(Deck.deck_id) 37 | .all() 38 | ) 39 | elements = ( 40 | session.query(Element) 41 | .order_by(Element.element_id) 42 | .all() 43 | ) 44 | legacies = ( 45 | session.query(Legacy) 46 | .order_by(Legacy.legacy_id) 47 | .all() 48 | ) 49 | recipes = ( 50 | session.query(Recipe) 51 | .order_by(Recipe.recipe_id) 52 | .all() 53 | ) 54 | verbs = ( 55 | session.query(Verb) 56 | .order_by(Verb.verb_id) 57 | .all() 58 | ) 59 | items = decks + elements + legacies + recipes + verbs 60 | for file in file_list: 61 | files[file.category.value][file] = [ 62 | item for item in items if item.file_id == file.id 63 | ] 64 | for category in files: 65 | for file in files[category]: 66 | print("exporting " + category + " " + file.name) 67 | content = {} 68 | objs = [] 69 | for item in files[category][file]: 70 | objs += [dict_one_item(item, category)] 71 | content[category] = objs 72 | 73 | output_string = json.dumps(content, indent=4) 74 | game_dir_base = Path(config["frangiclave"]["GAME_DIRECTORY"]) 75 | game_dir_cont = game_dir_base/DATA_DIR/'StreamingAssets'/'content' 76 | with (game_dir_cont/file.group.value/file.category).open("w") as f: 77 | f.write(output_string) 78 | 79 | 80 | def dict_one_item(item, category): 81 | if category == "recipes": 82 | return dict_one_recipe(item) 83 | elif category == "verbs": 84 | return dict_one_verb(item) 85 | elif category == "decks": 86 | return dict_one_deck(item) 87 | elif category == "legacies": 88 | return dict_one_legacy(item) 89 | elif category == "elements": 90 | return dict_one_element(item) 91 | else: 92 | print("Wrong category") 93 | return None 94 | 95 | def dict_one_element(elem): 96 | content = {} 97 | content["id"] = elem.element_id 98 | if (elem.label): 99 | content["label"] = elem.label 100 | if (elem.description): 101 | content["description"] = elem.description 102 | if (elem.aspects): 103 | aspects = {} 104 | for aspect in elem.aspects: 105 | aspects[aspect.aspect.element_id] = aspect.quantity 106 | content["aspects"] = aspects 107 | if (elem.animation_frames != 0): 108 | content["animFrames"] = elem.animation_frames 109 | if (elem.icon): 110 | content["icon"] = elem.icon 111 | if (elem.lifetime != 0): 112 | content["lifetime"] = elem.lifetime 113 | if (elem.unique): 114 | content["unique"] = elem.unique 115 | if (elem.decay_to): 116 | content["decayTo"] = elem.decay_to.element_id 117 | if (elem.is_aspect): 118 | content["isAspect"] = "true" 119 | if (elem.x_triggers): 120 | xtriggers = {} 121 | for xtrigger in elem.x_triggers: 122 | xtriggers[xtrigger.trigger.element_id] = xtrigger.result.element_id 123 | content["xtriggers"] = xtriggers 124 | if (elem.child_slots): 125 | slots = [] 126 | for slot in elem.child_slots: 127 | slots += [dict_slot_specification(slot)] 128 | content["slots"] = slots 129 | if (elem.no_art_needed): 130 | content["noartneeded"] = "true" 131 | if (elem.comments): 132 | content["comments"] = elem.comments 133 | if (elem.induces): 134 | induces = [] 135 | for item in elem.induces: 136 | induction = {} 137 | induction["id"] = item.recipe.recipe_id 138 | induction["chance"] = item.chance 139 | induces += [induction] 140 | content["induces"] = induces 141 | return content 142 | 143 | def dict_one_recipe(recipe): 144 | content = {} 145 | content["id"] = recipe.recipe_id 146 | if (recipe.label): 147 | content["label"] = recipe.label 148 | content["actionId"] = recipe.action.verb_id 149 | requirements = {} 150 | for req in recipe.requirements: 151 | requirements[req.element.element_id] = req.quantity 152 | content["requirements"] = requirements 153 | if (recipe.slot_specifications): 154 | slots = [] 155 | for slot in recipe.slot_specifications: 156 | slots += [dict_slot_specification(slot)] 157 | content["slots"] = slots 158 | effects = {} 159 | for effect in recipe.effects: 160 | effects[effect.element.element_id] = effect.quantity 161 | content["effects"] = effects 162 | if (recipe.aspects): 163 | aspects = {} 164 | for aspect in recipe.aspects: 165 | aspects[aspect.element.element_id] = aspect.quantity 166 | content["aspects"] = aspects 167 | if (recipe.mutation_effects): 168 | mutations = [] 169 | for mutation in recipe.mutation_effects: 170 | mutations += [dict_mutation_effect(mutation)] 171 | content["mutations"] = mutations 172 | content["hintonly"] = recipe.hint_only 173 | content["craftable"] = recipe.craftable 174 | if (recipe.start_description): 175 | content["startdescription"] = recipe.start_description 176 | content["description"] = recipe.description 177 | if (recipe.alternative_recipes): 178 | alternative_recipes = [] 179 | for item in recipe.alternative_recipes: 180 | item_dict = {} 181 | item_dict["id"] = item.recipe.recipe_id 182 | item_dict["chance"] = item.chance 183 | item_dict["additional"] = item.additional 184 | alternative_recipes += [item_dict] 185 | content["alternativerecipes"] = alternative_recipes 186 | if (recipe.alternative_recipes): 187 | linked = [] 188 | for item in recipe.linked_recipes: 189 | item_dict = {} 190 | item_dict["id"] = item.recipe.recipe_id 191 | item_dict["chance"] = item.chance 192 | item_dict["additional"] = item.additional 193 | linked += [item_dict] 194 | content["linked"] = linked 195 | content["warmup"] = recipe.warmup 196 | if (recipe.deck_effect): 197 | deck_effects = {} 198 | for effect in recipe.deck_effect: 199 | deck_effects[effect.deck.deck_id] = effect.quantity 200 | content["deckeffect"] = deck_effects 201 | if (recipe.signal_ending_flavour.value != "none"): 202 | content["signalEndingFlavour"] = recipe.signal_ending_flavour.value 203 | if (recipe.ending_flag): 204 | content["ending"] = recipe.ending_flag 205 | if (recipe.max_executions): 206 | content["maxexecutions"] = recipe.max_executions 207 | if (recipe.burn_image): 208 | content["burnimage"] = recipe.burn_image 209 | if (recipe.portal_effect.value != "none"): 210 | content["portaleffect"] = recipe.portal_effect.value 211 | content["signalimportantloop"] = recipe.signal_important_loop 212 | if (recipe.comments): 213 | content["comments"] = recipe.comments 214 | return content 215 | 216 | def dict_one_verb(verb): 217 | content = {} 218 | content["id"] = verb.verb_id 219 | content["label"] = verb.label 220 | content["description"] = verb.description 221 | content["atStart"] = verb.at_start 222 | if (verb.primary_slot_specification): 223 | content["slots"] = [dict_slot_specification(verb.primary_slot_specification)] 224 | content["comments"] = verb.comments 225 | return content 226 | 227 | def dict_one_deck(deck): 228 | content = {} 229 | content["id"] = deck.deck_id 230 | cards = [] 231 | for card in deck.cards: 232 | cards += [card.element_id] 233 | content["spec"] = cards 234 | if (deck.default_card): 235 | content["defaultcard"] = deck.default_card.element_id 236 | content["resetonexhaustion"] = deck.reset_on_exhaustion 237 | draw_messages = {} 238 | default_messages = {} 239 | for draw_message in deck.all_draw_messages: 240 | if (not draw_message.default): 241 | draw_messages[draw_message.element.element_id] = draw_message.message 242 | else: 243 | default_messages[draw_message.element.element_id] = draw_message.message 244 | content["drawmessages"] = draw_messages 245 | content["defaultdrawmessages"] = default_messages 246 | content["comments"] = deck.comments 247 | return content 248 | 249 | def dict_one_legacy(legacy): 250 | content = {} 251 | content["id"] = legacy.id 252 | content["label"] = legacy.label 253 | content["description"] = legacy.description 254 | content["startdescription"] = legacy.start_description 255 | effects = {} 256 | for effect in legacy.effects: 257 | effects[effect.element.element_id] = effect.quantity 258 | content["effects"] = effects 259 | content["image"] = legacy.image 260 | content["fromEnding"] = legacy.from_ending 261 | content["availableWithoutEndingMatch"] = legacy.available_without_ending_match 262 | content["comments"] = legacy.comments 263 | return content 264 | 265 | def dict_slot_specification(slot): 266 | content = {} 267 | content["id"] = slot.label 268 | if (slot.for_verb): 269 | content["actionId"] = slot.for_verb.verb_id 270 | if (slot.required): 271 | required = {} 272 | for item in slot.required: 273 | required[item.element.element_id] = item.quantity 274 | content["required"] = required 275 | if (slot.forbidden): 276 | forbidden = {} 277 | for item in slot.forbidden: 278 | forbidden[item.element.element_id] = item.quantity 279 | content["forbidden"] = forbidden 280 | content["description"] = slot.description 281 | content["greedy"] = slot.greedy 282 | content["consumes"] = slot.consumes 283 | content["noanim"] = slot.no_animation 284 | return content 285 | 286 | def dict_mutation_effect(mutation): 287 | content = {} 288 | content["filterOnAspectId"] = mutation.filter_on_aspect.element_id 289 | content["mutateAspectId"] = mutation.mutate_aspect.element_id 290 | content["mutationLevel"] = mutation.mutation_level 291 | content["additive"] = mutation.additive 292 | return content 293 | 294 | with get_session() as session: 295 | bo = export_compendium(session) 296 | print(bo) -------------------------------------------------------------------------------- /frangiclave/compendium/recipe.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | from typing import TYPE_CHECKING, Any, Dict, List, Optional 4 | 5 | from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, \ 6 | Enum as EnumType 7 | from sqlalchemy.ext.declarative import declared_attr 8 | from sqlalchemy.orm import relationship 9 | 10 | from frangiclave.compendium.base import Base, Session 11 | from frangiclave.compendium.deck import Deck 12 | from frangiclave.compendium.ending_flavour import EndingFlavour 13 | from frangiclave.compendium.file import File 14 | from frangiclave.compendium.game_content import GameContentMixin, GameContents 15 | from frangiclave.compendium.linked_recipe_details import LinkedRecipeDetails, \ 16 | LinkedRecipeChallengeRequirement 17 | from frangiclave.compendium.slot_specification import SlotSpecification 18 | from frangiclave.compendium.utils import to_bool, get 19 | 20 | 21 | if TYPE_CHECKING: 22 | from frangiclave.compendium.element import Element 23 | 24 | 25 | class PortalEffect(Enum): 26 | NONE = 'none' 27 | WOOD = 'wood' 28 | WHITEDOOR = 'whitedoor' 29 | STAGDOOR = 'stagdoor' 30 | SPIDERDOOR = 'spiderdoor' 31 | PEACOCKDOOR = 'peacockdoor' 32 | TRICUSPIDGATE = 'tricuspidgate' 33 | 34 | 35 | class MutationEffect(Base): 36 | __tablename__ = 'mutation_effects' 37 | 38 | id = Column(Integer, primary_key=True) 39 | recipe_id = Column(Integer, ForeignKey('recipes.id')) 40 | recipe = relationship('Recipe', back_populates='mutation_effects') 41 | filter_on_aspect_id = Column(Integer, ForeignKey('elements.id')) 42 | filter_on_aspect = relationship( 43 | 'Element', foreign_keys=filter_on_aspect_id 44 | ) 45 | mutate_aspect_id = Column(Integer, ForeignKey('elements.id')) 46 | mutate_aspect = relationship('Element', foreign_keys=mutate_aspect_id) 47 | mutation_level = Column(Integer) 48 | additive = Column(Boolean) 49 | 50 | @classmethod 51 | def from_data(cls, val: List[Dict[str, str]], game_contents: GameContents): 52 | return [ 53 | MutationEffect( 54 | filter_on_aspect=game_contents.get_element( 55 | get(v, 'filterOnAspectId') 56 | ), 57 | mutate_aspect=game_contents.get_element( 58 | get(v, 'mutateAspectId') 59 | ), 60 | mutation_level=get(v, 'mutationLevel', None, int), 61 | additive=get(v, 'additive', False, to_bool) 62 | ) 63 | for v in val 64 | ] 65 | 66 | 67 | class Recipe(Base, GameContentMixin): 68 | __tablename__ = 'recipes' 69 | 70 | id = Column(Integer, primary_key=True) 71 | recipe_id: str = Column(String, unique=True) 72 | 73 | label: str = Column(String) 74 | start_description: Optional[str] = Column(String, nullable=True) 75 | description: Optional[str] = Column(String, nullable=True) 76 | action_id: Optional[int] = Column( 77 | Integer, 78 | ForeignKey('verbs.id'), 79 | nullable=True 80 | ) 81 | action = relationship( 82 | 'Verb', 83 | foreign_keys=action_id, 84 | back_populates='recipes' 85 | ) 86 | requirements: List['RecipeRequirement'] = relationship('RecipeRequirement') 87 | table_requirements: List['RecipeTableRequirement'] = relationship( 88 | 'RecipeTableRequirement') 89 | extant_requirements: List['RecipeExtantRequirement'] = relationship( 90 | 'RecipeExtantRequirement') 91 | effects: List['RecipeEffect'] = relationship('RecipeEffect') 92 | aspects: List['RecipeAspect'] = relationship('RecipeAspect') 93 | mutation_effects: List[MutationEffect] = relationship( 94 | MutationEffect, back_populates='recipe' 95 | ) 96 | purge: List['RecipePurge'] = relationship('RecipePurge') 97 | halt_verb: List['RecipeHaltVerb'] = relationship('RecipeHaltVerb') 98 | delete_verb: List['RecipeDeleteVerb'] = relationship('RecipeDeleteVerb') 99 | signal_ending_flavour: EndingFlavour = Column( 100 | EnumType(EndingFlavour, name='ending_flavour') 101 | ) 102 | craftable: bool = Column(Boolean) 103 | hint_only: bool = Column(Boolean) 104 | warmup: int = Column(Integer) 105 | deck_effect: List['RecipeDeckEffect'] = relationship('RecipeDeckEffect') 106 | internal_deck_id: int = Column( 107 | Integer, ForeignKey('decks.id'), nullable=True 108 | ) 109 | internal_deck: Optional['Deck'] = relationship('Deck') 110 | alternative_recipes: List['RecipeAlternativeRecipeDetails'] = relationship( 111 | 'RecipeAlternativeRecipeDetails', 112 | back_populates='source_recipe', 113 | foreign_keys='RecipeAlternativeRecipeDetails.source_recipe_id' 114 | ) 115 | linked_recipes: List['RecipeLinkedRecipeDetails'] = relationship( 116 | 'RecipeLinkedRecipeDetails', 117 | back_populates='source_recipe', 118 | foreign_keys='RecipeLinkedRecipeDetails.source_recipe_id' 119 | ) 120 | from_alternative_recipes: List['RecipeAlternativeRecipeDetails'] = \ 121 | relationship( 122 | 'RecipeAlternativeRecipeDetails', 123 | foreign_keys='RecipeAlternativeRecipeDetails.recipe_id' 124 | ) 125 | from_linked_recipes: List['RecipeLinkedRecipeDetails'] = relationship( 126 | 'RecipeLinkedRecipeDetails', 127 | foreign_keys='RecipeLinkedRecipeDetails.recipe_id' 128 | ) 129 | ending_flag: Optional[str] = Column(String, nullable=True) 130 | max_executions: int = Column(Integer) 131 | burn_image: Optional[str] = Column(String, nullable=True) 132 | portal_effect: PortalEffect = Column( 133 | EnumType(PortalEffect, name='portal_effect') 134 | ) 135 | slot_specifications: List['RecipeSlotSpecification'] = relationship( 136 | 'RecipeSlotSpecification', back_populates='recipe' 137 | ) 138 | signal_important_loop: bool = Column(Boolean) 139 | comments: Optional[str] = Column(String, nullable=True) 140 | 141 | @property 142 | def from_recipes(self) -> List['Recipe']: 143 | return sorted( 144 | set( 145 | [d.source_recipe for d in self.from_alternative_recipes] 146 | + [d.source_recipe for d in self.from_linked_recipes] 147 | ), 148 | key=lambda r: r.recipe_id 149 | ) 150 | 151 | @classmethod 152 | def from_data( 153 | cls, 154 | file: File, 155 | data: Dict[str, Any], 156 | translations: Dict[str, Dict[str, Any]], 157 | game_contents: GameContents 158 | ) -> 'Recipe': 159 | r = game_contents.get_recipe(data['id']) 160 | r.file = file 161 | r.label = get(data, 'label', data['id'], translations=translations) 162 | r.start_description = get(data, 'startdescription', translations=translations) 163 | r.description = get(data, 'description', translations=translations) 164 | r.action = game_contents.get_verb(get(data, 'actionId')) 165 | r.requirements = RecipeRequirement.from_data( 166 | get(data, 'requirements', {}), game_contents 167 | ) 168 | r.table_requirements = RecipeTableRequirement.from_data( 169 | get(data, 'tablereqs', {}), game_contents 170 | ) 171 | r.extant_requirements = RecipeExtantRequirement.from_data( 172 | get(data, 'extantreqs', {}), game_contents 173 | ) 174 | r.effects = RecipeEffect.from_data( 175 | get(data, 'effects', {}), game_contents 176 | ) 177 | if 'aspects' in data: 178 | # TODO Remove this when fixed 179 | if isinstance(data['aspects'], str): 180 | logging.error('Invalid value for aspects for recipe {}'.format( 181 | data['id'] 182 | )) 183 | else: 184 | r.aspects = RecipeAspect.from_data( 185 | get(data, 'aspects', {}), game_contents 186 | ) 187 | r.mutation_effects = MutationEffect.from_data( 188 | get(data, 'mutations', []), game_contents 189 | ) 190 | r.purge = RecipePurge.from_data(get(data, 'purge', {}), game_contents) 191 | r.halt_verb = RecipeHaltVerb.from_data(get(data, 'haltverb', {}), game_contents) 192 | r.delete_verb = RecipeDeleteVerb.from_data(get(data, 'deleteverb', {}), game_contents) 193 | r.signal_ending_flavour = EndingFlavour(get( 194 | data, 'signalEndingFlavour', 'None' 195 | )) 196 | r.craftable = get(data, 'craftable', False, to_bool) 197 | r.hint_only = get(data, 'hintonly', False, to_bool) 198 | r.warmup = get(data, 'warmup', 0, int) 199 | r.deck_effect = RecipeDeckEffect.from_data( 200 | get(data, 'deckeffect', {}), game_contents 201 | ) 202 | internal_deck = get(data, 'internaldeck') 203 | if internal_deck: 204 | internal_deck['id'] = "internal:" + r.recipe_id 205 | r.internal_deck = Deck.from_data( 206 | file, internal_deck, {}, game_contents 207 | ) 208 | alternative_recipes = get(data, 'alternativerecipes', []) 209 | if not alternative_recipes: 210 | alternative_recipes = get(data, 'alt', []) 211 | r.alternative_recipes = [ 212 | RecipeAlternativeRecipeDetails.from_data( 213 | lrd, 214 | RecipeAlternativeRecipeDetailsChallengeRequirement, 215 | game_contents 216 | ) for lrd in alternative_recipes 217 | ] 218 | r.linked_recipes = [ 219 | RecipeLinkedRecipeDetails.from_data( 220 | lrd, 221 | RecipeLinkedRecipeDetailsChallengeRequirement, 222 | game_contents 223 | ) for lrd in get(data, 'linked', []) 224 | ] 225 | r.ending_flag = get(data, 'ending') 226 | r.max_executions = get(data, 'maxexecutions', 0, int) 227 | r.burn_image = get(data, 'burnimage') 228 | r.portal_effect = PortalEffect( 229 | get(data, 'portaleffect', 'none').lower() 230 | ) 231 | r.slot_specifications = [ 232 | RecipeSlotSpecification.from_data(v, { 233 | c: c_transformation["slots"][i] 234 | for c, c_transformation in translations.items() 235 | if "slots" in c_transformation 236 | }, game_contents) 237 | for i, v in enumerate(get(data, 'slots', []))] 238 | r.signal_important_loop = get( 239 | data, 'signalimportantloop', False, to_bool 240 | ) 241 | r.comments = get(data, 'comments', None) 242 | if not r.comments: 243 | r.comments = get(data, 'comment', None) 244 | return r 245 | 246 | @classmethod 247 | def get_by_recipe_id(cls, session: Session, recipe_id: str) -> 'Recipe': 248 | return session.query(cls).filter(cls.recipe_id == recipe_id).one() 249 | 250 | 251 | class ElementQuantity: 252 | 253 | id = Column(Integer, primary_key=True) 254 | 255 | @declared_attr 256 | def recipe_id(self) -> Column: 257 | return Column(Integer, ForeignKey(Recipe.id)) 258 | 259 | @declared_attr 260 | def recipe(self) -> Column: 261 | return relationship('Recipe') 262 | 263 | @declared_attr 264 | def element_id(self) -> Column: 265 | return Column(Integer, ForeignKey('elements.id')) 266 | 267 | @declared_attr 268 | def element(self) -> 'Element': 269 | return relationship('Element') 270 | 271 | quantity = Column(String) 272 | 273 | @classmethod 274 | def from_data(cls, val: Dict[str, str], game_contents: GameContents): 275 | return [ 276 | cls( 277 | element=game_contents.get_element(element_id), 278 | quantity=quantity 279 | ) 280 | for element_id, quantity in val.items() 281 | ] 282 | 283 | 284 | class DeckQuantity: 285 | 286 | id = Column(Integer, primary_key=True) 287 | 288 | @declared_attr 289 | def recipe_id(self) -> Column: 290 | return Column(Integer, ForeignKey(Recipe.id)) 291 | 292 | @declared_attr 293 | def deck_id(self) -> Column: 294 | return Column(Integer, ForeignKey('decks.id')) 295 | 296 | @declared_attr 297 | def deck(self) -> 'Deck': 298 | return relationship('Deck') 299 | 300 | quantity = Column(Integer) 301 | 302 | @classmethod 303 | def from_data(cls, val: Dict[str, str], game_contents: GameContents): 304 | return [ 305 | cls( 306 | deck=game_contents.get_deck(deck_id), 307 | quantity=int(quantity) 308 | ) 309 | for deck_id, quantity in val.items() 310 | ] 311 | 312 | 313 | class WildcardQuantity: 314 | 315 | id = Column(Integer, primary_key=True) 316 | wildcard = Column(String) 317 | quantity = Column(String) 318 | 319 | @declared_attr 320 | def recipe_id(self) -> Column: 321 | return Column(Integer, ForeignKey(Recipe.id)) 322 | 323 | @declared_attr 324 | def recipe(self) -> Column: 325 | return relationship('Recipe') 326 | 327 | @classmethod 328 | def from_data(cls, val: Dict[str, str], game_contents: GameContents): 329 | return [ 330 | cls( 331 | wildcard=wildcard, 332 | quantity=quantity 333 | ) 334 | for wildcard, quantity in val.items() 335 | ] 336 | 337 | 338 | class RecipeRequirement(Base, ElementQuantity): 339 | __tablename__ = 'recipes_requirements' 340 | 341 | 342 | class RecipeTableRequirement(Base, ElementQuantity): 343 | __tablename__ = 'recipes_table_requirements' 344 | 345 | 346 | class RecipeExtantRequirement(Base, ElementQuantity): 347 | __tablename__ = 'recipes_extant_requirements' 348 | 349 | 350 | class RecipeEffect(Base, ElementQuantity): 351 | __tablename__ = 'recipes_effects' 352 | 353 | 354 | class RecipeAspect(Base, ElementQuantity): 355 | __tablename__ = 'recipes_aspects' 356 | 357 | 358 | class RecipePurge(Base, ElementQuantity): 359 | __tablename__ = 'recipes_purges' 360 | 361 | 362 | class RecipeHaltVerb(Base, WildcardQuantity): 363 | __tablename__ = 'recipes_halt_verbs' 364 | 365 | 366 | class RecipeDeleteVerb(Base, WildcardQuantity): 367 | __tablename__ = 'recipes_delete_verbs' 368 | 369 | 370 | class RecipeDeckEffect(Base, DeckQuantity): 371 | __tablename__ = 'recipes_deck_effects' 372 | 373 | 374 | class RecipeAlternativeRecipeDetails(Base, LinkedRecipeDetails): 375 | __tablename__ = 'recipes_alternative_recipe_details' 376 | 377 | id = Column(Integer, primary_key=True) 378 | 379 | source_recipe_id: int = Column(Integer, ForeignKey(Recipe.id)) 380 | source_recipe: Recipe = relationship( 381 | Recipe, 382 | back_populates='alternative_recipes', 383 | foreign_keys=source_recipe_id 384 | ) 385 | challenges = relationship( 386 | 'RecipeAlternativeRecipeDetailsChallengeRequirement') 387 | 388 | 389 | class RecipeAlternativeRecipeDetailsChallengeRequirement( 390 | Base, LinkedRecipeChallengeRequirement 391 | ): 392 | __tablename__ = 'recipes_alternative_recipe_details_challenge_requirement' 393 | 394 | id = Column(Integer, primary_key=True) 395 | 396 | alternative_recipe_details_id: int = Column( 397 | Integer, ForeignKey(RecipeAlternativeRecipeDetails.id)) 398 | alternative_recipe_details: RecipeAlternativeRecipeDetails = \ 399 | relationship( 400 | RecipeAlternativeRecipeDetails, 401 | back_populates='challenges', 402 | foreign_keys=alternative_recipe_details_id 403 | ) 404 | 405 | 406 | class RecipeLinkedRecipeDetails(Base, LinkedRecipeDetails): 407 | __tablename__ = 'recipes_linked_recipe_details' 408 | 409 | id = Column(Integer, primary_key=True) 410 | 411 | source_recipe_id: int = Column(Integer, ForeignKey(Recipe.id)) 412 | source_recipe: Recipe = relationship( 413 | Recipe, 414 | back_populates='linked_recipes', 415 | foreign_keys=source_recipe_id 416 | ) 417 | challenges = relationship('RecipeLinkedRecipeDetailsChallengeRequirement') 418 | 419 | 420 | class RecipeLinkedRecipeDetailsChallengeRequirement( 421 | Base, LinkedRecipeChallengeRequirement 422 | ): 423 | __tablename__ = 'recipes_linked_recipe_details_challenge_requirement' 424 | 425 | id = Column(Integer, primary_key=True) 426 | 427 | linked_recipe_details_id: int = Column( 428 | Integer, ForeignKey(RecipeLinkedRecipeDetails.id)) 429 | linked_recipe_details: RecipeLinkedRecipeDetails = \ 430 | relationship( 431 | RecipeLinkedRecipeDetails, 432 | back_populates='challenges', 433 | foreign_keys=linked_recipe_details_id 434 | ) 435 | 436 | 437 | class RecipeSlotSpecification(Base, SlotSpecification): 438 | __tablename__ = 'recipes_slot_specifications' 439 | 440 | id = Column(Integer, primary_key=True) 441 | 442 | recipe_id: int = Column(Integer, ForeignKey(Recipe.id)) 443 | recipe: Recipe = relationship(Recipe, back_populates='slot_specifications') 444 | -------------------------------------------------------------------------------- /patch/CultistSimulator.Modding.mm/CultistSimulator.Modding.mm.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {E77B3CEF-3B80-4748-A00A-A92659BB281D} 8 | Library 9 | Properties 10 | CultistSimulator.Modding.mm 11 | Assembly-CSharp.Modding.mm 12 | v4.7.1 13 | 512 14 | 15 | 16 | 17 | AnyCPU 18 | true 19 | full 20 | false 21 | bin\Debug\ 22 | DEBUG;TRACE 23 | prompt 24 | 4 25 | false 26 | 27 | 28 | AnyCPU 29 | pdbonly 30 | true 31 | bin\Release\ 32 | TRACE 33 | prompt 34 | 4 35 | false 36 | 37 | 38 | 39 | ..\CultistSimulator\Assembly-CSharp.dll 40 | 41 | 42 | ..\CultistSimulator\Assembly-CSharp-firstpass.dll 43 | 44 | 45 | ..\CultistSimulator\Facepunch.Steamworks.dll 46 | 47 | 48 | ..\CultistSimulator\GalaxyCSharp.dll 49 | 50 | 51 | ..\MonoMod\Mono.Cecil.dll 52 | 53 | 54 | ..\MonoMod\Mono.Cecil.Mdb.dll 55 | 56 | 57 | ..\MonoMod\Mono.Cecil.Pdb.dll 58 | 59 | 60 | ..\MonoMod\Mono.Cecil.Rocks.dll 61 | 62 | 63 | ..\CultistSimulator\Mono.Security.dll 64 | 65 | 66 | ..\MonoMod\MonoMod.exe 67 | 68 | 69 | ..\MonoMod\MonoMod.Utils.dll 70 | 71 | 72 | ..\CultistSimulator\System.dll 73 | 74 | 75 | 76 | 77 | ..\CultistSimulator\Unity.Analytics.DataPrivacy.dll 78 | 79 | 80 | ..\CultistSimulator\Unity.Analytics.StandardEvents.dll 81 | 82 | 83 | ..\CultistSimulator\Unity.Analytics.Tracker.dll 84 | 85 | 86 | ..\CultistSimulator\Unity.Cloud.BugReporting.Client.dll 87 | 88 | 89 | ..\CultistSimulator\Unity.Cloud.BugReporting.Plugin.dll 90 | 91 | 92 | ..\CultistSimulator\Unity.TextMeshPro.dll 93 | 94 | 95 | ..\CultistSimulator\UnityEngine.dll 96 | 97 | 98 | ..\CultistSimulator\UnityEngine.AccessibilityModule.dll 99 | 100 | 101 | ..\CultistSimulator\UnityEngine.Advertisements.dll 102 | 103 | 104 | ..\CultistSimulator\UnityEngine.AIModule.dll 105 | 106 | 107 | ..\CultistSimulator\UnityEngine.Analytics.dll 108 | 109 | 110 | ..\CultistSimulator\UnityEngine.AnimationModule.dll 111 | 112 | 113 | ..\CultistSimulator\UnityEngine.ARModule.dll 114 | 115 | 116 | ..\CultistSimulator\UnityEngine.AssetBundleModule.dll 117 | 118 | 119 | ..\CultistSimulator\UnityEngine.AudioModule.dll 120 | 121 | 122 | ..\CultistSimulator\UnityEngine.BaselibModule.dll 123 | 124 | 125 | ..\CultistSimulator\UnityEngine.ClothModule.dll 126 | 127 | 128 | ..\CultistSimulator\UnityEngine.CloudWebServicesModule.dll 129 | 130 | 131 | ..\CultistSimulator\UnityEngine.ClusterInputModule.dll 132 | 133 | 134 | ..\CultistSimulator\UnityEngine.ClusterRendererModule.dll 135 | 136 | 137 | ..\CultistSimulator\UnityEngine.CoreModule.dll 138 | 139 | 140 | ..\CultistSimulator\UnityEngine.CrashReportingModule.dll 141 | 142 | 143 | ..\CultistSimulator\UnityEngine.DirectorModule.dll 144 | 145 | 146 | ..\CultistSimulator\UnityEngine.FacebookModule.dll 147 | 148 | 149 | ..\CultistSimulator\UnityEngine.FileSystemHttpModule.dll 150 | 151 | 152 | ..\CultistSimulator\UnityEngine.GameCenterModule.dll 153 | 154 | 155 | ..\CultistSimulator\UnityEngine.GridModule.dll 156 | 157 | 158 | ..\CultistSimulator\UnityEngine.HotReloadModule.dll 159 | 160 | 161 | ..\CultistSimulator\UnityEngine.ImageConversionModule.dll 162 | 163 | 164 | ..\CultistSimulator\UnityEngine.IMGUIModule.dll 165 | 166 | 167 | ..\CultistSimulator\UnityEngine.InputModule.dll 168 | 169 | 170 | ..\CultistSimulator\UnityEngine.JSONSerializeModule.dll 171 | 172 | 173 | ..\CultistSimulator\UnityEngine.LocalizationModule.dll 174 | 175 | 176 | ..\CultistSimulator\UnityEngine.Networking.dll 177 | 178 | 179 | ..\CultistSimulator\UnityEngine.ParticlesLegacyModule.dll 180 | 181 | 182 | ..\CultistSimulator\UnityEngine.ParticleSystemModule.dll 183 | 184 | 185 | ..\CultistSimulator\UnityEngine.PerformanceReportingModule.dll 186 | 187 | 188 | ..\CultistSimulator\UnityEngine.Physics2DModule.dll 189 | 190 | 191 | ..\CultistSimulator\UnityEngine.PhysicsModule.dll 192 | 193 | 194 | ..\CultistSimulator\UnityEngine.ProfilerModule.dll 195 | 196 | 197 | ..\CultistSimulator\UnityEngine.ScreenCaptureModule.dll 198 | 199 | 200 | ..\CultistSimulator\UnityEngine.SharedInternalsModule.dll 201 | 202 | 203 | ..\CultistSimulator\UnityEngine.SpatialTracking.dll 204 | 205 | 206 | ..\CultistSimulator\UnityEngine.SpriteMaskModule.dll 207 | 208 | 209 | ..\CultistSimulator\UnityEngine.SpriteShapeModule.dll 210 | 211 | 212 | ..\CultistSimulator\UnityEngine.StandardEvents.dll 213 | 214 | 215 | ..\CultistSimulator\UnityEngine.StreamingModule.dll 216 | 217 | 218 | ..\CultistSimulator\UnityEngine.StyleSheetsModule.dll 219 | 220 | 221 | ..\CultistSimulator\UnityEngine.SubstanceModule.dll 222 | 223 | 224 | ..\CultistSimulator\UnityEngine.TerrainModule.dll 225 | 226 | 227 | ..\CultistSimulator\UnityEngine.TerrainPhysicsModule.dll 228 | 229 | 230 | ..\CultistSimulator\UnityEngine.TextCoreModule.dll 231 | 232 | 233 | ..\CultistSimulator\UnityEngine.TextRenderingModule.dll 234 | 235 | 236 | ..\CultistSimulator\UnityEngine.TilemapModule.dll 237 | 238 | 239 | ..\CultistSimulator\UnityEngine.Timeline.dll 240 | 241 | 242 | ..\CultistSimulator\UnityEngine.TimelineModule.dll 243 | 244 | 245 | ..\CultistSimulator\UnityEngine.TLSModule.dll 246 | 247 | 248 | ..\CultistSimulator\UnityEngine.UI.dll 249 | 250 | 251 | ..\CultistSimulator\UnityEngine.UIElementsModule.dll 252 | 253 | 254 | ..\CultistSimulator\UnityEngine.UIModule.dll 255 | 256 | 257 | ..\CultistSimulator\UnityEngine.UmbraModule.dll 258 | 259 | 260 | ..\CultistSimulator\UnityEngine.UNETModule.dll 261 | 262 | 263 | ..\CultistSimulator\UnityEngine.UnityAnalyticsModule.dll 264 | 265 | 266 | ..\CultistSimulator\UnityEngine.UnityConnectModule.dll 267 | 268 | 269 | ..\CultistSimulator\UnityEngine.UnityTestProtocolModule.dll 270 | 271 | 272 | ..\CultistSimulator\UnityEngine.UnityWebRequestAssetBundleModule.dll 273 | 274 | 275 | ..\CultistSimulator\UnityEngine.UnityWebRequestAudioModule.dll 276 | 277 | 278 | ..\CultistSimulator\UnityEngine.UnityWebRequestModule.dll 279 | 280 | 281 | ..\CultistSimulator\UnityEngine.UnityWebRequestTextureModule.dll 282 | 283 | 284 | ..\CultistSimulator\UnityEngine.UnityWebRequestWWWModule.dll 285 | 286 | 287 | ..\CultistSimulator\UnityEngine.VehiclesModule.dll 288 | 289 | 290 | ..\CultistSimulator\UnityEngine.VFXModule.dll 291 | 292 | 293 | ..\CultistSimulator\UnityEngine.VideoModule.dll 294 | 295 | 296 | ..\CultistSimulator\UnityEngine.VRModule.dll 297 | 298 | 299 | ..\CultistSimulator\UnityEngine.WindModule.dll 300 | 301 | 302 | ..\CultistSimulator\UnityEngine.XRModule.dll 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | --------------------------------------------------------------------------------