317 lines
11 KiB
Python
317 lines
11 KiB
Python
|
|
"""UNWRITTEN - battle simulation + screenshots. Owner: Agent B.
|
||
|
|
|
||
|
|
Runs 225 scripted headless battles across every pack (both bosses included)
|
||
|
|
with a level-appropriate party and an auto command policy, asserting:
|
||
|
|
- no exceptions,
|
||
|
|
- victory is possible for every pack,
|
||
|
|
- XP / gold / loot are actually granted,
|
||
|
|
then prints a balance report (win rate + avg rounds per pack) for Fable.
|
||
|
|
|
||
|
|
Finally builds the real BattleScreen and screenshots three states:
|
||
|
|
battle_1.png command menu open on a player turn vs gw_mixed
|
||
|
|
battle_2.png mid-action, floating damage number
|
||
|
|
battle_3.png victory panel
|
||
|
|
|
||
|
|
Run:
|
||
|
|
cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_battle_sim.py
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import random
|
||
|
|
|
||
|
|
GAMEROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
sys.path.insert(0, GAMEROOT)
|
||
|
|
|
||
|
|
import mcrfpy
|
||
|
|
from mcrfpy import automation
|
||
|
|
|
||
|
|
from systems.state import GS
|
||
|
|
from systems import battle
|
||
|
|
from systems.battle import BattleCore
|
||
|
|
from data import characters as chardata
|
||
|
|
from data import items as itemdata
|
||
|
|
|
||
|
|
SHOTS = os.path.join(GAMEROOT, "tests", "shots")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- party setup
|
||
|
|
_WEAPON = {
|
||
|
|
1: {"PIP": "knife", "BRAMBLE": "worn_cudgel", "MOTH": "wick_staff",
|
||
|
|
"VERA": "wick_staff", "CANTOR": "worn_cudgel", "NYX": "knife"},
|
||
|
|
3: {"PIP": "short_sword", "BRAMBLE": "short_sword", "MOTH": "wick_staff",
|
||
|
|
"VERA": "wick_staff", "CANTOR": "short_sword", "NYX": "short_sword"},
|
||
|
|
5: {"PIP": "twin_axe", "BRAMBLE": "broad_blade", "MOTH": "tide_rod",
|
||
|
|
"VERA": "tide_rod", "CANTOR": "broad_blade", "NYX": "twin_axe"},
|
||
|
|
}
|
||
|
|
_TRINKET = {1: "river_stone", 3: "river_stone", 5: "waxed_charm"}
|
||
|
|
|
||
|
|
|
||
|
|
def _gear_tier(level):
|
||
|
|
if level >= 5:
|
||
|
|
return 5
|
||
|
|
if level >= 3:
|
||
|
|
return 3
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
def setup(comp, level, points=0, act=1):
|
||
|
|
GS.reset()
|
||
|
|
GS.act = act
|
||
|
|
GS.points = points
|
||
|
|
tier = _gear_tier(level)
|
||
|
|
for cid in comp:
|
||
|
|
GS.recruit(cid, level)
|
||
|
|
cs = GS.roster[cid]
|
||
|
|
cs.weapon = _WEAPON[tier].get(cid)
|
||
|
|
cs.trinket = _TRINKET[tier]
|
||
|
|
cs.full_heal()
|
||
|
|
GS.gold = 0
|
||
|
|
GS.grant("bread", 5)
|
||
|
|
GS.grant("red_tonic", 2)
|
||
|
|
GS.grant("blue_draught", 2)
|
||
|
|
GS.grant("bomb_flask", 1)
|
||
|
|
GS.grant("chalk", 1)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- auto policy
|
||
|
|
def policy(core, actor):
|
||
|
|
skills = chardata.learned_skills(actor.char_id, actor.cstate.level)
|
||
|
|
enemies = core.alive_enemies()
|
||
|
|
allies = core.alive_party()
|
||
|
|
|
||
|
|
# 1. heal a badly hurt ally
|
||
|
|
hurt = [a for a in allies if a.hp < a.max_hp * 0.45]
|
||
|
|
if hurt:
|
||
|
|
tgt = min(hurt, key=lambda c: c.hp / float(c.max_hp))
|
||
|
|
for s in skills:
|
||
|
|
if s["special"] in ("kindle", "tonic_toss") and actor.sp >= s["sp"]:
|
||
|
|
if s["special"] == "tonic_toss" and tgt.char_id == "NYX":
|
||
|
|
continue
|
||
|
|
return {"type": "skill", "skill": s, "target": tgt}
|
||
|
|
|
||
|
|
# 2. occasionally exercise a boss talk option
|
||
|
|
if core.is_boss and random.random() < 0.35:
|
||
|
|
opts = [o for o in core.boss_talk_options() if o["enabled"]]
|
||
|
|
if opts:
|
||
|
|
return {"type": "talk", "talk": opts[0]["id"]}
|
||
|
|
|
||
|
|
# 3. occasionally set up buffs (exercise shieldwall / second shadow)
|
||
|
|
for s in skills:
|
||
|
|
if s["special"] == "shieldwall" and not core.party_guard_active \
|
||
|
|
and actor.sp >= s["sp"] and random.random() < 0.25:
|
||
|
|
return {"type": "skill", "skill": s, "target": None}
|
||
|
|
if s["special"] == "second_shadow" and not actor.guaranteed_crit \
|
||
|
|
and actor.sp >= s["sp"] and random.random() < 0.30:
|
||
|
|
return {"type": "skill", "skill": s, "target": None}
|
||
|
|
|
||
|
|
# 4. offensive skill (prefer AoE with multiple foes)
|
||
|
|
dmg = [s for s in skills
|
||
|
|
if s["kind"] in ("phys", "magic") and actor.sp >= s["sp"]]
|
||
|
|
if dmg and random.random() < 0.6:
|
||
|
|
aoe = [s for s in dmg if s["target"] == "all_enemies"]
|
||
|
|
if aoe and len(enemies) >= 2:
|
||
|
|
s = random.choice(aoe)
|
||
|
|
else:
|
||
|
|
s = random.choice(dmg)
|
||
|
|
tgt = min(enemies, key=lambda e: e.hp) if enemies else None
|
||
|
|
return {"type": "skill", "skill": s, "target": tgt}
|
||
|
|
|
||
|
|
# 5. basic attack, focus the weakest enemy
|
||
|
|
if enemies:
|
||
|
|
return {"type": "attack", "target": min(enemies, key=lambda e: e.hp)}
|
||
|
|
return {"type": "guard"}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- one battle
|
||
|
|
def run_once(pack_id, comp, level, points=0, act=1):
|
||
|
|
setup(comp, level, points, act)
|
||
|
|
core = BattleCore(pack_id)
|
||
|
|
core.begin_round()
|
||
|
|
rounds = 1
|
||
|
|
steps = 0
|
||
|
|
while True:
|
||
|
|
na = core.next_actor()
|
||
|
|
if na is None:
|
||
|
|
core.end_round()
|
||
|
|
if core.check_end():
|
||
|
|
break
|
||
|
|
core.begin_round()
|
||
|
|
rounds += 1
|
||
|
|
if rounds > battle._MAX_ROUNDS:
|
||
|
|
core.result = "defeat"
|
||
|
|
break
|
||
|
|
continue
|
||
|
|
actor, forced = na
|
||
|
|
if forced is not None:
|
||
|
|
action = forced
|
||
|
|
elif actor.side == "enemy":
|
||
|
|
action = core.ai_action(actor)
|
||
|
|
else:
|
||
|
|
action = policy(core, actor)
|
||
|
|
result = core.resolve(actor, action)
|
||
|
|
if result["flee"]:
|
||
|
|
core.result = "flee"
|
||
|
|
break
|
||
|
|
if core.check_end():
|
||
|
|
break
|
||
|
|
steps += 1
|
||
|
|
if steps > 6000:
|
||
|
|
core.result = "defeat"
|
||
|
|
break
|
||
|
|
|
||
|
|
xp = gold = 0
|
||
|
|
drops = []
|
||
|
|
if core.result == "victory":
|
||
|
|
xp, gold, drops = core.collect_rewards()
|
||
|
|
GS.xp_gain(xp)
|
||
|
|
GS.add_gold(gold)
|
||
|
|
for d in drops:
|
||
|
|
GS.grant(d, 1)
|
||
|
|
return core.result, rounds, xp, gold, drops
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- sim config
|
||
|
|
# pack_id -> (party comp, level, points, act, label for report)
|
||
|
|
SIM = [
|
||
|
|
("gw_bats", (["PIP", "BRAMBLE", "MOTH"], 1, 0, 1)),
|
||
|
|
("gw_mixed", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)),
|
||
|
|
("gw_mixed2", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)),
|
||
|
|
("gw_scarabs", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)),
|
||
|
|
("gw_slimes", (["PIP", "BRAMBLE", "MOTH"], 1, 0, 1)),
|
||
|
|
("uc_bones", (["PIP", "BRAMBLE", "CANTOR"], 3, 0, 2)),
|
||
|
|
("uc_bones2", (["PIP", "BRAMBLE", "MOTH"], 4, 0, 2)),
|
||
|
|
("uc_chimes", (["PIP", "CANTOR", "MOTH"], 4, 0, 2)),
|
||
|
|
("uc_echoes", (["PIP", "BRAMBLE", "MOTH"], 3, 0, 2)),
|
||
|
|
("uc_mixed", (["PIP", "CANTOR", "MOTH"], 4, 0, 2)),
|
||
|
|
("uc_mimic", (["PIP", "BRAMBLE", "MOTH"], 4, 0, 2)),
|
||
|
|
("st_echoes", (["PIP", "MOTH", "CANTOR"], 5, 0, 3)),
|
||
|
|
("st_echoes2", (["PIP", "MOTH", "CANTOR"], 6, 0, 3)),
|
||
|
|
("boss_gatekeeper", (["PIP", "BRAMBLE", "MOTH"], 5, 4, 2)),
|
||
|
|
("boss_custodian", (["PIP", "MOTH", "CANTOR"], 7, 8, 3)),
|
||
|
|
]
|
||
|
|
TRIALS = 15
|
||
|
|
|
||
|
|
|
||
|
|
def run_sim():
|
||
|
|
random.seed(20260703)
|
||
|
|
report = []
|
||
|
|
total_xp = total_gold = total_drops = 0
|
||
|
|
total_battles = 0
|
||
|
|
for pack_id, (comp, level, points, act) in SIM:
|
||
|
|
wins = 0
|
||
|
|
round_sum = 0
|
||
|
|
win_rounds = 0
|
||
|
|
for _ in range(TRIALS):
|
||
|
|
res, rounds, xp, gold, drops = run_once(
|
||
|
|
pack_id, comp, level, points, act)
|
||
|
|
total_battles += 1
|
||
|
|
total_xp += xp
|
||
|
|
total_gold += gold
|
||
|
|
total_drops += len(drops)
|
||
|
|
round_sum += rounds
|
||
|
|
if res == "victory":
|
||
|
|
wins += 1
|
||
|
|
win_rounds += rounds
|
||
|
|
win_rate = 100.0 * wins / TRIALS
|
||
|
|
avg_rounds = (win_rounds / wins) if wins else round_sum / TRIALS
|
||
|
|
report.append((pack_id, level, win_rate, avg_rounds, wins))
|
||
|
|
return report, total_battles, total_xp, total_gold, total_drops
|
||
|
|
|
||
|
|
|
||
|
|
def print_report(report, total_battles, total_xp, total_gold, total_drops):
|
||
|
|
print("", flush=True)
|
||
|
|
print("=" * 64, flush=True)
|
||
|
|
print("UNWRITTEN - BATTLE BALANCE REPORT (%d battles, %d per pack)"
|
||
|
|
% (total_battles, TRIALS), flush=True)
|
||
|
|
print("=" * 64, flush=True)
|
||
|
|
print("%-18s %3s %8s %10s" % ("pack", "Lv", "win%", "avg rounds"),
|
||
|
|
flush=True)
|
||
|
|
print("-" * 64, flush=True)
|
||
|
|
for pack_id, level, wr, ar, wins in report:
|
||
|
|
print("%-18s %3d %7.0f%% %10.1f" % (pack_id, level, wr, ar), flush=True)
|
||
|
|
print("-" * 64, flush=True)
|
||
|
|
print("totals: XP granted %d | cogs granted %d | drops %d"
|
||
|
|
% (total_xp, total_gold, total_drops), flush=True)
|
||
|
|
print("=" * 64, flush=True)
|
||
|
|
|
||
|
|
|
||
|
|
def assert_sim(report, total_xp, total_gold, total_drops):
|
||
|
|
problems = []
|
||
|
|
for pack_id, level, wr, ar, wins in report:
|
||
|
|
if wins == 0:
|
||
|
|
problems.append("pack %s never won (win rate 0%%)" % pack_id)
|
||
|
|
if total_xp <= 0:
|
||
|
|
problems.append("no XP was ever granted")
|
||
|
|
if total_gold <= 0:
|
||
|
|
problems.append("no gold was ever granted")
|
||
|
|
if total_drops <= 0:
|
||
|
|
problems.append("no loot drops occurred")
|
||
|
|
if problems:
|
||
|
|
for p in problems:
|
||
|
|
print("FAIL: %s" % p, flush=True)
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- screenshots
|
||
|
|
def step(seconds):
|
||
|
|
n = int(seconds / (1.0 / 60.0))
|
||
|
|
for _ in range(max(1, n)):
|
||
|
|
mcrfpy.step(1.0 / 60.0)
|
||
|
|
|
||
|
|
|
||
|
|
def take_screenshots():
|
||
|
|
if not os.path.isdir(SHOTS):
|
||
|
|
os.makedirs(SHOTS)
|
||
|
|
random.seed(7)
|
||
|
|
|
||
|
|
# --- shot 1: command menu open vs gw_mixed ---
|
||
|
|
setup(["PIP", "BRAMBLE", "MOTH"], 2)
|
||
|
|
GS.current_area = "gearwood"
|
||
|
|
screen = battle.start_battle("gw_mixed")
|
||
|
|
tries = 0
|
||
|
|
while screen.state != "command" and tries < 400:
|
||
|
|
step(0.05)
|
||
|
|
tries += 1
|
||
|
|
step(0.05)
|
||
|
|
automation.screenshot(os.path.join(SHOTS, "battle_1.png"))
|
||
|
|
print("shot 1 (command menu, state=%s) saved" % screen.state, flush=True)
|
||
|
|
|
||
|
|
# --- shot 2: mid-action floating damage ---
|
||
|
|
actor = screen.cur_actor
|
||
|
|
target = screen.core.alive_enemies()[0]
|
||
|
|
if actor is not None and actor.side == "party":
|
||
|
|
screen._commit(actor, {"type": "attack", "target": target})
|
||
|
|
step(0.15)
|
||
|
|
automation.screenshot(os.path.join(SHOTS, "battle_2.png"))
|
||
|
|
print("shot 2 (mid-action) saved", flush=True)
|
||
|
|
|
||
|
|
# --- shot 3: victory panel ---
|
||
|
|
for e in screen.core.enemies:
|
||
|
|
e.hp = 0
|
||
|
|
screen.core.result = "victory"
|
||
|
|
screen._on_battle_end()
|
||
|
|
step(0.1)
|
||
|
|
automation.screenshot(os.path.join(SHOTS, "battle_3.png"))
|
||
|
|
print("shot 3 (victory panel) saved", flush=True)
|
||
|
|
screen._teardown()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- main
|
||
|
|
def main():
|
||
|
|
report, total_battles, total_xp, total_gold, total_drops = run_sim()
|
||
|
|
print_report(report, total_battles, total_xp, total_gold, total_drops)
|
||
|
|
ok = assert_sim(report, total_xp, total_gold, total_drops)
|
||
|
|
|
||
|
|
take_screenshots()
|
||
|
|
|
||
|
|
if ok:
|
||
|
|
print("PASS test_battle_sim", flush=True)
|
||
|
|
sys.exit(0)
|
||
|
|
else:
|
||
|
|
print("FAIL test_battle_sim", flush=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|