diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b2478a572e8..ba5357454d7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -113,6 +113,7 @@ Things you **CAN'T** do: [MC Tab Guide](./guides/MC_tab.md) +[Embedding tgui components in chat](../tgui/docs/chat-embedded-components.md) ## Pull Request Process There is no strict process when it comes to merging pull requests. Pull requests will sometimes take a while before they are looked at by a maintainer; the bigger the change, the more time it will take before they are accepted into the code. Every team member is a volunteer who is giving up their own time to help maintain and contribute, so please be courteous and respectful. Here are some helpful ways to make it easier for you and for the maintainers when making a pull request. diff --git a/code/__DEFINES/span.dm b/code/__DEFINES/span.dm index ef2f30112ac..de3ae115a22 100644 --- a/code/__DEFINES/span.dm +++ b/code/__DEFINES/span.dm @@ -114,3 +114,7 @@ #define span_warning(str) ("" + str + "") #define span_yell(str) ("" + str + "") #define span_yellowteamradio(str) ("" + str + "") + +// Spans that use embedded tgui components: +// Sorted alphabetically +#define span_tooltip(tip, main_text) ("" + main_text + "") diff --git a/code/__DEFINES/tcg.dm b/code/__DEFINES/tcg.dm new file mode 100644 index 00000000000..eec393c63e1 --- /dev/null +++ b/code/__DEFINES/tcg.dm @@ -0,0 +1,2 @@ +#define DEFAULT_TCG_DMI_ICON 'icons/runtime/tcg/default.dmi' +#define DEFAULT_TCG_DMI "icons/runtime/tcg/default.dmi" diff --git a/code/controllers/subsystem/tcgsetup.dm b/code/controllers/subsystem/tcgsetup.dm index 0e140aa40c8..c07482db12c 100644 --- a/code/controllers/subsystem/tcgsetup.dm +++ b/code/controllers/subsystem/tcgsetup.dm @@ -2,12 +2,187 @@ SUBSYSTEM_DEF(trading_card_game) name = "Trading Card Game" flags = SS_NO_FIRE init_order = INIT_ORDER_TCG + /// Base directory for all related string files var/card_directory = "strings/tcg" - var/list/card_files = list("set_one.json","set_two.json") + /// List of card files to load + var/list/card_files = list("set_one.json", "set_two.json") + /// List of keyword files + /// These allow you to add on hovor logic to parts of a card's text, displaying extra info + var/list/keyword_files = list("keywords.json") + /// What cardpack types to load var/card_packs = list(/obj/item/cardpack/series_one, /obj/item/cardpack/resin) + var/list/cached_guar_rarity = list() + var/list/cached_rarity_table = list() + /// List of all cards by series, with cards cached by rarity to make those lookups faster + var/list/cached_cards = list() + /// List of loaded keywords matched with their hovor text + var/list/keywords = list() var/loaded = FALSE //Let's load the cards before the map fires, so we can load cards on the map safely /datum/controller/subsystem/trading_card_game/Initialize() - reloadAllCardFiles(card_files, card_directory) + reloadAllCardFiles() return ..() + +///Loads all the card files +/datum/controller/subsystem/trading_card_game/proc/loadAllCardFiles() + for(var/keyword_file in keyword_files) + loadKeywordFile(keyword_file, card_directory) + styleKeywords() + for(var/card_file in card_files) + loadCardFile(card_file, card_directory) + +///Empty the rarity cache so we can safely add new cards +/datum/controller/subsystem/trading_card_game/proc/clearCards() + loaded = FALSE + cached_cards = list() + keywords = list() + +///Reloads all card files +/datum/controller/subsystem/trading_card_game/proc/reloadAllCardFiles() + clearCards() + loadAllCardFiles() + loaded = TRUE + +///Loads the contents of a json file into our global card list +/datum/controller/subsystem/trading_card_game/proc/loadKeywordFile(filename, directory = "strings/tcg") + var/list/keyword_data = json_decode(file2text("[directory]/[filename]")) + for(var/keyword in keyword_data) + if(keywords[keyword]) + stack_trace("Dupe detected, [keyword] was defined by [directory]/[filename] after it already had a value!") + continue + keywords[keyword] = keyword_data[keyword] + +///Styles our keywords, converting them from just the raw text to the output we want +/datum/controller/subsystem/trading_card_game/proc/styleKeywords() + // Add the tooltip component to our text, make it pretty + for(var/keyword in keywords) + var/tooltip_text = keywords[keyword] + keywords[keyword] = span_tooltip(tooltip_text, keyword) + +///Takes a string as input. Searches it for keywords in the pattern {$keyword}, and replaces them with their expanded form, generated above +/datum/controller/subsystem/trading_card_game/proc/resolve_keywords(search_through) + var/starting_text = search_through + while(TRUE) + var/fragment_start = findtext(search_through, "{$") + if(!fragment_start) + break + var/fragment_end = findtext(search_through, "}") + if(!fragment_end) + CRASH("[starting_text] contains a {$ that denotes the start of a keyword replacement, but not a closing }!") + ///Gets the keyword this string wants to use + ///We offset the start by two indexes to account for + var/keyword = copytext(search_through, fragment_start + 2, fragment_end) + var/replacement = keywords[keyword] + if(!replacement) + CRASH("[starting_text] contains a non-existent keyword! \[[keyword]\]") + search_through = replacetext(search_through, "{$[keyword]}", replacement) + + return search_through + +///Loads the contents of a json file into our global card list +/datum/controller/subsystem/trading_card_game/proc/loadCardFile(filename, directory = "strings/tcg") + var/list/json = json_decode(file2text("[directory]/[filename]")) + var/list/cards = json["cards"] + var/list/templates = list() + for(var/list/data in json["templates"]) + templates[data["template"]] = data + for(var/list/data in cards) + var/datum/card/card = new(data, templates) + //Lets cache the id by rarity, for top speed lookup later + if(!cached_cards[card.series]) + cached_cards[card.series] = list() + cached_cards[card.series]["ALL"] = list() + if(!cached_cards[card.series][card.rarity]) + cached_cards[card.series][card.rarity] = list() + cached_cards[card.series][card.rarity] += card.id + //Let's actually store the datum here + cached_cards[card.series]["ALL"][card.id] = card + +///Because old me wanted to keep memory costs down, each cardpack type shares a rarity list +///We do the spooky stuff in here to ensure we don't have too many lists lying around +/datum/controller/subsystem/trading_card_game/proc/get_rarity_table(type, list/sample_table) + //Pass by refrance moment + //This lets us only have one rarity table per pack, badmins beware + //Yes this is horribly overengineered. No I am not sorry + if(!cached_rarity_table[type]) + cached_rarity_table[type] = sample_table + return cached_rarity_table[type] + +///See above +/datum/controller/subsystem/trading_card_game/proc/get_guarenteed_rarity_table(type, list/sample_table) + if(!cached_guar_rarity[type]) + cached_guar_rarity[type] = sample_table + return cached_guar_rarity[type] + +///Prints all the cards names +/datum/controller/subsystem/trading_card_game/proc/printAllCards() + for(var/card_set in cached_cards) + message_admins("Printing the [card_set] set") + for(var/card in cached_cards[card_set]["ALL"]) + var/datum/card/toPrint = cached_cards[card_set]["ALL"][card] + message_admins(toPrint.name) + +///Checks the passed type list for missing raritys, or raritys out of bounds +/datum/controller/subsystem/trading_card_game/proc/checkCardpacks(cardPackList) + var/toReturn = "" + for(var/cardPack in cardPackList) + var/obj/item/cardpack/pack = new cardPack() + //Lets see if someone made a type yeah? + if(!cached_cards[pack.series]) + toReturn += "[pack.series] does not have any cards in it\n" + continue + for(var/card in cached_cards[pack.series]["ALL"]) + var/datum/card/template = cached_cards[pack.series]["ALL"][card] + if(template.rarity == "ALL") + toReturn += "[pack.type] has a rarity [template.rarity] on the card [template.id] that needs to be changed to something that isn't \"ALL\"\n" + continue + if(!(template.rarity in pack.rarity_table)) + toReturn += "[pack.type] has a rarity [template.rarity] on the card [template.id] that does not exist\n" + continue + //Lets run a check to see if all the rarities exist that we want to exist exist + for(var/pack_rarity in pack.rarity_table) + if(!cached_cards[pack.series][pack_rarity]) + toReturn += "[pack.type] does not have the required rarity [pack_rarity]\n" + qdel(pack) + return toReturn + +///Checks the global card list for cards that don't override all the default values of the card datum +/datum/controller/subsystem/trading_card_game/proc/checkCardDatums() + var/toReturn = "" + var/datum/thing = new() + for(var/series in cached_cards) + var/cards = cached_cards[series]["ALL"] + for(var/card in cards) + var/datum/card/target = cached_cards[series]["ALL"][card] + var/toAdd = "The card [target.id] in [series] has the following default variables:" + var/shouldAdd = FALSE + for(var/current_var in (target.vars ^ thing.vars)) + if(current_var == "icon" && target.vars[current_var] == DEFAULT_TCG_DMI) + continue + if(target.vars[current_var] == initial(target.vars[current_var])) + shouldAdd = TRUE + toAdd += "\n[current_var] with a value of [target.vars[current_var]]" + if(shouldAdd) + toReturn += toAdd + qdel(thing) + return toReturn + +///Used to test open a large amount of cardpacks +/datum/controller/subsystem/trading_card_game/proc/checkCardDistribution(cardPack, batchSize, batchCount, guaranteed) + var/totalCards = 0 + //Gotta make this look like an associated list so the implicit "does this exist" checks work proper later + var/list/cardsByCount = list("" = 0) + var/obj/item/cardpack/pack = new cardPack() + for(var/index in 1 to batchCount) + var/list/cards = pack.buildCardListWithRarity(batchSize, guaranteed) + for(var/id in cards) + totalCards++ + cardsByCount[id] += 1 + var/toSend = "Out of [totalCards] cards" + for(var/id in sort_list(cardsByCount, /proc/cmp_num_string_asc)) + if(id) + var/datum/card/template = cached_cards[pack.series]["ALL"][id] + toSend += "\nID:[id] [template.name] [(cardsByCount[id] * 100) / totalCards]% Total:[cardsByCount[id]]" + message_admins(toSend) + qdel(pack) diff --git a/code/game/objects/items/tcg/tcg.dm b/code/game/objects/items/tcg/tcg.dm index 94806a81368..352326e2445 100644 --- a/code/game/objects/items/tcg/tcg.dm +++ b/code/game/objects/items/tcg/tcg.dm @@ -1,11 +1,3 @@ - -GLOBAL_LIST_EMPTY(cached_guar_rarity) -GLOBAL_LIST_EMPTY(cached_rarity_table) -//Global list of all cards by series, with cards cached by rarity to make those lookups faster -GLOBAL_LIST_EMPTY(cached_cards) - -#define DEFAULT_TCG_DMI_ICON 'icons/runtime/tcg/default.dmi' -#define DEFAULT_TCG_DMI "icons/runtime/tcg/default.dmi" #define TAPPED_ANGLE 90 #define UNTAPPED_ANGLE 0 @@ -32,7 +24,7 @@ GLOBAL_LIST_EMPTY(cached_cards) datum_series = series if(!datum_id) datum_id = id - var/list/temp_list = GLOB.cached_cards[datum_series] + var/list/temp_list = SStrading_card_game.cached_cards[datum_series] if(!temp_list) return var/datum/card/temp = temp_list["ALL"][datum_id] @@ -51,11 +43,11 @@ GLOBAL_LIST_EMPTY(cached_cards) * This proc gets the card's associated card datum to play with */ /obj/item/tcgcard/proc/extract_datum() - var/list/cached_cards = GLOB.cached_cards[series] + var/list/cached_cards = SStrading_card_game.cached_cards[series] if(!cached_cards) return null if(!cached_cards["ALL"][id]) - CRASH("A card without a datum has appeared, either the global list is empty, or you fucked up bad. Series{[series]} ID{[id]} Len{[GLOB.cached_cards.len]}") + CRASH("A card without a datum has appeared, either the global list is empty, or you fucked up bad. Series{[series]} ID{[id]} Len{[SStrading_card_game.cached_cards.len]}") return cached_cards["ALL"][id] /obj/item/tcgcard/get_name_chaser(mob/user, list/name_chaser = list()) @@ -111,7 +103,7 @@ GLOBAL_LIST_EMPTY(tcgcard_radial_choices) /obj/item/tcgcard/update_desc(updates) . = ..() if(!flipped) - var/datum/card/template = GLOB.cached_cards[series]["ALL"][id] + var/datum/card/template = SStrading_card_game.cached_cards[series]["ALL"][id] desc = "[template.desc]" else desc = "It's the back of a trading card... no peeking!" @@ -121,7 +113,7 @@ GLOBAL_LIST_EMPTY(tcgcard_radial_choices) icon_state = "cardback" return ..() - var/datum/card/template = GLOB.cached_cards[series]["ALL"][id] + var/datum/card/template = SStrading_card_game.cached_cards[series]["ALL"][id] icon_state = template.icon_state return ..() @@ -364,16 +356,8 @@ GLOBAL_LIST_EMPTY(tcgcard_radial_choices) /obj/item/cardpack/Initialize(mapload) . = ..() AddElement(/datum/element/item_scaling, 0.4, 1) - //Pass by refrance moment - //This lets us only have one rarity table per pack, badmins beware - if(GLOB.cached_rarity_table[type]) - rarity_table = GLOB.cached_rarity_table[type] - else - GLOB.cached_rarity_table[type] = rarity_table - if(GLOB.cached_guar_rarity[type]) - guar_rarity = GLOB.cached_guar_rarity[type] - else - GLOB.cached_guar_rarity[type] = guar_rarity + rarity_table = SStrading_card_game.get_rarity_table(type, rarity_table) + guar_rarity = SStrading_card_game.get_guarenteed_rarity_table(type, guar_rarity) /obj/item/cardpack/attack_self(mob/user) . = ..() @@ -444,7 +428,7 @@ GLOBAL_LIST_EMPTY(tcgcard_radial_choices) //What we're doing here is using the cached the results of the rarity we find. //This allows us to only have to run this once per rarity, ever. //Unless you reload the cards of course, in which case we have to do this again. - var/list/cards = GLOB.cached_cards[series][rarity] + var/list/cards = SStrading_card_game.cached_cards[series][rarity] if(cards.len) toReturn += pick(cards) else @@ -482,10 +466,11 @@ GLOBAL_LIST_EMPTY(tcgcard_radial_choices) /datum/card/New(list/data = list(), list/templates = list()) applyTemplates(data, templates) apply(data) + applyKeywords(data | templates) ///For each var that the card datum and the json entry share, we set the datum var to the json entry /datum/card/proc/apply(list/data) - for(var/name in (vars & data)) + for(var/name in (data & vars)) vars[name] = data[name] ///Applies a json file to a card datum @@ -493,113 +478,13 @@ GLOBAL_LIST_EMPTY(tcgcard_radial_choices) apply(templates["default"]) apply(templates[data["template"]]) -///Loads all the card files -/proc/loadAllCardFiles(cardFiles, directory) - var/list/templates = list() - for(var/cardFile in cardFiles) - loadCardFile(cardFile, directory, templates) - -///Prints all the cards names -/proc/printAllCards() - for(var/card_set in GLOB.cached_cards) - message_admins("Printing the [card_set] set") - for(var/card in GLOB.cached_cards[card_set]["ALL"]) - var/datum/card/toPrint = GLOB.cached_cards[card_set]["ALL"][card] - message_admins(toPrint.name) - -///Checks the passed type list for missing raritys, or raritys out of bounds -/proc/checkCardpacks(cardPackList) - var/toReturn = "" - for(var/cardPack in cardPackList) - var/obj/item/cardpack/pack = new cardPack() - //Lets see if someone made a type yeah? - if(!GLOB.cached_cards[pack.series]) - toReturn += "[pack.series] does not have any cards in it\n" +///Searches for keywords in the card's variables, marked by wrapping them in {$} +///Adds on hovor logic to them, using the passed in list +///We use the changed_vars list just to make the var searching faster +/datum/card/proc/applyKeywords(list/changed_vars) + for(var/name in (changed_vars & vars)) + var/value = vars[name] + if(!istext(value)) continue - for(var/card in GLOB.cached_cards[pack.series]["ALL"]) - var/datum/card/template = GLOB.cached_cards[pack.series]["ALL"][card] - if(template.rarity == "ALL") - toReturn += "[pack.type] has a rarity [template.rarity] on the card [template.id] that needs to be changed to something that isn't \"ALL\"\n" - continue - if(!(template.rarity in pack.rarity_table)) - toReturn += "[pack.type] has a rarity [template.rarity] on the card [template.id] that does not exist\n" - continue - //Lets run a check to see if all the rarities exist that we want to exist exist - for(var/pack_rarity in pack.rarity_table) - if(!GLOB.cached_cards[pack.series][pack_rarity]) - toReturn += "[pack.type] does not have the required rarity [pack_rarity]\n" - qdel(pack) - return toReturn + vars[name] = SStrading_card_game.resolve_keywords(value) -///Checks the global card list for cards that don't override all the default values of the card datum -/proc/checkCardDatums() - var/toReturn = "" - var/datum/thing = new() - for(var/series in GLOB.cached_cards) - var/cards = GLOB.cached_cards[series]["ALL"] - for(var/card in cards) - var/datum/card/target = GLOB.cached_cards[series]["ALL"][card] - var/toAdd = "The card [target.id] in [series] has the following default variables:" - var/shouldAdd = FALSE - for(var/current_var in (target.vars ^ thing.vars)) - if(current_var == "icon" && target.vars[current_var] == DEFAULT_TCG_DMI) - continue - if(target.vars[current_var] == initial(target.vars[current_var])) - shouldAdd = TRUE - toAdd += "\n[current_var] with a value of [target.vars[current_var]]" - if(shouldAdd) - toReturn += toAdd - qdel(thing) - return toReturn - -///Used to test open a large amount of cardpacks -/proc/checkCardDistribution(cardPack, batchSize, batchCount, guaranteed) - var/totalCards = 0 - //Gotta make this look like an associated list so the implicit "does this exist" checks work proper later - var/list/cardsByCount = list("" = 0) - var/obj/item/cardpack/pack = new cardPack() - for(var/index in 1 to batchCount) - var/list/cards = pack.buildCardListWithRarity(batchSize, guaranteed) - for(var/id in cards) - totalCards++ - cardsByCount[id] += 1 - var/toSend = "Out of [totalCards] cards" - for(var/id in sort_list(cardsByCount, /proc/cmp_num_string_asc)) - if(id) - var/datum/card/template = GLOB.cached_cards[pack.series]["ALL"][id] - toSend += "\nID:[id] [template.name] [(cardsByCount[id] * 100) / totalCards]% Total:[cardsByCount[id]]" - message_admins(toSend) - qdel(pack) - -///Empty the rarity cache so we can safely add new cards -/proc/clearCards() - SStrading_card_game.loaded = FALSE - GLOB.cached_cards = list() - -///Reloads all card files -/proc/reloadAllCardFiles(cardFiles, directory) - clearCards() - loadAllCardFiles(cardFiles, directory) - SStrading_card_game.loaded = TRUE - -///Loads the contents of a json file into our global card list -/proc/loadCardFile(filename, directory = "strings/tcg") - var/list/json = json_decode(file2text("[directory]/[filename]")) - var/list/cards = json["cards"] - var/list/templates = list() - for(var/list/data in json["templates"]) - templates[data["template"]] = data - for(var/list/data in cards) - var/datum/card/card = new(data, templates) - //Lets cache the id by rarity, for top speed lookup later - if(!GLOB.cached_cards[card.series]) - GLOB.cached_cards[card.series] = list() - GLOB.cached_cards[card.series]["ALL"] = list() - if(!GLOB.cached_cards[card.series][card.rarity]) - GLOB.cached_cards[card.series][card.rarity] = list() - GLOB.cached_cards[card.series][card.rarity] += card.id - //Let's actually store the datum here - GLOB.cached_cards[card.series]["ALL"][card.id] = card - -#undef DEFAULT_TCG_DMI_ICON -#undef DEFAULT_TCG_DMI diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 9c6d210acbf..a05bc539bcb 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -636,7 +636,7 @@ GLOBAL_PROTECT(admin_verbs_hideable) if(!SStrading_card_game.loaded) message_admins("The card subsystem is not currently loaded") return - reloadAllCardFiles(SStrading_card_game.card_files, SStrading_card_game.card_directory) + SStrading_card_game.reloadAllCardFiles() /client/proc/validate_cards() set name = "Validate Cards" @@ -646,8 +646,8 @@ GLOBAL_PROTECT(admin_verbs_hideable) if(!SStrading_card_game.loaded) message_admins("The card subsystem is not currently loaded") return - var/message = checkCardpacks(SStrading_card_game.card_packs) - message += checkCardDatums() + var/message = SStrading_card_game.checkCardpacks(SStrading_card_game.card_packs) + message += SStrading_card_game.checkCardDatums() if(message) message_admins(message) @@ -663,12 +663,12 @@ GLOBAL_PROTECT(admin_verbs_hideable) var/batchCount = input("How many times should we open it?", "Don't worry, I understand") as null|num var/batchSize = input("How many cards per batch?", "I hope you remember to check the validation") as null|num var/guar = input("Should we use the pack's guaranteed rarity? If so, how many?", "We've all been there. Man you should have seen the old system") as null|num - checkCardDistribution(pack, batchSize, batchCount, guar) + SStrading_card_game.checkCardDistribution(pack, batchSize, batchCount, guar) /client/proc/print_cards() set name = "Print Cards" set category = "Debug" - printAllCards() + SStrading_card_game.printAllCards() /client/proc/give_spell(mob/spell_recipient in GLOB.mob_list) set category = "Admin.Fun" diff --git a/code/modules/unit_tests/card_mismatch.dm b/code/modules/unit_tests/card_mismatch.dm index 506e88f19c3..dd41749fd0c 100644 --- a/code/modules/unit_tests/card_mismatch.dm +++ b/code/modules/unit_tests/card_mismatch.dm @@ -1,7 +1,7 @@ /datum/unit_test/card_mismatch /datum/unit_test/card_mismatch/Run() - var/message = checkCardpacks(SStrading_card_game.card_packs) - message += checkCardDatums() + var/message = SStrading_card_game.checkCardpacks(SStrading_card_game.card_packs) + message += SStrading_card_game.checkCardDatums() if(message) Fail(message) diff --git a/strings/tcg/keywords.json b/strings/tcg/keywords.json new file mode 100644 index 00000000000..d4a02bd8a06 --- /dev/null +++ b/strings/tcg/keywords.json @@ -0,0 +1,18 @@ +{ + "Faction": "Groupings of cards that can often share effects and traits together", + "On Summon": "This effect is activated once it's creature cost is paid and it enters the battlefield", + "On Equip": "This effect is activated once it's item cost is paid and it enters the battlefield, equipping itself to a chosen card on your side of the field (Unless otherwise stated)", + "Asimov": "Creatures possessing this trait cannot attack or defend against creatures with the Human subtype", + "Changeling": "This creature possesses all creature subtypes simultaneously. Any effects which affect a specific subtype apply to Changelings", + "Graytide": "When this creature enters the battlefield, it gains +1/+1 for the number of creatures on your side of the field with Graytide, until the end of the turn", + "Holy": "Immunity to all event cards", + "Taunt": "All opposing creature attacks must be directed towards the creature with Taunt", + "First Strike": "This creature has attack priority in combat", + "Deadeye": "This creature can always hit opponents, regardless of effects or immunities", + "Squad Tactics": "When this creature attacks an opponent's creature and defeats it in combat, the owner of the defeated card takes 1 lifeshard of damage from combat", + "Immunity": "The creature cannot be affected by card effects or combat of its immunity type. This includes both friendly and opposing effects", + "Fury": "The creature must attack at every possibility", + "Blocker": "The creature cannot declare attacks, but can defend", + "Hivemind": "The creature enters combat with a hivemind token on it.The first time this card would take damage, remove that token instead. This does not apply to immediate removal effects, only points of damage", + "Clockwork": "The creature can copy a single keyword on another creature on the field, until they lose the clockwork keyword or leave the field" +} diff --git a/strings/tcg/set_one.json b/strings/tcg/set_one.json index 7e000c3b628..db0f41f18f6 100644 --- a/strings/tcg/set_one.json +++ b/strings/tcg/set_one.json @@ -11,7 +11,7 @@ "id": "AI", "name": "AI", "desc": "The latest generation of NT's top secret artificial intelligence project, this time with actual human brains in a jar! Don't tell the press though.", - "rules": "Asimov", + "rules": "{$Asimov}", "icon_state": "ai", "power": "3", "resolve": "6", @@ -25,7 +25,7 @@ "id": "stickman", "name": "Angry Stickman", "desc": "Sure, he's flat and crudely drawn, but watch out! He's a menace!", - "rules": "On summon: If another 'Angry Stickman' card has been destroyed, you may summon it for at double cost. This ability may be activated only once per turn.", + "rules": "{$On Summon}: If another 'Angry Stickman' card has been destroyed, you may summon it for at double cost. This ability may be activated only once per turn.", "icon_state": "angry_stickman", "power": "1", "resolve": "1", @@ -39,7 +39,7 @@ "id": "changeling", "name": "Armoured Changeling", "desc": "The strange creatures known as changelings have been known to develop natural armour as a defense mechanism when in combat.", - "rules": "Changeling", + "rules": "{$Changeling}", "icon_state": "armored_changeling", "power": "2", "resolve": "8", @@ -53,7 +53,7 @@ "id": "assistant", "name": "Staff Assistant", "desc": "The lowest ladder on the Nanotrasen Employment Ladder, Staff Assistants are employed to help out with tasks deemed 'too menial for robots'.", - "rules": "Greytide, for every card with 'Greytide', this card has +1/+1 on the field.", + "rules": "{$Graytide}, for every card with '{$Graytide}', this card has +1/+1 on the field.", "icon_state": "assistant", "power": "1", "resolve": "1", @@ -67,7 +67,7 @@ "id": "atmos_tech", "name": "Atmospheric Technician", "desc": "The Atmospheric Technicians are tasked with keeping the station's air clean, breathable, and, most importantly, devoid of plasma.", - "rules": "On Summon: Search your deck for an Atmospherics Battlefield card, and add it to your hand. Shuffle your deck afterward.", + "rules": "{$On Summon}: Search your deck for an Atmospherics Battlefield card, and add it to your hand. Shuffle your deck afterward.", "icon_state": "atmos_tech", "power": "2", "resolve": "3", @@ -123,7 +123,7 @@ "id": "caps_suit", "name": "Apadyne Technologies Mk.2 R.I.O.T. Suit (Captain's Version)", "desc": "A heavily customised Apadyne Technologies Mk.2 R.I.O.T. Suit, rebuilt and refitted to Nanotrasen's highest standards for issue to Station Captains.", - "rules": "On equip: tap the equipped card for 2 turns, without triggering the target card's effects.", + "rules": "{$On Equip}: tap the equipped card for 2 turns, without triggering the target card's effects.", "icon_state": "captain_hardsuit", "power": "-1", "resolve": "5", @@ -151,7 +151,7 @@ "id": "chap_plate", "name": "Crusader Armour", "desc": "The common war attire of chaplains. Mostly ceremonial.", - "rules": "Holy", + "rules": "{$Holy}", "icon_state": "chaplain_crusader", "power": "2", "resolve": "2", @@ -165,7 +165,7 @@ "id": "chemist", "name": "Chemist", "desc": "Chemists are encouraged to not set up illicit methamphetamine factories on the company's dime.", - "rules": "Tap this card: flip a coin. If heads: a friendly Medical Faction card gains 0/+2. If tails, an opponents creature of your choice gains +2/0.", + "rules": "Tap this card: flip a coin. If heads: a friendly Medical {$Faction} card gains 0/+2. If tails, an opponents creature of your choice gains +2/0.", "icon_state": "chemist", "power": "0", "resolve": "3", @@ -263,7 +263,7 @@ "id": "clown", "name": "Clown", "desc": "Every Nanotrasen station has a clown on board, as high command believes that a source of entertainment will reduce instances of murder-suicide on board Spinward Stations. The results of this hypothesis are, as of yet, unproven.", - "rules": "Taunt", + "rules": "{$Taunt}", "icon_state": "clown", "power": "2", "resolve": "4", @@ -277,7 +277,7 @@ "id": "clownborg", "name": "Cyborg (Clown Shell)", "desc": "The clown shell is a new development in cyborg technology, designed to capture the joyous hijinks of the station clown in a notably more macabre and disturbing fashion.", - "rules": "Taunt, Asimov", + "rules": "{$Taunt}, {$Asimov}", "icon_state": "borg_clown", "power": "2", "resolve": "4", @@ -291,7 +291,7 @@ "id": "clown_suit", "name": "HONK Ltd. Entertainment Voidsuit", "desc": "The most advanced clown suit produced by HONK Ltd. the Entertainment Voidsuit is designed to withstand extreme conditions while still maintaining the aesthetic expected of clowns.", - "rules": "On Equip: give the equipped unit Taunt.", + "rules": "{$On Equip}: give the equipped unit {$Taunt}.", "icon_state": "clown_hardsuit", "power": "0", "resolve": "0", @@ -305,7 +305,7 @@ "id": "abductor_armour", "name": "Abductor Combat Armour", "desc": "Recovered from the strange alien species known as the Abductors, this armour is made from an extremely tough yet flexible material that has been dubbed as Alien Alloy by researchers.", - "rules": "On Equip: give the equipped unit Effect Immunity and Spell Immunity.", + "rules": "{$On Equip}: give the equipped unit Effect {$Immunity} and Spell {$Immunity}.", "icon_state": "abductor_combat", "power": "1", "resolve": "3", @@ -319,7 +319,7 @@ "id": "chef", "name": "Cook", "desc": "Every Nanotrasen chef is trained in 3 cuisines of their choosing upon being hired, alongside the closely guarded secret of Close Quarters Cooking.", - "rules": "First Strike", + "rules": "{$First Strike}", "icon_state": "cook", "power": "3", "resolve": "2", @@ -333,7 +333,7 @@ "id": "wiz_suit", "name": "Wizard Federation Standard Issue Hardsuit", "desc": "Seemingly reverse engineered from captured engineering hardsuits, the iconic Wizard Federation Hardsuit is a spectacular melding of technology and magic.", - "rules": "On Equip: The equipped creature cannot attack targets with Holy.", + "rules": "{$On Equip}: The equipped creature cannot attack targets with {$Holy}.", "icon_state": "wizard_hardsuit", "power": "3", "resolve": "1", @@ -347,7 +347,7 @@ "id": "curator", "name": "Curator", "desc": "In Nanotrasen polls, the Curator has ranked as the most pointless job on station, much to the ire of the Curator's union. Thankfully, we don't have to listen to them.", - "rules": "On Summon: Draw 1 card: if it's an event card, discard it.", + "rules": "{$On Summon}: Draw 1 card: if it's an event card, discard it.", "icon_state": "curator", "power": "1", "resolve": "1", @@ -361,7 +361,7 @@ "id": "ianborg", "name": "Borgi Ian", "desc": "While Ian's cyborg costume is very convincing, we at the NTED would like to remind all employees that Ian has not been experimented on.", - "rules": "Asimov. You may sacrifice this card in play: Summon a Silicon type card from your hand worth up to double this card's cost.", + "rules": "{$Asimov}. You may sacrifice this card in play: Summon a Silicon type card from your hand worth up to double this card's cost.", "icon_state": "ian_cyborg", "power": "0", "resolve": "3", @@ -375,7 +375,7 @@ "id": "deathsquad_armour", "name": "Apadyne Technologies Mk.3 R.I.O.T. Carapace", "desc": "The most advanced set of armour available for purchase from Apadyne Technologies, the Mk.3 R.I.O.T. Carapace is issued to Nanotrasen's most elite forces.", - "rules": "On Equip: if the equipped creature is of the Security faction, it gains Taunt.", + "rules": "{$On Equip}: if the equipped creature is of the Security faction, it gains {$Taunt}.", "icon_state": "deathsquad", "power": "3", "resolve": "3", @@ -389,7 +389,7 @@ "id": "det", "name": "Detective", "desc": "Nanotrasen hires nothing but the best detectives to investigate crime on our stations. A penchant for cigarettes and outdated fashion isn't mandatory, but is appreciated.", - "rules": "Deadeye", + "rules": "{$Deadeye}", "icon_state": "detective", "power": "3", "resolve": "2", @@ -403,7 +403,7 @@ "id": "nukie_elite", "name": "Elite Syndicate Nuclear Stormtrooper", "desc": "The best of the best of the syndicate troops, elite stormtroopers can be distinguished by their black armour. Shoot on sight, ask questions later!", - "rules": "Fury", + "rules": "{$Fury}", "icon_state": "nukie_elite", "power": "5", "resolve": "5", @@ -417,7 +417,7 @@ "id": "engiborg", "name": "Cyborg (Engineering Shell)", "desc": "A common sight on Nanotrasen Stations, Engineering Shells maintain critical station systems in hazardous conditions.", - "rules": "Asimov", + "rules": "{$Asimov}", "icon_state": "borg_engi", "power": "2", "resolve": "2", @@ -431,7 +431,7 @@ "id": "ert_command", "name": "NT P.A.V. Suit (Command)", "desc": "Issued to members of Emergency Response Teams, the P.A.V. Suit gives superior protection from any threat the galaxy can throw at it. This particular model is outfitted with a sidearm holster and a sleek blue finish.", - "rules": "While equipped, give the equipped unit Squad Tactics and First Strike.", + "rules": "While equipped, give the equipped unit {$Squad Tactics} and {$First Strike}.", "icon_state": "ert_command", "power": "2", "resolve": "2", @@ -445,7 +445,7 @@ "id": "ert_engi", "name": "NT P.A.V. Suit (Engineering)", "desc": "Issued to members of Emergency Response Teams, the P.A.V. Suit gives superior protection from any threat the galaxy can throw at it. This particular model is outfitted with a welding screen and a flashy yellow finish.", - "rules": "While equipped, give the equipped unit Squad Tactics.", + "rules": "While equipped, give the equipped unit {$Squad Tactics}.", "icon_state": "ert_engi", "power": "1", "resolve": "1", @@ -459,7 +459,7 @@ "id": "ert_med", "name": "NT P.A.V. Suit (Medical)", "desc": "Issued to members of Emergency Response Teams, the P.A.V. Suit gives superior protection from any threat the galaxy can throw at it. This particular model is outfitted with a sterile coating and a calming white finish.", - "rules": "While equipped, give the equipped unit Squad Tactics.", + "rules": "While equipped, give the equipped unit {$Squad Tactics}.", "icon_state": "ert_med", "power": "1", "resolve": "2", @@ -473,7 +473,7 @@ "id": "ert_sec", "name": "NT P.A.V. Suit (Security)", "desc": "Issued to members of Emergency Response Teams, the P.A.V. Suit gives superior protection from any threat the galaxy can throw at it. This particular model is outfitted with bulletproof padding and an intimidating red finish.", - "rules": "While equipped, give the equipped unit Squad Tactics.", + "rules": "While equipped, give the equipped unit {$Squad Tactics}.", "icon_state": "ert_sec", "power": "2", "resolve": "1", @@ -501,7 +501,7 @@ "id": "borg", "name": "Cyborg", "desc": "Created as part of humanity's first foray into artificial intelligence, the original cyborg models used organic parts in lieu of sophisticated artificial brains.", - "rules": "Asimov", + "rules": "{$Asimov}", "icon_state": "borg_generic", "power": "3", "resolve": "3", @@ -515,7 +515,7 @@ "id": "geneticist", "name": "Geneticist", "desc": "Geneticists are tasked with manipulating human DNA to produce special effects. Nanotrasen maintains a strict 'no superhero' policy for mutations, following the Superhero Civil War of 2150.", - "rules": "You may tap this card and pay 3 mana: Give a friendly creature Hivemind until this card leaves the field.", + "rules": "You may tap this card and pay 3 mana: Give a friendly creature {$Hivemind} until this card leaves the field.", "icon_state": "geneticist", "power": "3", "resolve": "4", @@ -529,7 +529,7 @@ "id": "med_geneticist", "name": "Geneticist", "desc": "Geneticists are tasked with manipulating human DNA to produce special effects. Nanotrasen maintains a strict 'no superhero' policy for mutations, following the Superhero Civil War of 2150.", - "rules": "Graytide, Hivemind", + "rules": "{$Graytide}, {$Hivemind}", "icon_state": "geneticist_med", "power": "3", "resolve": "6", @@ -543,7 +543,7 @@ "id": "spookian", "name": "Ghost Ian", "desc": "Oh my god! Ian's dead!", - "rules": "On Summon: Search your deck for a battlefield, and add it to your hand. Shuffle your deck afterwards.", + "rules": "{$On Summon}: Search your deck for a battlefield, and add it to your hand. Shuffle your deck afterwards.", "icon_state": "ian_ghost", "power": "1", "resolve": "1", @@ -557,7 +557,7 @@ "id": "HOP", "name": "Head of Personnel", "desc": "The head of the Cargo and Service Departments, guardian of all access, and Ian's lovable, yet dumb, sidekick.", - "rules": "Once per turn: Select a friendly creature card. That card gains changeling.", + "rules": "Once per turn: Select a friendly creature card. That card gains {$Changeling}.", "icon_state": "hop", "power": "4", "resolve": "3", @@ -571,7 +571,7 @@ "id": "HOS", "name": "Head of Security", "desc": "Nanotrasen hires most heads of staff based on their qualifications as being amicable, good at conflict resolution, ability to handle high-stakes situations, humanity, and desire to learn. Heads of Security only need a highschool degree.", - "rules": "On Summon: Select a card type. That card type now costs 1 extra mana to summon. This effect persists until Head of Security is removed from the battlefield.", + "rules": "{$On Summon}: Select a card type. That card type now costs 1 extra mana to summon. This effect persists until Head of Security is removed from the battlefield.", "icon_state": "hos", "power": "4", "resolve": "4", @@ -585,7 +585,7 @@ "id": "hos_suit", "name": "Apadyne Technologies 'Tyrant' Class Hardshell", "desc": "The distinctive shape of the Tyrant Class Hardshell is caused, in part, by the large amount of kevlar reinforcement and the ablative armour layer. Perhaps more importantly, it also looks rad.", - "rules": "Grant the equip card Fury until this card is removed from play.", + "rules": "Grant the equip card {$Fury} until this card is removed from play.", "icon_state": "hos_hardsuit", "power": "4", "resolve": "2", @@ -599,7 +599,7 @@ "id": "ian", "name": "Ian", "desc": "This adorable corgi has become the defacto mascot of the Spinward Stations to many. He comes in many forms, many sizes, and many shapes, but he's still just as lovable. Hand wash only.", - "rules": "Holy, You may Sacrifice this card on the field: Play a Command card from your hand for free.", + "rules": "{$Holy}, You may Sacrifice this card on the field: Play a Command card from your hand for free.", "icon_state": "ian", "power": "0", "resolve": "3", @@ -613,7 +613,7 @@ "id": "inquisitor_suit", "name": "Inquisitor's Hardsuit", "desc": "Nanotrasen officially doesn't believe in ghosts, magic, or anything that can't be solved with science. When you see someone show up in one of these, let that remind you of that fact.", - "rules": "Apply First Strike to the equip creature.", + "rules": "Apply {$First Strike} to the equip creature.", "icon_state": "inquisitor", "power": "2", "resolve": "2", @@ -627,7 +627,7 @@ "id": "intern", "name": "Intern", "desc": "All Nanotrasen interns come with 3 things: A resume, a desire to learn, and vague promises that they're getting paid at some point. So don't be too rough on them.", - "rules": "First Strike", + "rules": "{$First Strike}", "icon_state": "intern", "power": "1", "resolve": "1", @@ -641,7 +641,7 @@ "id": "jannie", "name": "Janitor", "desc": "A true testament to futility, they clean and they clean and they clean, knowing that there's no way they can clean it all. Yet, they perservere, knowing that without them, the crew would simply give in to their base animalistic nature.", - "rules": "Taunt", + "rules": "{$Taunt}", "icon_state": "janitor", "power": "1", "resolve": "1", @@ -655,7 +655,7 @@ "id": "jannieborg", "name": "Cyborg (Custodial Shell)", "desc": "A powerful, state of the act cleaning machine. They exist to eradicate stains, snag garbage, and replace lights, forever. We are legally obligated by the Janitor's Union to state that these machines are no replacement for a flesh-and-blood janitor.", - "rules": "Asimov, you may tap this card: Tap an opponent's Human Creature as well.", + "rules": "{$Asimov}, you may tap this card: Tap an opponent's Human Creature as well.", "icon_state": "borg_janitor", "power": "1", "resolve": "3", @@ -669,7 +669,7 @@ "id": "lawyer", "name": "Lawyer", "desc": "Nanotrasen knows the value of a good lawyer. That's why they're all working hard at our home offices defending us from frivolous labor suits from lazy no-good employees who should be working hard instead of slacking off reading trading cards.", - "rules": "When an opponent attacks with a creature with 3 or more power, this card gains Taunt.", + "rules": "When an opponent attacks with a creature with 3 or more power, this card gains {$Taunt}.", "icon_state": "lawyer", "power": "0", "resolve": "4", @@ -697,7 +697,7 @@ "id": "medborg", "name": "Cyborg (Medical Shell)", "desc": "A state of the art medical shell, for when biological life just can't take care of itself. Comes equipped with built-in surgical equipment and all the medicated lollipops you could ever want.", - "rules": "Asimov, you may tap this card and pay 2 mana: Reset a card's resolve to it's original value.", + "rules": "{$Asimov}, you may tap this card and pay 2 mana: Reset a card's resolve to it's original value.", "icon_state": "borg_medical", "power": "2", "resolve": "3", @@ -739,7 +739,7 @@ "id": "miningborg", "name": "Cyborg (Mining Shell)", "desc": "Fitted with a drill and tracks, the Mining Shell is designed to hold up to the rigours of mining, be that on the hellish surface of Indecipheres, or in the silent vacuum of the asteroid belt.", - "rules": "Asimov, at the end of your turn, if this card is not tapped, you may tap this card at the start of your next turn to gain 1 mana.", + "rules": "{$Asimov}, at the end of your turn, if this card is not tapped, you may tap this card at the start of your next turn to gain 1 mana.", "icon_state": "borg_miner", "power": "3", "resolve": "1", @@ -753,7 +753,7 @@ "id": "monkey", "name": "Monkey", "desc": "Nanotrasen seeks to phase out animal testing by 2570, in accordance with new TerraGov legislation. This will be replaced with more ethical solutions, such as computer simulations, or experimentation on Staff Assistants.", - "rules": "Greytide, this card is considered Human with a Geneticist on your side of the field.", + "rules": "{$Graytide}, this card is considered Human with a Geneticist on your side of the field.", "icon_state": "monkey", "power": "1", "resolve": "1", @@ -767,7 +767,7 @@ "id": "nukeop", "name": "Nuclear Operative", "desc": "The frontline grunts of the syndicate army, Nuclear Operatives are typically well trained and equipped for their grim duty.", - "rules": "Squad Tactics", + "rules": "{$Squad Tactics}", "icon_state": "nukie_red", "power": "4", "resolve": "2", @@ -781,7 +781,7 @@ "id": "paramed", "name": "Paramedic", "desc": "Nanotrasen encourages all paramedics to think of others before themselves- if this means running through a plasma fire to save a colleague, so be it.", - "rules": "Taunt, First Strike", + "rules": "{$Taunt}, {$First Strike}", "icon_state": "paramedic", "power": "2", "resolve": "3", @@ -795,7 +795,7 @@ "id": "peaceborg", "name": "Cyborg (Peacekeeper Shell)", "desc": "After the unilateral phasing out of Security Shells in 2554 following mass reports of cyborg-on-human violence, the Peacekeeper Shell was introduced as a stopgap solution until the problems could be resolved.", - "rules": "Asimov, this card loses -1 power for every creature on your opponent's side of the field", + "rules": "{$Asimov}, this card loses -1 power for every creature on your opponent's side of the field", "icon_state": "borg_peace", "power": "3", "resolve": "3", @@ -809,7 +809,7 @@ "id": "plasma_engi", "name": "Station Engineer (Plasmaman)", "desc": "The ever industrious plasmamen are well suited to engineering work, due to their natural radiation resistance.", - "rules": "Immunity to Battlefields", + "rules": "{$Immunity} to Battlefields", "icon_state": "engi_plasma", "power": "2", "resolve": "4", @@ -851,7 +851,7 @@ "id": "rabbit_pai", "name": "Personal AI Device (Rabbit Shell)", "desc": "Personal AI Devices are able to take the form of many household pets, to provide a homely sense of comfort and companionship to their owners.", - "rules": "This card may steal the Asimov keyword off of another friendly silicon creature.", + "rules": "This card may steal the {$Asimov} keyword off of another friendly silicon creature.", "icon_state": "pai_rabbit", "power": "0", "resolve": "1", @@ -907,7 +907,7 @@ "id": "roboticist", "name": "Roboticist", "desc": "The roboticist's work is as close as Nanotrasen legally allows its employees to come to necromancy.", - "rules": "If a Asimov card on your side of the field is destroyed, you may pay 2 mana and tap this card: Return that card to your hand.", + "rules": "If a {$Asimov} card on your side of the field is destroyed, you may pay 2 mana and tap this card: Return that card to your hand.", "icon_state": "roboticist", "power": "2", "resolve": "2", @@ -949,7 +949,7 @@ "id": "secborg", "name": "Cyborg (Security Shell)", "desc": "Following an incident in 2554, the Security Cyborg Shell was unilaterally phased out and replaced by the Peacekeeper. Nonetheless, many units remain in service with various other organisations such as private militaries.", - "rules": "Asimov, when this card targets a human creature, deal 1 damage to it after the battle resolves.", + "rules": "{$Asimov}, when this card targets a human creature, deal 1 damage to it after the battle resolves.", "icon_state": "borg_sec", "power": "4", "resolve": "2", @@ -963,7 +963,7 @@ "id": "sec_officer", "name": "Security Officer", "desc": "Nanotrasen would like to remind all employees to support their station security team; remember, the boys in red keep you safe!", - "rules": "Squad Tactics", + "rules": "{$Squad Tactics}", "icon_state": "sec", "power": "2", "resolve": "2", @@ -977,7 +977,7 @@ "id": "ratvar_armor", "name": "Ratvarian Clockwork Cuirass", "desc": "Fashioned from paranormally reinforced brass, the Ratvar Cult's clockwork armour is as beautiful as it is heretical.", - "rules": "While equipped, give the equipped unit Clockwork.", + "rules": "While equipped, give the equipped unit {$Clockwork}.", "icon_state": "clock_cultist", "power": "2", "resolve": "2", @@ -991,7 +991,7 @@ "id": "beercanborg", "name": "Cyborg (Service Shell- Beercan)", "desc": "Despite being based on the Medical Shell, this particular Service Shell is tasked with destroying livers, rather than healing them.", - "rules": "Asimov, you may discard this card: draw one Service Faction card from your deck, then shuffle.", + "rules": "{$Asimov}, you may discard this card: draw one Service {$Faction} card from your deck, then shuffle.", "icon_state": "borg_serv_can", "power": "1", "resolve": "1", @@ -1005,7 +1005,7 @@ "id": "flamboyantborg", "name": "Cyborg (Service Shell- Flamboyant)", "desc": "Sometimes a cyborg just needs to show a bit of flamboyance, you know?", - "rules": "Asimov, gains +2/+2 when it's the only card on your side of the field.", + "rules": "{$Asimov}, gains +2/+2 when it's the only card on your side of the field.", "icon_state": "borg_serv_pink", "power": "0", "resolve": "1", @@ -1019,7 +1019,7 @@ "id": "skirtborg", "name": "Cyborg (Service Shell- Skirted)", "desc": "The Service Shell is intended to be the most human of the Cyborg Shells, due to its outwardly social role- none exemplify this better than the Skirted Shell, showing that even robots can't escape fashion norms.", - "rules": "Asimov", + "rules": "{$Asimov}", "icon_state": "borg_serv_skirt", "power": "0", "resolve": "3", @@ -1033,7 +1033,7 @@ "id": "classicborg", "name": "Cyborg (Service Shell- Classic)", "desc": "The classic Service Shell, the Classic Shell is what most crewmembers think of when they think of a 'useless robot that serves drinks'.", - "rules": "Asimov, for every piece of equipment in play, gain +1 temporary resolve during the opponent's turn. That temporary resolve is lost at the start of your turn.", + "rules": "{$Asimov}, for every piece of equipment in play, gain +1 temporary resolve during the opponent's turn. That temporary resolve is lost at the start of your turn.", "icon_state": "borg_serv_suit", "power": "1", "resolve": "1", @@ -1047,7 +1047,7 @@ "id": "stylinborg", "name": "Cyborg (Service Shell- Ritzy)", "desc": "Ooh, isn't this robot one cool cat?", - "rules": "Asimov", + "rules": "{$Asimov}", "icon_state": "borg_serv_tux", "power": "1", "resolve": "2", @@ -1089,7 +1089,7 @@ "id": "swarmer", "name": "Swarmer", "desc": "Leading researchers theorise that Swarmers were designed as some kind of vanguard for an alien invasion force, which seemingly has never materialised.", - "rules": "Greytide, Immunity to Engineering creature cards.", + "rules": "{$Graytide}, {$Immunity} to Engineering creature cards.", "icon_state": "swarmer", "power": "0", "resolve": "1", @@ -1117,7 +1117,7 @@ "id": "warden", "name": "Warden", "desc": "The Warden is tasked with the herculean (and futile) feat of defending the armory and brig, and never leaving his post, no matter the situation.", - "rules": "Squad Tactics, Blocker", + "rules": "{$Squad Tactics}, {$Blocker}", "icon_state": "warden", "power": "2", "resolve": "4", @@ -1131,7 +1131,7 @@ "id": "xeno", "name": "Xenomorph Hunter (2560 Core Set)", "desc": "This particular strain of Xenomorph is capable of semi-transparency, allowing it to strike from the shadows.", - "rules": "Hivemind", + "rules": "{$Hivemind}", "icon_state": "xeno_hunter", "power": "2", "resolve": "3", @@ -1176,7 +1176,7 @@ "id": "glitch_system", "name": "Glitch in the System", "desc": "Even a meticulously maintained AI system will eventually develop errors. Many are benign, but some may cause unforeseen problems...", - "rules": "Select a Creature with Asimov that you control. Remove the Asimov trait from it.", + "rules": "Select a Creature with {$Asimov} that you control. Remove the {$Asimov} trait from it.", "icon_state": "glitch_system", "power": "0", "resolve": "0", @@ -1431,7 +1431,7 @@ "id": "sleeping_carp", "name": "Scroll of the Sleeping Carp", "desc": "Created by the long-extinct Carp Monks of Space Tibet, the Sleeping Carp style has been kept alive by dedicated practitioners, and even found its way into the Syndicate's training regime.", - "rules": "On equip: Your opponent must show you one card in their hand of their choice.", + "rules": "{$On Equip}: Your opponent must show you one card in their hand of their choice.", "icon_state": "sleeping_carp", "power": "3", "resolve": "1", diff --git a/strings/tcg/set_two.json b/strings/tcg/set_two.json index 9fa33bdc552..3620476651f 100644 --- a/strings/tcg/set_two.json +++ b/strings/tcg/set_two.json @@ -11,7 +11,7 @@ "id": "xenoborg", "name": "Xenoborg", "desc": "With a mini-gun in one hand and a rocket launcher in the other, the Xenoborg is a failed hybridization of a Xenomorph and a cyborg.", - "rules": "Asimov. Once per turn, you may sacrifice a silicon card, and pay the difference between that card's summon cost and this card's summon cost to summon this card from your hand.", + "rules": "{$Asimov}. Once per turn, you may sacrifice a silicon card, and pay the difference between that card's summon cost and this card's summon cost to summon this card from your hand.", "icon_state": "xeno_borg", "power": 7, "resolve": 5, @@ -25,7 +25,7 @@ "id": "sentinel", "name": "Xenomorph Sentinel", "desc": "The juices from a Sentinel's neurotoxin gland pair brilliantly with a Pan-Galactic Gargle Blaster.", - "rules": "Hivemind", + "rules": "{$Hivemind}", "icon_state": "xeno_sentinel", "power": 5, "resolve": 3, @@ -39,7 +39,7 @@ "id": "drone", "name": "Xenomorph Drone", "desc": "Rarely seen on the frontlines, the Drone is your average worker that lays the foundation of the hive.", - "rules": "Hivemind. Tap this card: you may summon a Xeno faction creature for 1 less mana this turn.", + "rules": "{$Hivemind}. Tap this card: you may summon a Xeno faction creature for 1 less mana this turn.", "icon_state": "xeno_drone", "power": 1, "resolve": 1, @@ -67,7 +67,7 @@ "id": "spitter", "name": "Xenomorph Spitter", "desc": "While their acid gland is too dangerous to mix with alcohol (not that it stops the marines), a Spitter's acid is useful for industry as it can melt almost anything with ease.", - "rules": "Hivemind. Tap this card: draw the top 2 cards of your deck, you may re-arrange their order, then return them to the top of your deck.", + "rules": "{$Hivemind}. Tap this card: draw the top 2 cards of your deck, you may re-arrange their order, then return them to the top of your deck.", "icon_state": "xeno_spitter", "power": 3, "resolve": 3, @@ -81,7 +81,7 @@ "id": "runner", "name": "Xenomorph Runner", "desc": "Running fast and dying faster, the sheer number of Runner corpses from a battle are enough to keep a research facility busy for years.", - "rules": "First Strike, Hivemind", + "rules": "{$First Strike}, {$Hivemind}", "icon_state": "xeno_runner", "power": 2, "resolve": 3, @@ -95,7 +95,7 @@ "id": "praetorian", "name": "Xenomorph Praetorian", "desc": "The Praetorian is the Queen's royal guard, never seen far from the Queen's chambers.", - "rules": "Hivemind. If you have 2 or more other Xeno cards on your field alongside this card, you may sacrifice 3 Xeno cards and add Xenomorph Queen to your hand from your deck.", + "rules": "{$Hivemind}. If you have 2 or more other Xeno cards on your field alongside this card, you may sacrifice 3 Xeno cards and add Xenomorph Queen to your hand from your deck.", "icon_state": "xeno_praetorian", "power": 3, "resolve": 6, @@ -109,7 +109,7 @@ "id": "hivelord", "name": "Xenomorph Hivelord", "desc": "The Hivelord is the last word in construction, capable of building entire hives in a matter of seconds.", - "rules": "Hivemind. For two mana, tap this card and summon a 0/2 Resin Wall counter creature. Each Resin Wall has Blocker.", + "rules": "{$Hivemind}. For two mana, tap this card and summon a 0/2 Resin Wall counter creature. Each Resin Wall has {$Blocker}.", "icon_state": "xeno_hivelord", "power": 1, "resolve": 3, @@ -123,7 +123,7 @@ "id": "boiler", "name": "Xenomorph Boiler", "desc": "The Boiler is a long-range artillery machine, capable of spewing clouds of acid that melt everything in seconds.", - "rules": "Hivemind. If this card attacks, it is tapped for an additional turn before it is untapped.", + "rules": "{$Hivemind}. If this card attacks, it is tapped for an additional turn before it is untapped.", "icon_state": "xeno_boiler", "power": 6, "resolve": 2, @@ -137,7 +137,7 @@ "id": "ravager", "name": "Xenomorph Ravager", "desc": "With large scythe claws for hands, the furious Ravager goes berserk at the sight of fire.", - "rules": "Hivemind, Fury", + "rules": "{$Hivemind}, {$Fury}", "icon_state": "xeno_ravager", "power": 4, "resolve": 2, @@ -151,7 +151,7 @@ "id": "crusher", "name": "Xenomorph Crusher", "desc": "Extensive testing has revealed that a pulse rifle is unable to penetrate a Crusher's thick armored head.", - "rules": "Hivemind, Blocker", + "rules": "{$Hivemind}, {$Blocker}", "icon_state": "xeno_crusher", "power": 1, "resolve": 6, @@ -165,7 +165,7 @@ "id": "defender", "name": "Xenomorph Defender", "desc": "The Defender's armored head crest makes an excellent makeshift shield.", - "rules": "Hivemind, Blocker", + "rules": "{$Hivemind}, {$Blocker}", "icon_state": "xeno_defender", "power": 1, "resolve": 2, @@ -179,7 +179,7 @@ "id": "warrior", "name": "Xenomorph Warrior", "desc": "Warriors exhibit greater cruelty than other Xeno strains, enjoying snapping a victim's limbs before finishing them off.", - "rules": "Hivemind", + "rules": "{$Hivemind}", "icon_state": "xeno_warrior", "power": 4, "resolve": 4, @@ -193,7 +193,7 @@ "id": "queen", "name": "Xenomorph Queen (Resin Frontier)", "desc": "The ruler of the hive. Organs from a Queen fetch a high price amongst researchers and less-than-moral surgeons.", - "rules": "For 2 mana, tap the equipped creature and summon a 1/1 Xenomorph Brood counter creature, with Hivemind.", + "rules": "For 2 mana, tap the equipped creature and summon a 1/1 Xenomorph Brood counter creature, with {$Hivemind}.", "icon_state": "xeno_queen", "power": 5, "resolve": 5, @@ -207,7 +207,7 @@ "id": "carrier", "name": "Xenomorph Carrier", "desc": "Carriers are like the Easter Bunny except the eggs they hide will kill you.", - "rules": "Hivemind, Squad Tactics", + "rules": "{$Hivemind}, {$Squad Tactics}", "icon_state": "xeno_carrier", "power": 2, "resolve": 2, @@ -221,7 +221,7 @@ "id": "defiler", "name": "Xenomorph Defiler", "desc": "Instead of utilizing eggs, the Defiler prefers to inject an unknown chemical in their victim, causing a devastating infection.", - "rules": "Hivemind. When this card attacks a target enemy creature, calculate damage as though the target creature has this card's power subtracted from it first.", + "rules": "{$Hivemind}. When this card attacks a target enemy creature, calculate damage as though the target creature has this card's power subtracted from it first.", "icon_state": "xeno_defiler", "power": 1, "resolve": 3, @@ -235,7 +235,7 @@ "id": "predalien", "name": "Predalien", "desc": "No concrete information is available on this elusive creature as no one that has seen it has lived to tell the tale.", - "rules": "Hivemind, Changeling", + "rules": "{$Hivemind}, {$Changeling}", "icon_state": "xeno_predalien", "power": 4, "resolve": 3, @@ -249,7 +249,7 @@ "id": "shrike", "name": "Xenomorph Shrike", "desc": "It is unknown why Shrikes are able to lead a Hive, but their hives are always much smaller than a Queen's.", - "rules": "Hivemind. If Xenomorph Queen would be destroyed, you may re-equip Xenomorph Queen on this card instead, once per game.", + "rules": "{$Hivemind}. If Xenomorph Queen would be destroyed, you may re-equip Xenomorph Queen on this card instead, once per game.", "icon_state": "xeno_shrike", "power": 3, "resolve": 1, @@ -263,7 +263,7 @@ "id": "bull", "name": "Xenomorph Bull", "desc": "Popular in illegal rodeos, a Bull's horn can pierce a reinforced wall with ease.", - "rules": "First Strike, Hivemind", + "rules": "{$First Strike}, {$Hivemind}", "icon_state": "xeno_bull", "power": 5, "resolve": 3, @@ -291,7 +291,7 @@ "id": "screecher", "name": "Xenomorph Screecher", "desc": "The Screecher's screeches are more psychologically damaging than the resulting hearing damage.", - "rules": "Deadeye", + "rules": "{$Deadeye}", "icon_state": "xeno_screecher", "power": 3, "resolve": 2, @@ -316,4 +316,4 @@ "rarity": "common" } ] -} \ No newline at end of file +} diff --git a/tgstation.dme b/tgstation.dme index 52039d6b2f0..da7db912e36 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -163,6 +163,7 @@ #include "code\__DEFINES\strippable.dm" #include "code\__DEFINES\subsystems.dm" #include "code\__DEFINES\supermatter.dm" +#include "code\__DEFINES\tcg.dm" #include "code\__DEFINES\text.dm" #include "code\__DEFINES\tgs.config.dm" #include "code\__DEFINES\tgs.dm" diff --git a/tgui/docs/chat-embedded-components.md b/tgui/docs/chat-embedded-components.md new file mode 100644 index 00000000000..f5a5db7aed5 --- /dev/null +++ b/tgui/docs/chat-embedded-components.md @@ -0,0 +1,82 @@ +# Chat Embedded Components + +Have you ever embedded html into tgui chat? Maybe just css stuff like ` `? +Have you ever wanted to embed tgui components instead? For styling or ease of use of course. + +Well we have a system for that! You can pass component information in via html attributes, and it'll be rendered in chat. +How? Let's get into it. + +## How it works + +Here's a sample span that embeds a tooltip around the wrapped text. + +`Does it work?` + +There's two components here, let's break them down. + +### Targeting a component + +Telling tgui chat what component you want to render is really simple. You just embed its name in the data-component attribute. + +You saw it before, but for reference, +`
` +this tells the div to render a tooltip. + +There is a bit of nuance here however. + +We can't embed components that haven't been prewhitelisted. + +This isn't because of security concerns or anything, we just can't lookup components by their name without creating a lookup table. +You can find that in [tgui chat's renderer](../packages/tgui-panel/chat/renderer.js) under the name `TGUI_CHAT_COMPONENTS` + +Adding a new component is simple, just add it's name to the dictionary, and import it into the file. + +### Sending props + +Ok, so we know how to render a component, but that's nearly useless unless we also know how to send extra info alongside it. + +So how's that work? + +The syntax is similar to sending a component, but has a bit more caveats. +We have two bits of info to contend with. The name of the prop, and it's value. +First then, how do you send the name of a prop? + +#### Sending a prop's name + +` intended name. This can also be found in the [renderer file](../packages/tgui-panel/chat/renderer.js) with the name `TGUI_CHAT_ATTRIBUTES_TO_PROPS` + +#### Sending a prop's value + +Setting a prop's value is as simple as giving the data-content attribute a string value. + +This does mean that we can only send strings to javascript. Because of this, we do some parsing js side to pull out other values you may want to send. + +There's three supported datatypes. + +##### **Booleans** + +Booleans are send by sending a string in the form +`data-bool=\"$true\"` and "`data-bool=\"$false\"` +The $ char is included to allow you to send the string "true" without breaking anything. + +Please keep this restriction in mind. + +##### **Numbers** + +Numbers are simpler then the above. If you send a string with no whitespace that can be parsed as an int or float, it will be. + +So `data-int=\"-10\"` will be parsed as `-10` + +`data-float=\"10.2\"` will also be correctly handled, treated as `10.2` + +##### **Strings** + +Strings are the most simple. If a value is passed to an html attribute, and it doesn't meet any of the above requirements, it will be + +`data-string=\"hey man, it works!\"` diff --git a/tgui/packages/tgui-panel/chat/renderer.js b/tgui/packages/tgui-panel/chat/renderer.js index 32a9b74ed04..b9506611605 100644 --- a/tgui/packages/tgui-panel/chat/renderer.js +++ b/tgui/packages/tgui-panel/chat/renderer.js @@ -8,8 +8,10 @@ import { EventEmitter } from 'common/events'; import { classes } from 'common/react'; import { createLogger } from 'tgui/logging'; import { COMBINE_MAX_MESSAGES, COMBINE_MAX_TIME_WINDOW, IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, IMAGE_RETRY_MESSAGE_AGE, MAX_PERSISTED_MESSAGES, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL, MESSAGE_TYPE_UNKNOWN } from './constants'; +import { render } from 'inferno'; import { canPageAcceptType, createMessage, isSameMessage } from './model'; import { highlightNode, linkifyNode } from './replaceInTextNode'; +import { Tooltip } from "../../tgui/components"; const logger = createLogger('chatRenderer'); @@ -17,6 +19,18 @@ const logger = createLogger('chatRenderer'); // that is still trackable. const SCROLL_TRACKING_TOLERANCE = 24; +// List of injectable component names to the actual type +export const TGUI_CHAT_COMPONENTS = { + Tooltip, +}; + +// List of injectable attibute names mapped to their proper prop +// We need this because attibutes don't support lowercase names +export const TGUI_CHAT_ATTRIBUTES_TO_PROPS = { + "position": "position", + "content": "content", +}; + const findNearestScrollableParent = startingNode => { const body = document.body; let node = startingNode; @@ -301,6 +315,51 @@ class ChatRenderer { else { logger.error('Error: message is missing text payload', message); } + // Get all nodes in this message that want to be rendered like jsx + const nodes = node.querySelectorAll("[data-component]"); + for (let i = 0; i < nodes.length; i++) { + const childNode = nodes[i]; + const targetName = childNode.getAttribute("data-component"); + // Let's pull out the attibute info we need + let outputProps = {}; + for (let j = 0; j < childNode.attributes.length; j++) { + const attribute = childNode.attributes[j]; + + let working_value = attribute.nodeValue; + // We can't do the "if it has no value it's truthy" trick + // Because getAttribute returns "", not null. Hate IE + if (working_value === "$true") { + working_value = true; + } + else if (working_value === "$false") { + working_value = false; + } + else if (!isNaN(working_value)) { + const parsed_float = parseFloat(working_value); + if (!isNaN(parsed_float)) { + working_value = parsed_float; + } + } + + let canon_name = attribute.nodeName.replace("data-", ""); + // html attributes don't support upper case chars, so we need to map + canon_name = TGUI_CHAT_ATTRIBUTES_TO_PROPS[canon_name]; + outputProps[canon_name] = working_value; + } + const oldHtml = { __html: childNode.innerHTML }; + while (childNode.firstChild) { + childNode.removeChild(childNode.firstChild); + } + const Element = TGUI_CHAT_COMPONENTS[targetName]; + /* eslint-disable react/no-danger */ + render( + + + , childNode); + /* eslint-enable react/no-danger */ + + } + // Highlight text if (!message.avoidHighlighting && this.highlightRegex) { const highlighted = highlightNode(node, diff --git a/tgui/packages/tgui-panel/styles/goon/chat-dark.scss b/tgui/packages/tgui-panel/styles/goon/chat-dark.scss index 6acf30f6f4c..ddb60d6e802 100644 --- a/tgui/packages/tgui-panel/styles/goon/chat-dark.scss +++ b/tgui/packages/tgui-panel/styles/goon/chat-dark.scss @@ -966,6 +966,7 @@ em { margin-left: 3em; } +<<<<<<< HEAD /* SKYRAT DARK MODE CLASSES */ .mentor {color: #8a2be2;} .looc {color: #d8b555;} @@ -1006,4 +1007,9 @@ em { .pink { color: #ff00ff; font-weight: bold; +======= +.tooltip { + font-style: italic; + border-bottom: 1px dashed #fff; +>>>>>>> 12a90800c51 (Adds tooltips to /tg/c keywords. Adds support for chat embedded tgui components (#65383)) } diff --git a/tgui/packages/tgui-panel/styles/goon/chat-light.scss b/tgui/packages/tgui-panel/styles/goon/chat-light.scss index f9291341392..731c50e4912 100644 --- a/tgui/packages/tgui-panel/styles/goon/chat-light.scss +++ b/tgui/packages/tgui-panel/styles/goon/chat-light.scss @@ -924,6 +924,7 @@ h1.alert, h2.alert { margin-left: 3em; } +<<<<<<< HEAD /* SKYRAT LIGHT MODE CLASSES */ .mentor {color: #8a2be2;} .looc {color: #6699CC; font-weight: bold;} @@ -957,4 +958,9 @@ h1.alert, h2.alert { .pink { color: #ff00ff; font-weight: bold; +======= +.tooltip { + font-style: italic; + border-bottom: 1px dashed #000; +>>>>>>> 12a90800c51 (Adds tooltips to /tg/c keywords. Adds support for chat embedded tgui components (#65383)) }