466 lines
17 KiB
GDScript
466 lines
17 KiB
GDScript
class_name Profile
|
|
extends RefCounted
|
|
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
|
|
## Man startet mit allen neutralen Karten (Stapel: 30 nach Manakurve); die Elemente schaltet die Kampagne frei (je Kapitel einen
|
|
## Booster), die Werkstatt bringt den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
|
|
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
|
|
|
|
const SAVE_VERSION := 8
|
|
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
|
|
const SAVE_PATH := "user://spielstand.json"
|
|
const BOOSTER_COST := 100 # neutraler Booster
|
|
const ELEMENT_BOOSTER_COST := 200 # Booster eines Elements (ab seinem Kapitel)
|
|
const BOOSTER_SIZE := 5
|
|
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
|
|
const START_COINS := 150
|
|
# Münzen nach dem Kampf: je eigener Zug, je ausgespielte Karte und je Zug unter FAST_TURN_SEC
|
|
# Sekunden; ein Sieg verdoppelt alles, die Schwierigkeit multipliziert (siehe battle_coins).
|
|
const COINS_TURN := 5
|
|
const COINS_CARD := 5
|
|
const COINS_FAST := 5
|
|
const FAST_TURN_SEC := 15.0
|
|
# Schwierigkeit im Einzelspieler: Stufe der Gegnerkarten relativ zu deiner Deckstärke (level),
|
|
# KI (smart: Vorausschau, sonst gierig), ein zufälliges Merkmal (perk) und der Münzfaktor.
|
|
const DIFFICULTIES := {
|
|
"leicht": {"name": "Leicht", "level": -1, "smart": false, "perk": false, "reward": 1.0},
|
|
"normal": {"name": "Normal", "level": 0, "smart": true, "perk": false, "reward": 1.5},
|
|
"schwer": {"name": "Schwer", "level": 1, "smart": true, "perk": true, "reward": 2.0},
|
|
}
|
|
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
|
|
const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
|
|
const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
|
|
|
|
var coins := 0
|
|
var dust := 0
|
|
var xp := 0 # Erfahrung: alle je verdienten Münzen (Skills.level_for)
|
|
var customs: Array = [] # Eigenbauten aus der Werkstatt (Card.to_dict), beim Laden registriert
|
|
var skills := {} # Skill-ID -> gelernte Stufe (Skills.SKILLS)
|
|
var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
|
|
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
|
|
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
|
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
|
|
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
|
|
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
|
|
|
|
|
## Neues Profil: alle neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel.
|
|
static func starter() -> Profile:
|
|
var p := Profile.new()
|
|
p.coins = START_COINS
|
|
p.give_start()
|
|
return p
|
|
|
|
|
|
## Alle neutralen Karten in Common werden die Sammlung, ein Kurvenstapel daraus der Stapel.
|
|
func give_start() -> void:
|
|
collection = {}
|
|
for id in DeckGen.element_deck(Cards.NEUTRAL):
|
|
collection[id] = 0
|
|
deck = DeckGen.curve_deck(Cards.NEUTRAL, Game.make_rng())
|
|
|
|
|
|
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
|
|
static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
|
for candidate in [path, path + ".bak"]:
|
|
if FileAccess.file_exists(candidate):
|
|
var json := JSON.new()
|
|
if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary:
|
|
return from_dict(json.data)
|
|
if FileAccess.file_exists(path):
|
|
DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt")
|
|
var p := starter()
|
|
p.save(path)
|
|
return p
|
|
|
|
|
|
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
|
|
## Einstellungen und bekommen die Startsammlung.
|
|
static func from_dict(d: Dictionary) -> Profile:
|
|
var p := Profile.new()
|
|
p.coins = int(d.get("coins", 0))
|
|
p.dust = int(d.get("dust", 0))
|
|
p.xp = int(d.get("xp", 0))
|
|
var sk = d.get("skills", {})
|
|
for id in sk:
|
|
if Skills.SKILLS.has(id):
|
|
p.skills[id] = clampi(int(sk[id]), 0, int(Skills.SKILLS[id].max))
|
|
p.campaign_stage = int(d.get("campaign_stage", 0))
|
|
for cd in d.get("customs", []):
|
|
if cd is Dictionary and cd.has("id") and str(cd.id).begins_with("c_"):
|
|
var c := Cards.Card.from_dict(cd)
|
|
Cards.register(c)
|
|
p.customs.append(c.to_dict())
|
|
if int(d.get("version", 1)) >= SAVE_VERSION:
|
|
var coll = d.get("collection", {})
|
|
for cid in coll:
|
|
if Cards.has_id(cid):
|
|
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
|
|
var ids := []
|
|
for cid in d.get("deck", []):
|
|
if p.collection.has(cid) and not (cid in ids):
|
|
ids.append(cid)
|
|
p.deck = ids.slice(0, Game.DECK_MAX)
|
|
if p.collection.is_empty():
|
|
p.give_start()
|
|
var se = d.get("settings", {})
|
|
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
|
|
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
|
|
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
|
|
"big_font": bool(se.get("big_font", false))}
|
|
if not DIFFICULTIES.has(p.settings.difficulty):
|
|
p.settings.difficulty = "normal"
|
|
p.perk = str(d.get("perk", ""))
|
|
if not Game.PERKS.has(p.perk):
|
|
p.perk = ""
|
|
return p
|
|
|
|
|
|
func to_dict() -> Dictionary:
|
|
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "xp": xp, "skills": skills, "customs": customs, "collection": collection,
|
|
"deck": deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
|
|
|
|
|
|
## Speichert und sichert den vorherigen Stand als .bak.
|
|
func save(path: String = SAVE_PATH) -> void:
|
|
var abs := ProjectSettings.globalize_path(path)
|
|
if FileAccess.file_exists(path):
|
|
DirAccess.copy_absolute(abs, abs + ".bak")
|
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
|
f.store_string(JSON.stringify(to_dict(), "\t"))
|
|
f.close()
|
|
|
|
|
|
func take_notices() -> Array[String]:
|
|
var out := notices.duplicate()
|
|
notices.clear()
|
|
return out
|
|
|
|
|
|
# --- Sammlung und Stapel -----------------------------------------------------------
|
|
|
|
func owns(cid: String) -> bool:
|
|
return collection.has(cid)
|
|
|
|
|
|
func level(cid: String) -> int:
|
|
return int(collection.get(cid, 0))
|
|
|
|
|
|
func rarity(cid: String) -> String:
|
|
return Cards.RARITIES[level(cid)]
|
|
|
|
|
|
## Karten-ID -> Stufe für den Kampf (nur aufgewertete Karten, siehe Game.Player.looks).
|
|
func looks() -> Dictionary:
|
|
var out := {}
|
|
for cid in collection:
|
|
if int(collection[cid]) > 0:
|
|
out[cid] = int(collection[cid])
|
|
return out
|
|
|
|
|
|
## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
|
|
## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
|
|
func deck_cards() -> Array:
|
|
return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN, collection)
|
|
|
|
|
|
## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
|
|
func toggle_deck(cid: String) -> String:
|
|
if cid in deck:
|
|
deck.erase(cid)
|
|
return ""
|
|
if not owns(cid):
|
|
return "Karte nicht in der Sammlung"
|
|
if deck.size() >= Game.DECK_MAX:
|
|
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
|
|
deck.append(cid)
|
|
return ""
|
|
|
|
|
|
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
|
|
func upgrade(cid: String) -> String:
|
|
if not owns(cid):
|
|
return "Karte nicht in der Sammlung"
|
|
var lvl := level(cid)
|
|
if lvl >= Cards.RARITIES.size() - 1:
|
|
return "Schon auf der höchsten Stufe"
|
|
var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
|
|
if dust < cost:
|
|
return "Nicht genug Staub (%d nötig)" % cost
|
|
dust -= cost
|
|
collection[cid] = lvl + 1
|
|
return ""
|
|
|
|
|
|
## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
|
|
## Gibt "" zurück oder eine Fehlermeldung.
|
|
func dismantle(cid: String) -> String:
|
|
if not owns(cid):
|
|
return "Karte nicht in der Sammlung"
|
|
var lvl := level(cid)
|
|
dust += dust_gain(DUST_DISMANTLE[Cards.RARITIES[lvl]])
|
|
if lvl == 0:
|
|
collection.erase(cid)
|
|
deck.erase(cid)
|
|
else:
|
|
collection[cid] = lvl - 1
|
|
return ""
|
|
|
|
|
|
## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung.
|
|
func craft(cid: String) -> String:
|
|
if owns(cid):
|
|
return "Karte schon in der Sammlung"
|
|
if dust < CRAFT_COST:
|
|
return "Nicht genug Staub (%d nötig)" % CRAFT_COST
|
|
dust -= CRAFT_COST
|
|
collection[cid] = 0
|
|
return ""
|
|
|
|
|
|
# --- Booster ----------------------------------------------------------------------
|
|
|
|
## Preis eines Boosters: neutral BOOSTER_COST, je Element ELEMENT_BOOSTER_COST.
|
|
static func booster_cost(element: String) -> int:
|
|
return BOOSTER_COST if element == Cards.NEUTRAL else ELEMENT_BOOSTER_COST
|
|
|
|
|
|
## Der Booster eines Elements ist zu haben, sobald sein Kampagnenkapitel offen ist (neutral immer).
|
|
func booster_unlocked(element: String) -> bool:
|
|
return campaign_stage >= Campaign.STAGES_PER_CHAPTER * Campaign.CHAPTER_ELEMENTS.find(element)
|
|
|
|
|
|
## Fünf Karten des Elements mit gewürfelter Seltenheit; luck (Packglück) verschiebt die Chancen,
|
|
## mindestens eine Karte hat die Seltenheit floor (Rare, mit Garantie Super Rare).
|
|
static func roll_booster(r: RandomNumberGenerator, element: String, luck: int = 0, floor: String = "Rare") -> Array:
|
|
var rarities := RARITY_WEIGHTS.keys()
|
|
var weights := Skills.pack_weights(luck)
|
|
var pool := Cards.all().filter(func(c): return c.elements[0] == element)
|
|
var out := []
|
|
for i in BOOSTER_SIZE:
|
|
var rar: String = rarities[r.rand_weighted(weights)]
|
|
if i == BOOSTER_SIZE - 1 and out.all(func(x): return Cards.RARITIES.find(x.rarity) < Cards.RARITIES.find(floor)):
|
|
rar = floor
|
|
out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar})
|
|
return out
|
|
|
|
|
|
## Öffnet einen Booster des Elements. Neue Karten kommen in Sammlung und Stapel (solange Platz ist).
|
|
## Von jeder Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine
|
|
## gleiche oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn der Booster
|
|
## noch gesperrt ist oder die Münzen nicht reichen.
|
|
func open_booster(element: String, r: RandomNumberGenerator = null) -> Array:
|
|
return open_boosters(element, 1, r)
|
|
|
|
|
|
## Öffnet n Booster des Elements auf einmal; leer, wenn gesperrt oder die Münzen für alle nicht reichen.
|
|
func open_boosters(element: String, n: int, r: RandomNumberGenerator = null) -> Array:
|
|
if not booster_unlocked(element) or coins < pack_price(element) * n:
|
|
return []
|
|
if r == null:
|
|
r = Game.make_rng()
|
|
var out := []
|
|
for i in n:
|
|
out += _open_one(element, r)
|
|
return out
|
|
|
|
|
|
func _open_one(element: String, r: RandomNumberGenerator) -> Array:
|
|
coins -= pack_price(element)
|
|
var out := roll_booster(r, element, skill("packglueck"), Skills.pack_floor(skill("garantie")))
|
|
for res in out:
|
|
var lvl: int = Cards.RARITIES.find(res.rarity)
|
|
res["new"] = not owns(res.card.id)
|
|
res["upgraded"] = false
|
|
res["dust"] = 0
|
|
if res.new:
|
|
collection[res.card.id] = lvl
|
|
if deck.size() < Game.DECK_MAX:
|
|
deck.append(res.card.id)
|
|
elif lvl > level(res.card.id):
|
|
res["upgraded"] = true
|
|
res["dust"] = dust_gain(DUST_DISMANTLE[rarity(res.card.id)])
|
|
collection[res.card.id] = lvl
|
|
else:
|
|
res["dust"] = dust_gain(DUST_DISMANTLE[res.rarity])
|
|
dust += res.dust
|
|
return out
|
|
|
|
|
|
# --- Kampf und Kampagne -----------------------------------------------------------
|
|
|
|
## Münzen eines Kampfes: COINS_TURN je eigenem Zug, COINS_CARD je ausgespielter Karte, COINS_FAST
|
|
## je schnellem Zug; ein Sieg verdoppelt, factor ist der Münzfaktor der Schwierigkeit.
|
|
static func battle_coins(won: bool, turns: int, cards: int, fast: int, factor: float = 1.0) -> int:
|
|
var pts := COINS_TURN * turns + COINS_CARD * cards + COINS_FAST * fast
|
|
if won:
|
|
pts *= 2
|
|
return int(round(pts * factor))
|
|
|
|
|
|
## Verbucht ein Kampfergebnis mit den verdienten Münzen (battle_coins, mal Münzsegen) und gibt die
|
|
## Gutschrift zurück; sie zählt auch als Erfahrung, ein neues Level meldet sich. stage >= 0:
|
|
## Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei und bringt ihre Belohnung obendrauf.
|
|
func record_battle(won: bool, earned: int, stage: int = -1) -> int:
|
|
var reward := int(round(earned * coin_factor()))
|
|
if won and stage >= 0 and stage == campaign_stage:
|
|
campaign_stage += 1
|
|
reward += Campaign.stage(stage).reward
|
|
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
|
|
coins += reward
|
|
var before := player_level()
|
|
xp += reward
|
|
if player_level() > before:
|
|
var gained := player_level() - before
|
|
notices.append(("Level %d erreicht: +%d Skillpunkt" if gained == 1 else "Level %d erreicht: +%d Skillpunkte") % [player_level(), gained])
|
|
return reward
|
|
|
|
|
|
# --- Skilltree ----------------------------------------------------------------------
|
|
|
|
## Spielerlevel aus der Erfahrung (nicht die Kartenstufe level(cid)).
|
|
func player_level() -> int:
|
|
return Skills.level_for(xp)
|
|
|
|
|
|
## Gelernte Stufe eines Skills.
|
|
func skill(id: String) -> int:
|
|
return int(skills.get(id, 0))
|
|
|
|
|
|
## Freie Skillpunkte: ein Punkt je Level, abzüglich der gelernten Stufen.
|
|
func skill_points() -> int:
|
|
var spent := 0
|
|
for id in skills:
|
|
spent += int(skills[id])
|
|
return player_level() - spent
|
|
|
|
|
|
## Lernt die nächste Stufe eines Skills für einen Punkt; der Skill davor im Pfad braucht eine Stufe.
|
|
## Gibt "" zurück oder eine Fehlermeldung.
|
|
func learn(id: String) -> String:
|
|
if skill(id) >= int(Skills.SKILLS[id].max):
|
|
return "Schon auf der höchsten Stufe"
|
|
var prev := Skills.previous(id)
|
|
if prev != "" and skill(prev) == 0:
|
|
return "Erst den Skill davor lernen"
|
|
if skill_points() <= 0:
|
|
return "Kein Skillpunkt frei"
|
|
skills[id] = skill(id) + 1
|
|
return ""
|
|
|
|
|
|
## Preis eines Boosters mit Händler-Rabatt.
|
|
func pack_price(element: String) -> int:
|
|
return int(round(booster_cost(element) * (1.0 - 0.1 * skill("packpreis"))))
|
|
|
|
|
|
func coin_factor() -> float:
|
|
return 1.0 + 0.1 * skill("muenzen")
|
|
|
|
|
|
## Staub mit Staubsammler-Bonus.
|
|
func dust_gain(base: int) -> int:
|
|
return int(round(base * (1.0 + 0.25 * skill("staub"))))
|
|
|
|
|
|
## Merkmale im Kampf: das gewählte (nach Kapitel 1) und die der Kampfskills.
|
|
func battle_perks() -> Array:
|
|
var out := []
|
|
if perk != "" and perk_unlocked():
|
|
out.append(perk)
|
|
for id in Skills.PERK_OF:
|
|
if skill(id) > 0 and not (Skills.PERK_OF[id] in out):
|
|
out.append(Skills.PERK_OF[id])
|
|
return out
|
|
|
|
|
|
func difficulty() -> Dictionary:
|
|
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
|
|
|
|
|
|
## Deine Stufe: die Deckstärke des Kampfstapels (100 Common … 500 Ultimate Rare) als Seltenheitsstufe.
|
|
func deck_level() -> int:
|
|
return clampi(roundi((DeckGen.strength(deck_cards(), collection) - 100) / 100.0), 0, Cards.RARITIES.size() - 1)
|
|
|
|
|
|
## Stufe der Gegnerkarten im Einzelspieler: deine Stufe plus den Versatz der Schwierigkeit.
|
|
func opponent_level() -> int:
|
|
return clampi(deck_level() + int(difficulty().level), 0, Cards.RARITIES.size() - 1)
|
|
|
|
|
|
## Elemente der offenen Kampagnenkapitel (Neutral immer, ab Kapitel 5 alle).
|
|
func unlocked_elements() -> Array:
|
|
return Campaign.CHAPTER_ELEMENTS.slice(0, mini(Campaign.CHAPTER_ELEMENTS.size(), campaign_stage / Campaign.STAGES_PER_CHAPTER + 1))
|
|
|
|
|
|
## Alle Karten der offenen Elemente plus die eigenen Eigenbauten: was Sammlung und Deck zeigen.
|
|
func unlocked_cards() -> Array:
|
|
var open := unlocked_elements()
|
|
var out: Array = Cards.all().filter(func(c): return c.elements[0] in open)
|
|
for id in collection:
|
|
if Cards.has_id(id) and Cards.by_id(id).is_custom():
|
|
out.append(Cards.by_id(id))
|
|
return out
|
|
|
|
|
|
# --- Werkstatt ----------------------------------------------------------------------
|
|
|
|
## Die Werkstatt öffnet nach dem Boss von Kapitel 5.
|
|
func workshop_unlocked() -> bool:
|
|
return campaign_stage >= Campaign.CHAPTER_ELEMENTS.size() * Campaign.STAGES_PER_CHAPTER
|
|
|
|
|
|
## Preis eines Eigenbaus in Staub: der doppelte Kartenwert, plus 100 je weiterem Element.
|
|
func build_price(card: Cards.Card) -> int:
|
|
return 2 * card.value() + 100 * maxi(card.elements.size() - 1, 0)
|
|
|
|
|
|
## Baut eine Karte (Werkstatt): beliebig viele Elemente, Angriff 0 bis 10, Leben 1 bis 10, beliebig
|
|
## viele Eigenschaften, Kosten nach Cards.cost_with. Sie kommt als Common in Sammlung und Stapel.
|
|
## Gibt "" zurück oder eine Fehlermeldung.
|
|
func build_card(name: String, elements: Array, attack: int, health: int, keywords: Array) -> String:
|
|
if not workshop_unlocked():
|
|
return "Werkstatt noch gesperrt"
|
|
if name.strip_edges() == "":
|
|
return "Name fehlt"
|
|
if elements.is_empty():
|
|
return "Mindestens ein Element"
|
|
if attack < 0 or attack > 10 or health < 1 or health > 10:
|
|
return "Angriff 0 bis 10, Leben 1 bis 10"
|
|
var n := 1
|
|
for cd in customs:
|
|
n = maxi(n, int(str(cd.id).trim_prefix("c_")) + 1)
|
|
var c := Cards.custom("c_%d" % n, name.strip_edges(), elements, attack, health, keywords)
|
|
var price := build_price(c)
|
|
if dust < price:
|
|
return "Nicht genug Staub (%d nötig)" % price
|
|
dust -= price
|
|
Cards.register(c)
|
|
customs.append(c.to_dict())
|
|
collection[c.id] = 0
|
|
if deck.size() < Game.DECK_MAX:
|
|
deck.append(c.id)
|
|
return ""
|
|
|
|
|
|
## Einzelspieler-Gegner: ein Element aus den offenen Kapiteln, 30 Karten nach Manakurve in opponent_level;
|
|
## auf Schwer ein zufälliges Merkmal. Leben wie deins.
|
|
func single_opponent(r: RandomNumberGenerator) -> Game.Player:
|
|
var elements := unlocked_elements()
|
|
var element: String = elements[r.randi_range(0, elements.size() - 1)]
|
|
var opp := Game.Player.new("Gegner", false, Game.START_LIFE, element)
|
|
opp.deck = DeckGen.curve_deck(element, r)
|
|
var level := opponent_level()
|
|
for id in opp.deck:
|
|
opp.looks[id] = level
|
|
if bool(difficulty().perk):
|
|
var keys: Array = Game.PERKS.keys()
|
|
opp.perks = [keys[r.randi_range(0, keys.size() - 1)]]
|
|
return opp
|
|
|
|
|
|
func perk_unlocked() -> bool:
|
|
return campaign_stage >= Campaign.STAGES_PER_CHAPTER
|