Vollständiger, engineunabhängiger Regelkern (Luneraria.Core, ohne UnityEngine): Partie mit sechs Plätzen und zwei Teams, private Planung mit atomarer Übernahme, Gold, Käufe, Aufwertungen, Formation, drei Lanes mit zwei Laneturmen und zwei Basistürmen je Team, Ancients, unbegrenzte Wellen, Helden mit Stufen, Items und Talenten, Angriffschampions, Bossgrotte mit Anspruch, Entsendung, Kopfgeld und dreirundiger Sperre, Bots und Siegbedingung (nur Ancient-Fall). Serverautoritative Befehlsschnittstelle. - Deterministisch: fester Takt, geordnete Maps, fortlaufende IDs, kein Zufall, keine Uhr, keine Threads - Tabellen einmal aus Godot erzeugt (tools/port/gen_content.py) - Golden-Referenzen aus Godot (tools/godot-golden): 19 Szenarien, 120 Snapshots, 51.221 Werte, Abweichung 0 (Toleranzen dokumentiert) - Tests: content 75, core 91, m2 73, golden 157, determinism 6 = 402/402 - Leistung (M5): Simulationsschritt 0,259 ms statt 1,587 ms in GDScript - Referenzfehler R1 im Bot-Gedächtnis bewusst übernommen und dokumentiert - Geplante Helden (Lucy, Ghaliga, Siegfried, Feena) im Datenmodell, inaktiv - Doku unter docs/port (U1-RULES, PORT-STATUS, PORT-QA, U2 bis U5) Unity-Editor-Lauf offen: Die Unity-Lizenz ist noch nicht aktiviert; gebaut und getestet mit dem .NET 8 SDK auf Unitys Sprachstand (C# 9, .NET Standard 2.1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
11 KiB
GDScript
210 lines
11 KiB
GDScript
extends Node
|
|
## Golden references for the C# port: interprets Assets/Luneraria/Tests/Core/Golden/scenarios.json
|
|
## with the Godot rules (luneraria/g3, 1b540f2) and writes the domain state after every
|
|
## "snapshot" operation plus the result of every command (Golden/golden.json). The C# core
|
|
## (GoldenSuite) runs the same operations and compares its own snapshots field by field.
|
|
## Run: sh tools/godot-golden/run_godot.sh golden_scenarios --in=<scenarios.json> --out=<golden.json> [--only=id,...]
|
|
|
|
var state: LuneMatch
|
|
var snapshots: Array = []
|
|
var results: Array = []
|
|
|
|
func _ready() -> void:
|
|
var source: String = ""
|
|
var out: String = "/tmp/lune_golden.json"
|
|
var only: PackedStringArray = []
|
|
for arg: String in OS.get_cmdline_user_args():
|
|
if arg.begins_with("--in="):
|
|
source = arg.substr(5)
|
|
elif arg.begins_with("--out="):
|
|
out = arg.substr(6)
|
|
elif arg.begins_with("--only="):
|
|
only = arg.substr(7).split(",")
|
|
var spec: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(source))
|
|
var scenarios: Dictionary = {}
|
|
for scenario: Dictionary in spec.scenarios:
|
|
if not only.is_empty() and not only.has(scenario.id):
|
|
continue
|
|
var started: int = Time.get_ticks_msec()
|
|
snapshots = []
|
|
results = []
|
|
for operation: Dictionary in scenario.ops:
|
|
_apply(operation)
|
|
scenarios[scenario.id] = {"title": scenario.title, "snapshots": snapshots, "results": results}
|
|
print("GOLDEN %s: %d snapshots, %d results, %d ms" % [scenario.id, snapshots.size(), results.size(), Time.get_ticks_msec() - started])
|
|
var file := FileAccess.open(out, FileAccess.WRITE)
|
|
file.store_string(JSON.stringify({"engine": Engine.get_version_info().string, "reference": "luneraria/g3 1b540f2", "scenarios": scenarios}, "", false, true))
|
|
file.close()
|
|
print("GOLDEN_DONE ", out)
|
|
get_tree().quit()
|
|
|
|
# ---------------------------------------------------------------- operations
|
|
func _apply(op: Dictionary) -> void:
|
|
match str(op.op):
|
|
"setup":
|
|
state = LuneMatch.new()
|
|
var options: Dictionary = {"seed": int(op.seed), "human_slot": int(op.get("human", -1))}
|
|
if op.has("bunds"):
|
|
options.bunds = op.bunds
|
|
if op.has("profiles"):
|
|
options.profiles = op.profiles
|
|
state.setup(options)
|
|
"building":
|
|
state.building_hp[str(op.id)] = float(op.hp)
|
|
"buildings_full":
|
|
for definition: Dictionary in state.map.buildings:
|
|
state.building_hp[definition.id] = float(definition.max_hp)
|
|
"round":
|
|
state.round_number = int(op.value)
|
|
"gold":
|
|
state.players[int(op.slot)].draft.gold = int(state.players[int(op.slot)].draft.gold) + int(op.add)
|
|
"gold_all":
|
|
for player: Dictionary in state.players:
|
|
player.draft.gold = int(player.draft.gold) + int(op.add)
|
|
"cmd":
|
|
var command: Dictionary = _resolve(int(op.slot), op.command)
|
|
var result: Dictionary = state.submit(int(op.slot), command)
|
|
results.append(["cmd", int(op.slot), str(command.type), bool(result.ok), str(result.message)])
|
|
"bots":
|
|
for slot: int in range(6):
|
|
LuneBot.plan(state, slot, str(op.get("profile", "")))
|
|
"begin":
|
|
results.append(["begin", -1, "", state.begin_combat(), ""])
|
|
"steps":
|
|
for i: int in range(int(op.n)):
|
|
if state.phase != LuneMatch.Phase.COMBAT:
|
|
break
|
|
state.step_combat()
|
|
"finish":
|
|
state.finish_combat()
|
|
"unit_set":
|
|
var select: Dictionary = op.select
|
|
for unit: LuneUnit in state.combat.units:
|
|
if select.has("kind") and unit.kind != str(select.kind):
|
|
continue
|
|
if select.has("zone") and unit.zone != str(select.zone):
|
|
continue
|
|
if select.has("team") and unit.team != int(select.team):
|
|
continue
|
|
if op.has("hp"):
|
|
unit.hp = float(op.hp)
|
|
"settle_boss":
|
|
results.append(["settle_boss", -1, "", true, state._settle_boss()])
|
|
"expedition":
|
|
var slot: int = int(op.slot)
|
|
state.players[slot].draft.gold = int(state.players[slot].draft.gold) + 6000
|
|
var ids: Array[String] = state.base_ids(state.players[slot].bund)
|
|
for type_index: Variant in op.types:
|
|
_record(slot, {"type": "buy", "type_id": ids[int(type_index)]})
|
|
var entry: Dictionary = state.players[slot].draft.roster[-1]
|
|
_record(slot, {"type": "upgrade", "uid": entry.uid})
|
|
_record(slot, {"type": "assign", "uid": entry.uid, "lane": "grotto"})
|
|
if op.has("hero"):
|
|
_record(slot, {"type": "hero_buy", "hero_id": str(op.hero)})
|
|
_record(slot, {"type": "assign", "uid": state.players[slot].draft.hero.uid, "lane": "grotto"})
|
|
"play":
|
|
for i: int in range(int(op.rounds)):
|
|
if state.phase == LuneMatch.Phase.OVER:
|
|
break
|
|
if bool(op.get("bots", true)):
|
|
for slot: int in range(6):
|
|
LuneBot.plan(state, slot, str(op.get("profile", "")))
|
|
var fought: int = state.round_number
|
|
state.begin_combat()
|
|
state.finish_combat()
|
|
_snapshot({"label": "round-%d" % fought})
|
|
"snapshot":
|
|
_snapshot(op)
|
|
_:
|
|
push_error("unknown op " + str(op.op))
|
|
|
|
func _record(slot: int, command: Dictionary) -> void:
|
|
var result: Dictionary = state.submit(slot, command)
|
|
results.append(["cmd", slot, str(command.type), bool(result.ok), str(result.message)])
|
|
|
|
## Uid references: $last:<slot>, $roster:<slot>:<index>, $hero:<slot>, $booking:<slot>:<index>.
|
|
func _resolve(slot: int, command: Dictionary) -> Dictionary:
|
|
var copy: Dictionary = command.duplicate(true)
|
|
if copy.has("uid") and str(copy.uid).begins_with("$"):
|
|
var parts: PackedStringArray = str(copy.uid).substr(1).split(":")
|
|
var owner: Dictionary = state.players[int(parts[1])]
|
|
match parts[0]:
|
|
"last": copy.uid = str(owner.draft.roster[-1].uid)
|
|
"roster": copy.uid = str(owner.draft.roster[int(parts[2])].uid)
|
|
"hero": copy.uid = str(owner.draft.hero.uid)
|
|
"booking": copy.uid = str(owner.draft.new_bookings[int(parts[2])].uid)
|
|
if copy.has("tier"):
|
|
copy.tier = int(copy.tier)
|
|
if copy.has("choice"):
|
|
copy.choice = int(copy.choice)
|
|
return copy
|
|
|
|
# ---------------------------------------------------------------- snapshot
|
|
func _snapshot(op: Dictionary) -> void:
|
|
var players: Array = []
|
|
for player: Dictionary in state.players:
|
|
players.append({"gold": int(player.gold), "draft_gold": int(player.draft.gold), "ready": bool(player.ready), "earned": int(player.earned),
|
|
"spent": int(player.spent), "next_seq": int(player.next_seq), "roster": _roster(player.roster, false), "hero": _hero(player.hero),
|
|
"bookings": player.bookings.map(func(b: Dictionary) -> String: return "%s|%s|%s|%d|%d" % [b.uid, b.champion_id, b.lane, int(b.cost), int(b.get("deploy_round", 0))]),
|
|
"new_bookings": (player.draft.new_bookings as Array).map(func(b: Dictionary) -> String: return "%s|%s|%s|%d" % [b.uid, b.champion_id, b.lane, int(b.cost)]),
|
|
"draft_roster": _roster(player.draft.roster, true), "draft_hero": _hero(player.draft.hero), "boss_lane": str(player.draft.boss_lane),
|
|
"undo": (player.undo as Array).size()})
|
|
var claim: Dictionary = state.boss.get("claim", {})
|
|
var snap: Dictionary = {"label": str(op.label), "phase": state.phase, "round": state.round_number, "winner": state.winner, "end_note": state.end_note,
|
|
"players": players, "buildings": state.map.buildings.map(func(d: Dictionary) -> float: return float(state.building_hp[d.id])),
|
|
"boss": {"next": int(state.boss.next_available_round), "settled": int(state.boss.get("settled_round", 0)),
|
|
"claim": "" if claim.is_empty() else "%d|%d|%d|%d|%s" % [int(claim.team), int(claim.won_round), int(claim.deploy_round), int(claim.chooser_slot), claim.lane]},
|
|
"boss_log": state.boss_log.map(func(e: Dictionary) -> String: return "%d|%s|%d|%s|%d" % [int(e.round), e.event, int(e.get("team", -1)), str(e.get("lane", "")), int(e.get("chooser", -1))]),
|
|
"announcements": state.announcements().map(func(a: Dictionary) -> String: return "%s|%d|%d|%s|%s|%d" % [a.kind, int(a.team), int(a.slot), a.lane, str(a.get("champion_id", "")), int(a.get("bounty", 0))]),
|
|
"settlement": _settlement(), "combat": _combat()}
|
|
if bool(op.get("units", false)) and state.combat != null:
|
|
snap.units = state.combat.units.map(_unit)
|
|
if bool(op.get("events", false)) and state.combat != null:
|
|
snap.events = state.combat.events.map(func(e: Dictionary) -> Array: return [int(e.serial), int(e.step), float(e.time), int(e.team), str(e.lane), str(e.zone), str(e.kind),
|
|
int(e.unit_id), str(e.def_id), int(e.bounty), str(e.get("building_id", "")), int(e.xp), e.xp_heroes])
|
|
if bool(op.get("wave", false)):
|
|
snap.wave = LuneWaves.build(state.balance, state.round_number).map(func(m: Dictionary) -> Array: return [str(m.id), float(m.max_hp), float(m.damage), int(m.bounty)])
|
|
snapshots.append(snap)
|
|
|
|
func _roster(roster: Array, with_fresh: bool) -> Array:
|
|
return roster.map(func(e: Dictionary) -> String: return "%s|%s|%s|%d%s" % [e.uid, e.type_id, e.lane, int(e.invested), ("|%s" % str(bool(e.get("fresh", false))).to_lower()) if with_fresh else ""])
|
|
|
|
func _hero(hero: Dictionary) -> String:
|
|
if hero.is_empty():
|
|
return ""
|
|
return "%s|%s|%d|%s|%s|%s|%d|%s|%s" % [hero.uid, hero.hero_id, int(hero.xp), hero.lane, "+".join(hero.items), "+".join(hero.talents), int(hero.invested),
|
|
str(bool(hero.get("fresh", false))).to_lower(), "+".join(hero.get("fresh_items", []))]
|
|
|
|
func _settlement() -> Variant:
|
|
var s: Dictionary = state.last_settlement
|
|
if s.is_empty():
|
|
return null
|
|
return {"round": int(s.round), "gains": (s.gains as Array).map(func(g: int) -> int: return g), "income": int(s.income), "lost": s.buildings_lost,
|
|
"end_reason": str(s.end_reason), "combat_time": float(s.combat_time), "boss": str(s.boss), "lane_kills": s.lane_kills, "xp": s.xp}
|
|
|
|
func _combat() -> Variant:
|
|
var c: LuneCombat = state.combat
|
|
if c == null:
|
|
return null
|
|
var boss: String = ""
|
|
if c.grotto_boss != null:
|
|
boss = "%s|%s|%s" % [str(c.grotto_boss.alive).to_lower(), str(c.grotto_boss.dormant).to_lower(), str(c.grotto_boss.id)]
|
|
return {"round": c.round_number, "steps": c.steps, "elapsed": c.elapsed, "finished": c.finished, "end_reason": c.end_reason,
|
|
"destroyed": c.destroyed_cores.duplicate(), "grotto": "%s|%d|%d|%s" % [c.grotto_outcome, c.grotto_claim_team, c.grotto_active_team, str(c.grotto_resolved).to_lower()],
|
|
"grotto_boss": boss, "grotto_boss_hp": c.grotto_boss.hp if c.grotto_boss != null else 0.0, "events": c.events.size(),
|
|
"mobile": [c.mobile_count(0), c.mobile_count(1), c.mobile_count(2)]}
|
|
|
|
func _unit(unit: LuneUnit) -> Dictionary:
|
|
var effects: Array = []
|
|
for family: String in unit.effects:
|
|
var effect: Dictionary = unit.effects[family]
|
|
effects.append([family, str(effect.kind), float(effect.amount), float(effect.remaining)])
|
|
var dots: Array = []
|
|
for family: String in unit.dots:
|
|
var dot: Dictionary = unit.dots[family]
|
|
dots.append([family, float(dot.damage), float(dot.remaining), float(dot.next_tick), int(dot.attack_type)])
|
|
return {"id": unit.id, "kind": unit.kind, "team": unit.team, "lane": unit.lane, "zone": unit.zone, "slot": unit.owner_slot, "uid": unit.persistent_uid,
|
|
"def": str(unit.def.get("id", "")), "alive": unit.alive, "hp": unit.hp, "max_hp": unit.max_hp, "shield": unit.shield,
|
|
"x": unit.position.x, "y": unit.position.y, "facing": unit.facing, "progress": unit.progress,
|
|
"target": unit.target.id if unit.target != null else -1, "hits": unit.hit_count, "reached": unit.reached_base, "dormant": unit.dormant,
|
|
"effects": effects, "dots": dots}
|