Files
Grinch-AP/worlds/terraria/Checks.py
Seldom 1873c52aa6 Terraria: 1.4.4 and Calamity support (#3847)
* Terraria integration

* Precollected items for debugging

* Fix item classification

* Golem requires Plantera's Bulb

* Pumpkin Moon requires Dungeon

* Progressive Dungeon

* Reorg, Options.py work

* Items are boss flags

* Removed unused option

* Removed nothing

* Wall, Plantera, and Zenith goals

* Achievements and items

* Fixed The Cavalry and Completely Awesome achievements

* Made "Dead Men Tell No Tales" a grindy achievement

* Some docs, Python 3.8 compat

* docs

* Fix extra item and "Head in the Clouds" being included when achievements are disabled

* Requested changes

* Fix potential thread unsafety, replace Nothing with 50 Silver

* Remove a log

* Corrected heading

* Added incompatible mods list

* In-progress calamity integration

* Terraria events progress

* Rules use events

* Removed an intentional crash I accidentally left in

* Fixed infinite loop

* Moved rules to data file

* Moved item rewards to data file

* Generating from data file

* Fixed broken Mech Boss goal

* Changes Calamity makes to vanilla rules, Calamity final bosses goal

* Added Deerclops, fixed Zenith goal

* Final detailed vanilla pass

* Disable calamity goals

* Typo

* Fixed some reward items not adding to item pool

* In-progress unit test fixes

* Unit test fixes

* `.apworld` compat

* Organized rewards file, made Frog Leg and Fllpper available in vanilla

* Water Walking Boots and Titan Glove rewards

* Add goals to slot data

* Fixed Hammush logic in Post-Mech goal

* Fixed coin rewards

* Updated Terraria docs

* Formatted

* Deathlink in-progress

* Boots of the Hero is grindy

* Fixed zenith goal not placing an item

* Address review

* Gelatin World Tour is grindy

* Difficulty notice

* Switched some achievements' grindiness

* Added "Hey! Listen!" achievement

* Terarria Python 3.8 compat

* Fixed Terraria You and What Army logic

* Calamity minion accessories

* Typo

* Calamity integration

* `deathlink` -> `death_link`

Co-authored-by: Zach Parks <zach@alliware.com>

* Missing `.`

Co-authored-by: Zach Parks <zach@alliware.com>

* Incorrect type annotation

Co-authored-by: Zach Parks <zach@alliware.com>

* `deathlink` -> `death_link` 2

Co-authored-by: Zach Parks <zach@alliware.com>

* Style

Co-authored-by: Zach Parks <zach@alliware.com>

* Markdown style

Co-authored-by: Zach Parks <zach@alliware.com>

* Markdown style 2

Co-authored-by: Zach Parks <zach@alliware.com>

* Address review

* Fix bad merge

* Terraria utility mod recommendations

* Calamity minion armor logic

* ArmorMinions -> Armor Minions, boss rush goal, fixed unplaced item

* Fixed unplaced item

* Started on Terraria 1.4.4

* Crate logic

* getfixedboi, 1.4.4 achievements, shimmer, town slimes, `Rule`, `Condition`, etc

* More clam getfixedboi logic, bar decraft logic, `NotGetfixedboi` -> `Not Getfixedboi`

* Calamity fixes

* Calamity crate ore logic

* Fixed item accessibility not generating in getfixedboi, fixed not generating with incompatible options, fixed grindy function

* Early achievements, separate achievement category options

* Infinity +1 Sword achievement can be location in later goals

* The Frequent Flyer is impossible in Calamity getfixedboi

* Add Enchanted Sword and Starfury for starting inventories

* Don't Dread on Me is redundant in Calamity

* In Calamity getfixedboi, Queen Bee summons enemies who drop Plague Cell Canisters

* Can't use Gelatin Crystal outside Hallow

* You can't get the Terminus without flags

* Typo

* Options difficult warnings

* Robbing the Grave is Hardmode

* Don't reserve an ID for unused Victory item

* Plantera is accessible early in Calamity via Giant Plantera's Bulbs

* Unshuffled Life Crystal and Defender Medal items

* Comment about Midas' Blessing

* Update worlds/terraria/Options.py

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Remove stray expression

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Review suggestions

* Option naming caps consistency, add Laser Drill, Lunatic Cultist alt reqs, fix Eldritch Soul Artifact, Ceaseless Void reqs Dungeon

* Cal Clone doesn't drop Broken Hero Sword anymore, Laser Drill is weaker in Calamity

Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com>

* Fix Acid Rain logic

* Fix XB-∞ Hekate failing accessibility checks (by commenting it out bc it doesn't affect logic)

* Hardmode ores being fishable early in Calamity is not a bug anymore

* Mecha Mayhem is inaccessible in getfixedboi

* Update worlds/terraria/Rules.dsv

Co-authored-by: Seafo <92278897+Seatori@users.noreply.github.com>

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com>
2025-04-15 15:51:05 +02:00

783 lines
24 KiB
Python

from BaseClasses import Item, Location
from typing import Tuple, Union, Set, List, Dict
import string
import pkgutil
class TerrariaItem(Item):
game = "Terraria"
class TerrariaLocation(Location):
game = "Terraria"
def add_token(
tokens: List[Tuple[int, int, Union[str, int, None]]],
token: Union[str, int],
token_index: int,
):
if token == "":
return
if type(token) == str:
tokens.append((token_index, 0, token.rstrip()))
elif type(token) == int:
tokens.append((token_index, 1, token))
IDENT = 0
NUM = 1
SEMI = 2
HASH = 3
AT = 4
NOT = 5
AND = 6
OR = 7
LPAREN = 8
RPAREN = 9
END_OF_LINE = 10
CHAR_TO_TOKEN_ID = {
";": SEMI,
"#": HASH,
"@": AT,
"~": NOT,
"&": AND,
"|": OR,
"(": LPAREN,
")": RPAREN,
}
TOKEN_ID_TO_CHAR = {id: char for char, id in CHAR_TO_TOKEN_ID.items()}
def tokens(rule: str) -> List[Tuple[int, int, Union[str, int, None]]]:
token_list = []
token = ""
token_index = 0
escaped = False
for index, char in enumerate(rule):
if escaped:
if token == "":
token_index = index
token += char
escaped = False
elif char == "\\":
if type(token) == int:
add_token(token_list, token, token_index)
token = ""
escaped = True
elif char == "/" and token.endswith("/"):
add_token(token_list, token[:-1], token_index)
return token_list
elif token == "" and char.isspace():
pass
elif token == "" and char.isdigit():
token_index = index
token = int(char)
elif type(token) == int and char.isdigit():
token = token * 10 + int(char)
elif (id := CHAR_TO_TOKEN_ID.get(char)) != None:
add_token(token_list, token, token_index)
token_list.append((index, id, None))
token = ""
else:
if token == "":
token_index = index
token += char
add_token(token_list, token, token_index)
return token_list
NAME = 0
NAME_SEMI = 1
FLAG_OR_SEMI = 2
POST_FLAG = 3
FLAG = 4
FLAG_ARG = 5
FLAG_ARG_END = 6
COND_OR_SEMI = 7
POST_COND = 8
COND = 9
LOC = 10
FN = 11
POST_FN = 12
FN_ARG = 13
FN_ARG_END = 14
END = 15
GOAL = 16
POS_FMT = [
"name or `#`",
"`;`",
"flag or `;`",
"`;`, `|`, or `(`",
"flag",
"text or number",
"`)`",
"name, `#`, `@`, `~`, `(`, or `;`",
"`;`, `&`, `|`, or `)`",
"name, `#`, `@`, `~`, or `(`",
"name",
"name",
"`(`, `;`, `&`, `|`, or `)`",
"text or number",
"`)`",
"end of line",
"goal",
]
RWD_NAME = 0
RWD_NAME_SEMI = 1
RWD_FLAG = 2
RWD_FLAG_SEMI = 3
RWD_END = 4
RWD_LABEL = 5
RWD_POS_FMT = ["name or `#`", "`;`", "flag", "`;`", "end of line", "name"]
def unexpected(line: int, char: int, id: int, token, pos, pos_fmt, file):
if id == IDENT or id == NUM:
token_fmt = f"`{token}`"
elif id == END_OF_LINE:
token_fmt = "end of line"
else:
token_fmt = f"`{TOKEN_ID_TO_CHAR[id]}`"
raise Exception(
f"in `{file}`, found {token_fmt} at {line + 1}:{char + 1}; expected {pos_fmt[pos]}"
)
COND_ITEM = 0
COND_LOC = 1
COND_FN = 2
COND_GROUP = 3
class Condition:
def __init__(
self,
# True = positive, False = negative
sign: bool,
# See the `COND_*` constants
type: int,
# Condition name or list
condition: Union[str, Tuple[Union[bool, None], List["Condition"]]],
argument: Union[str, int, None],
):
self.sign = sign
self.type = type
self.condition = condition
self.argument = argument
class Rule:
def __init__(
self,
name: str,
# Name to arg
flags: Dict[str, Union[str, int, None]],
# True = or, False = and, None = N/A
operator: Union[bool, None],
conditions: List[Condition],
):
self.name = name
self.flags = flags
self.operator = operator
self.conditions = conditions
def validate_conditions(
rule: str,
rule_indices: dict,
conditions: List[Condition],
):
for condition in conditions:
if condition.type == COND_ITEM:
if condition.condition not in rule_indices:
raise Exception(
f"item `{condition.condition}` in `{rule}` is not defined"
)
elif condition.type == COND_LOC:
if condition.condition not in rule_indices:
raise Exception(
f"location `{condition.condition}` in `{rule}` is not defined"
)
elif condition.type == COND_FN:
if condition.condition not in {
"npc",
"calamity",
"grindy",
"pickaxe",
"hammer",
"mech_boss",
"minions",
"getfixedboi",
}:
raise Exception(
f"function `{condition.condition}` in `{rule}` is not defined"
)
elif condition.type == COND_GROUP:
_, conditions = condition.condition
validate_conditions(rule, rule_indices, conditions)
def mark_progression(
conditions: List[Condition],
progression: Set[str],
rules: list,
rule_indices: dict,
loc_to_item: dict,
):
for condition in conditions:
if condition.type == COND_ITEM:
prog = condition.condition in progression
progression.add(loc_to_item[condition.condition])
rule = rules[rule_indices[condition.condition]]
if (
not prog
and "Achievement" not in rule.flags
and "Location" not in rule.flags
and "Item" not in rule.flags
):
mark_progression(
rule.conditions, progression, rules, rule_indices, loc_to_item
)
elif condition.type == COND_LOC:
mark_progression(
rules[rule_indices[condition.condition]].conditions,
progression,
rules,
rule_indices,
loc_to_item,
)
elif condition.type == COND_GROUP:
_, conditions = condition.condition
mark_progression(conditions, progression, rules, rule_indices, loc_to_item)
def read_data() -> Tuple[
# Goal to rule index that ends that goal's range and the locations required
List[Tuple[int, Set[str]]],
# Rules
List[Rule],
# Rule to rule index
Dict[str, int],
# Label to rewards
Dict[str, List[str]],
# Reward to flags
Dict[str, Set[str]],
# Item name to ID
Dict[str, int],
# Location name to ID
Dict[str, int],
# NPCs
List[str],
# Pickaxe to pick power
Dict[str, int],
# Hammer to hammer power
Dict[str, int],
# Mechanical bosses
List[str],
# Calamity final bosses
List[str],
# Progression rules
Set[str],
# Armor to minion count,
Dict[str, int],
# Accessory to minion count,
Dict[str, int],
]:
next_id = 0x7E0000
item_name_to_id = {}
goals = []
goal_indices = {}
rules = []
rule_indices = {}
loc_to_item = {}
npcs = []
pickaxes = {}
hammers = {}
mech_boss_loc = []
mech_bosses = []
final_boss_loc = []
final_bosses = []
armor_minions = {}
accessory_minions = {}
progression = set()
for line, rule in enumerate(
pkgutil.get_data(__name__, "Rules.dsv").decode().splitlines()
):
goal = None
name = None
flags = {}
sign = True
operator = None
outer = []
conditions = []
pos = NAME
for char, id, token in tokens(rule):
if pos == NAME:
if id == IDENT:
name = token
pos = NAME_SEMI
elif id == HASH:
pos = GOAL
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == NAME_SEMI:
if id == SEMI:
pos = FLAG_OR_SEMI
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FLAG_OR_SEMI:
if id == IDENT:
flag = token
pos = POST_FLAG
elif id == SEMI:
pos = COND_OR_SEMI
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == POST_FLAG:
if id == SEMI:
if flag is not None:
if flag in flags:
raise Exception(
f"set flag `{flag}` at {line + 1}:{char + 1} that was already set"
)
flags[flag] = None
flag = None
pos = COND_OR_SEMI
elif id == OR:
pos = FLAG
elif id == LPAREN:
pos = FLAG_ARG
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FLAG:
if id == IDENT:
if flag is not None:
if flag in flags:
raise Exception(
f"set flag `{flag}` at {line + 1}:{char + 1} that was already set"
)
flags[flag] = None
flag = None
flag = token
pos = POST_FLAG
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FLAG_ARG:
if id == IDENT or id == NUM:
if flag in flags:
raise Exception(
f"set flag `{flag}` at {line + 1}:{char + 1} that was already set"
)
flags[flag] = token
flag = None
pos = FLAG_ARG_END
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FLAG_ARG_END:
if id == RPAREN:
pos = POST_FLAG
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == COND_OR_SEMI:
if id == IDENT:
conditions.append(Condition(sign, COND_ITEM, token, None))
sign = True
pos = POST_COND
elif id == HASH:
pos = LOC
elif id == AT:
pos = FN
elif id == NOT:
sign = not sign
pos = COND
elif id == LPAREN:
outer.append((sign, None, conditions))
conditions = []
sign = True
pos = COND
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == POST_COND:
if id == SEMI:
if outer:
raise Exception(
f"found `;` at {line + 1}:{char + 1} after unclosed `(`"
)
pos = END
elif id == AND:
if operator is True:
raise Exception(
f"found `&` at {line + 1}:{char + 1} in group containing `|`"
)
operator = False
pos = COND
elif id == OR:
if operator is False:
raise Exception(
f"found `|` at {line + 1}:{char + 1} in group containing `&`"
)
operator = True
pos = COND
elif id == RPAREN:
if not outer:
raise Exception(
f"found `)` at {line + 1}:{char + 1} without matching `(`"
)
condition = operator, conditions
sign, operator, conditions = outer.pop()
conditions.append(Condition(sign, COND_GROUP, condition, None))
sign = True
pos = POST_COND
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == COND:
if id == IDENT:
conditions.append(Condition(sign, COND_ITEM, token, None))
sign = True
pos = POST_COND
elif id == HASH:
pos = LOC
elif id == AT:
pos = FN
elif id == NOT:
sign = not sign
elif id == LPAREN:
outer.append((sign, operator, conditions))
conditions = []
sign = True
operator = None
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == LOC:
if id == IDENT:
conditions.append(Condition(sign, COND_LOC, token, None))
sign = True
pos = POST_COND
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FN:
if id == IDENT:
function = token
pos = POST_FN
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == POST_FN:
if id == LPAREN:
pos = FN_ARG
elif id == SEMI:
conditions.append(Condition(sign, COND_FN, function, None))
pos = END
elif id == AND:
conditions.append(Condition(sign, COND_FN, function, None))
sign = True
if operator is True:
raise Exception(
f"found `&` at {line + 1}:{char + 1} in group containing `|`"
)
operator = False
pos = COND
elif id == OR:
conditions.append(Condition(sign, COND_FN, function, None))
sign = True
if operator is False:
raise Exception(
f"found `|` at {line + 1}:{char + 1} in group containing `&`"
)
operator = True
pos = COND
elif id == RPAREN:
conditions.append(Condition(sign, COND_FN, function, None))
if not outer:
raise Exception(
f"found `)` at {line + 1}:{char + 1} without matching `(`"
)
condition = operator, conditions
sign, operator, conditions = outer.pop()
conditions.append(Condition(sign, COND_GROUP, condition, None))
sign = True
pos = POST_COND
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FN_ARG:
if id == IDENT or id == NUM:
conditions.append(Condition(sign, COND_FN, function, token))
sign = True
pos = FN_ARG_END
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == FN_ARG_END:
if id == RPAREN:
pos = POST_COND
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
elif pos == END:
unexpected(line, char, id, token, pos)
elif pos == GOAL:
if id == IDENT:
goal = token
pos = END
else:
unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv")
if pos != NAME and pos != FLAG_OR_SEMI and pos != COND_OR_SEMI and pos != END:
unexpected(line, char + 1, END_OF_LINE, None, pos, POS_FMT, "Rules.dsv")
if name:
if name in rule_indices:
raise Exception(
f"rule `{name}` on line `{line + 1}` shadows a previous rule"
)
rule_indices[name] = len(rules)
rules.append(Rule(name, flags, operator, conditions))
for flag in flags:
if flag not in {
"Location",
"Item",
"Goal",
"Early",
"Achievement",
"Grindy",
"Fishing",
"Npc",
"Pickaxe",
"Hammer",
"Minions",
"Armor Minions",
"Mech Boss",
"Final Boss",
"Getfixedboi",
"Not Getfixedboi",
"Calamity",
"Not Calamity",
"Not Calamity Getfixedboi",
}:
raise Exception(
f"rule `{name}` on line `{line + 1}` has unrecognized flag `{flag}`"
)
if "Item" in flags:
item_name = flags["Item"] or f"Post-{name}"
if item_name in item_name_to_id:
raise Exception(
f"item `{item_name}` on line `{line + 1}` shadows a previous item"
)
item_name_to_id[item_name] = next_id
next_id += 1
loc_to_item[name] = item_name
else:
loc_to_item[name] = name
if "Npc" in flags:
npcs.append(name)
if (power := flags.get("Pickaxe")) is not None:
pickaxes[name] = power
if (power := flags.get("Hammer")) is not None:
hammers[name] = power
if "Mech Boss" in flags:
mech_bosses.append(flags["Item"] or f"Post-{name}")
mech_boss_loc.append(name)
if "Final Boss" in flags:
final_bosses.append(flags["Item"] or f"Post-{name}")
final_boss_loc.append(name)
if (minions := flags.get("Armor Minions")) is not None:
armor_minions[name] = minions
if (minions := flags.get("Minions")) is not None:
accessory_minions[name] = minions
if goal:
if goal in goal_indices:
raise Exception(
f"goal `{goal}` on line `{line + 1}` shadows a previous goal"
)
goal_indices[goal] = len(goals)
goals.append((len(rules), set()))
for rule in rules:
if "Goal" in rule.flags:
if (name := rule.flags.get("Goal")) is not None:
goal_name = name
else:
goal_name = (
rule.name.translate(str.maketrans("", "", string.punctuation))
.replace(" ", "_")
.lower()
)
_, items = goals[goal_indices[goal_name]]
items.add(rule.name)
_, mech_boss_items = goals[goal_indices["mechanical_bosses"]]
mech_boss_items.update(mech_boss_loc)
_, final_boss_items = goals[goal_indices["calamity_final_bosses"]]
final_boss_items.update(final_boss_loc)
for rule in rules:
validate_conditions(rule.name, rule_indices, rule.conditions)
for rule in rules:
prog = False
if (
"Npc" in rule.flags
or "Goal" in rule.flags
or "Pickaxe" in rule.flags
or "Hammer" in rule.flags
or "Mech Boss" in rule.flags
or "Final Boss" in rule.flags
or "Minions" in rule.flags
or "Armor Minions" in rule.flags
):
progression.add(loc_to_item[rule.name])
prog = True
if prog or "Location" in rule.flags or "Achievement" in rule.flags:
mark_progression(
rule.conditions, progression, rules, rule_indices, loc_to_item
)
# Will be randomized via `slot_randoms` / `self.multiworld.random`
label = None
labels = {}
rewards = {}
for line in pkgutil.get_data(__name__, "Rewards.dsv").decode().splitlines():
reward = None
flags = set()
pos = RWD_NAME
for char, id, token in tokens(line):
if pos == RWD_NAME:
if id == IDENT:
reward = f"Reward: {token}"
pos = RWD_NAME_SEMI
elif id == HASH:
pos = RWD_LABEL
else:
unexpected(line, char, id, token, pos, RWD_POS_FMT, "Rewards.dsv")
elif pos == RWD_NAME_SEMI:
if id == SEMI:
pos = RWD_FLAG
else:
unexpected(line, char, id, token, pos, RWD_POS_FMT, "Rewards.dsv")
elif pos == RWD_FLAG:
if id == IDENT:
if token in flags:
raise Exception(
f"set flag `{token}` at {line + 1}:{char + 1} that was already set"
)
flags.add(token)
pos = RWD_FLAG_SEMI
else:
unexpected(line, char, id, token, pos, RWD_POS_FMT, "Rewards.dsv")
elif pos == RWD_FLAG_SEMI:
if id == SEMI:
pos = RWD_END
else:
unexpected(line, char, id, token, pos, RWD_POS_FMT, "Rewards.dsv")
elif pos == RWD_END:
unexpected(line, char, id, token, pos, RWD_POS_FMT, "Rewards.dsv")
elif pos == RWD_LABEL:
if id == IDENT:
label = token
if label in labels:
raise Exception(
f"started label `{label}` at {line + 1}:{char + 1} that was already used"
)
labels[label] = []
pos = RWD_END
else:
unexpected(line, char, id, token, pos, RWD_POS_FMT, "Rewards.dsv")
if pos != RWD_NAME and pos != RWD_FLAG and pos != RWD_END:
unexpected(line, char + 1, END_OF_LINE, None, pos)
if reward:
if reward in rewards:
raise Exception(
f"reward `{reward}` on line `{line + 1}` shadows a previous reward"
)
rewards[reward] = flags
if not label:
raise Exception(
f"reward `{reward}` on line `{line + 1}` is not labeled"
)
labels[label].append(reward)
if reward in item_name_to_id:
raise Exception(
f"item `{reward}` on line `{line + 1}` shadows a previous item"
)
item_name_to_id[reward] = next_id
next_id += 1
item_name_to_id["Reward: Coins"] = next_id
next_id += 1
location_name_to_id = {}
for rule in rules:
if "Location" in rule.flags or "Achievement" in rule.flags:
if rule.name in location_name_to_id:
raise Exception(f"location `{rule.name}` shadows a previous location")
location_name_to_id[rule.name] = next_id
next_id += 1
return (
goals,
rules,
rule_indices,
labels,
rewards,
item_name_to_id,
location_name_to_id,
npcs,
pickaxes,
hammers,
mech_bosses,
final_bosses,
progression,
armor_minions,
accessory_minions,
)
(
goals,
rules,
rule_indices,
labels,
rewards,
item_name_to_id,
location_name_to_id,
npcs,
pickaxes,
hammers,
mech_bosses,
final_bosses,
progression,
armor_minions,
accessory_minions,
) = read_data()