Compare commits

..

5 commits

Author SHA1 Message Date
Martin Stickelbroeck
d7c3a03e23 M2: QA-Matrix und Status nachgetragen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:33:52 +02:00
Martin Stickelbroeck
f53859a298 M2: Helden, Items, Talente, Angriffschampions und Bossgrotte
Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen,
drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall,
ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das
Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt
aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73,
M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:33:11 +02:00
Martin Stickelbroeck
c27a0097c3 M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme
Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:37:23 +02:00
Martin Stickelbroeck
b565546c37 M1: autoritativer Drei-Lane-Kern (Karte, Kampf, Partie, Wellen, Bots)
LuneMap spiegelsymmetrisch mit 16 Türmen und 2 Ancients, LuneCombat mit
reihenfolgeunabhängigem Zweiphasenschritt und Gebäudesperren für jede
Schadensquelle, LuneMatch mit Planungsentwürfen, atomarer Übernahme,
Einmal-Belohnung und Kernsieg/Unentschieden, endlose LuneWaves, Bots über
dieselbe Befehlsschnittstelle. 87 Kernprüfungen bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:57:37 +02:00
Martin Stickelbroeck
4b6a4f530e M0: Bestandsaufnahme, Regelwerk, Architektur und Assetmatrix für Champions of Luneraria
Regelwerk trennt verbindliche Produktregeln, Arbeitsannahmen A-F und
eigene Detailentscheidungen. Assetmatrix wird aus den Manifesten erzeugt.
Baseline-Tests (Legacy) 209/33/463/206/1665 bestanden, Logs unter
docs/luneraria/logs/m0-baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:04:36 +02:00
149 changed files with 13338 additions and 9 deletions

View file

@ -0,0 +1,158 @@
"""Boss grotto for Champions of Luneraria: a rock island in the river with a crescent of
cliffs, a cave mouth and violet crystals. Symmetric across the map diagonal, so both
entrances (towards Blau and towards Rot) are equal. Stone uses ENV_* material names.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py
Local Godot frame: +x/+z are the map axes; the map diagonal (x = z) is the mirror line.
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi, sqrt
from mathutils import Vector, noise
P = Path(__file__).resolve().parents[2]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria'
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
parts = []
def G(x, y, z):
return Vector((x, -z, y))
def material(name, color, rough=.9, metal=0.0, emission=0.0):
m = bpy.data.materials.new(name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
bs = m.node_tree.nodes.get('Principled BSDF')
bs.inputs['Base Color'].default_value = (*color, 1)
bs.inputs['Roughness'].default_value = rough
bs.inputs['Metallic'].default_value = metal
if emission:
bs.inputs['Emission Color'].default_value = (*color, 1)
bs.inputs['Emission Strength'].default_value = emission
return m
M = {
'cliff': material('ENV_cliff', (.133, .153, .138)),
'soil': material('ENV_soil', (.10, .15, .065), .97),
'limestone': material('ENV_limestone', (.283, .246, .178)),
'dark': material('GR_Shadow', (.02, .02, .03), 1.0),
'crystal': material('GR_Crystal', (.62, .38, 1.0), .15, 0, 3.0),
}
def obj(name, vs, fs, kind, smooth=False):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in vs], [], fs)
me.update()
me.materials.append(M[kind])
for p in me.polygons:
p.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def island(name, radius, top, bottom, seed, kind='soil'):
sides, levels = 64, 6
vs, fs = [], []
for li in range(levels):
y = top - (top - bottom) * li / (levels - 1)
for k in range(sides):
a = 2 * pi * k / sides
# Radius depends on |angle - 45 deg| so the island is symmetric across x = z.
m = abs(((a - pi / 4) + pi) % (2 * pi) - pi)
r = radius * (1 + .08 * noise.noise(Vector((cos(m) * 2, li * .5, seed)))) * (1 - .06 * li / levels)
vs.append(G(cos(a) * r, y, sin(a) * r))
for li in range(levels - 1):
for k in range(sides):
a, b = li * sides + k, li * sides + (k + 1) % sides
fs.append((a, a + sides, b + sides, b))
fs += [tuple(range(sides)), tuple(reversed(range((levels - 1) * sides, levels * sides)))]
return obj(name, vs, fs, kind)
def boulder(name, center, size, seed, kind='cliff'):
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=2, radius=1.0)
c = G(*center)
for v in bm.verts:
d = v.co.normalized()
r = 1 + .32 * noise.noise(d * 1.8 + Vector((seed, seed * .5, seed * .3)))
gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2]
if gy < -.25 * size[1]:
gy = -.25 * size[1]
v.co = c + Vector((gx, -gz, gy))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(M[kind])
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def gem(name, center, height, radius, tilt, kind='crystal'):
cx, cy, cz = center
tip = G(cx + tilt[0], cy + height, cz + tilt[1])
base = [G(cx + cos(2 * pi * k / 6) * radius, cy + height * .25, cz + sin(2 * pi * k / 6) * radius) for k in range(6)]
root = G(cx, cy - .1, cz)
vs = [tip] + base + [root]
fs = [(0, 1 + (k + 1) % 6, 1 + k) for k in range(6)] + [(7, 1 + k, 1 + (k + 1) % 6) for k in range(6)]
return obj(name, vs, fs, kind)
def mirror(p):
return (p[2], p[1], p[0])
island('Island', 7.2, .35, -1.2, 3)
# Crescent of cliffs on the far side from Mid (towards -x-z), open towards both entrances.
ring = []
for k in range(9):
a = pi + pi / 4 + (k - 4) * .30
ring.append((cos(a) * 5.6, 0.0, sin(a) * 5.6, 1.5 + (1.2 if abs(k - 4) <= 1 else .5 * (4 - abs(k - 4)) / 4)))
for i, (x, y, z, h) in enumerate(ring):
boulder('Cliff_%d' % i, (x, y + h * .45, z), (1.6, h, 1.4), 10 + min(i, 8 - i))
# Cave mouth: dark recess under an arch in the middle of the crescent.
a = pi + pi / 4
boulder('Cave_Shadow', (cos(a) * 4.6, .8, sin(a) * 4.6), (1.4, 1.1, .7), 31, 'dark')
boulder('Arch_Top', (cos(a) * 4.8, 3.1, sin(a) * 4.8), (2.2, .8, 1.3), 32)
# Violet crystals, mirrored pairs so neither entrance looks different.
for i, (x, z, h, r) in enumerate([(-2.2, -3.6, 1.8, .32), (-3.9, -1.2, 1.4, .28), (-1.0, -4.3, 1.1, .22), (-3.2, -3.2, 2.3, .4)]):
for p in {(x, 0.3, z), mirror((x, 0.3, z))}:
gem('Crystal_%d_%d' % (i, int(p[0] * 10)), p, h, r, (.12 * (1 if p[0] < p[2] else -1), .08))
# Entrance stones: symmetric pair of limestone steps facing Blau (+x) and Rot (+z)... see manifest.
for p in [(4.6, .18, 1.2), mirror((4.6, .18, 1.2))]:
boulder('Step_%d' % int(p[0] * 10), p, (1.1, .25, .8), 40, 'limestone')
bpy.ops.object.select_all(action='DESELECT')
for ob in parts:
ob.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
grotto = bpy.context.object
grotto.name = 'BossGrotto'
bm = bmesh.new()
bm.from_mesh(grotto.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(grotto.data)
bm.free()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'grotto.blend'))
bpy.ops.export_scene.gltf(filepath=str(OUT / 'grotto.glb'), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_yup=True, export_animations=False)
raw = (OUT / 'grotto.glb').read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
(OUT / 'grotto_manifest.json').write_text(json.dumps({'assets': [{
'id': 'boss_grotto', 'resource_path': 'res://assets/luneraria/grotto.glb', 'source': 'assets-source/luneraria/grotto.blend',
'triangles': triangles, 'sha256': hashlib.sha256(raw).hexdigest(),
'materials': [m['name'] for m in doc['materials']]}]}, indent=2) + '\n')
print('GROTTO_BUILD', triangles, 'triangles', flush=True)

View file

@ -0,0 +1,144 @@
"""Lane tower for Champions of Luneraria: stone plinth, tapered shaft with brass bands,
crenellated platform and a hovering crystal. The crystal material (TW_Crystal) is tinted
per team at runtime; stone surfaces use the Kluft material names (ENV_*).
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_tower.py
Godot coordinates are written through G(x, y, z) (x right, y up, z towards the viewer).
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi
from mathutils import Vector, noise
P = Path(__file__).resolve().parents[2]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria'
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
parts = []
def G(x, y, z):
return Vector((x, -z, y))
def material(name, color, rough=.9, metal=0.0, emission=0.0):
m = bpy.data.materials.new(name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
bs = m.node_tree.nodes.get('Principled BSDF')
bs.inputs['Base Color'].default_value = (*color, 1)
bs.inputs['Roughness'].default_value = rough
bs.inputs['Metallic'].default_value = metal
if emission:
bs.inputs['Emission Color'].default_value = (*color, 1)
bs.inputs['Emission Strength'].default_value = emission
return m
M = {
'stone': material('ENV_limestone', (.283, .246, .178)),
'dark': material('ENV_cliff', (.133, .153, .138)),
'paving': material('ENV_paving', (.162, .171, .136), .92),
'brass': material('ENV_brass', (.515, .366, .153), .45, .7),
'crystal': material('TW_Crystal', (.55, .8, 1.0), .15, 0, 2.5),
}
def obj(name, vs, fs, kind, smooth=False):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in vs], [], fs)
me.update()
me.materials.append(M[kind])
for p in me.polygons:
p.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def lathe(name, profile, sides, kind, center=(0, 0, 0), rotation=0.0, jitter=0.0, seed=0):
cx, cy, cz = center
vs, fs = [], []
for j, (r, y) in enumerate(profile):
for k in range(sides):
a = 2 * pi * k / sides + rotation
rr = r * (1 + jitter * noise.noise(Vector((cos(a) * 3, y * 2, sin(a) * 3 + seed))))
vs.append(G(cx + cos(a) * rr, cy + y, cz + sin(a) * rr))
n = len(profile)
for j in range(n - 1):
for k in range(sides):
a, b = j * sides + k, j * sides + (k + 1) % sides
fs.append((a, b, b + sides, a + sides))
fs += [tuple(reversed(range(sides))), tuple(range((n - 1) * sides, n * sides))]
return obj(name, vs, fs, kind)
def box(name, center, size, yaw, kind):
cx, cy, cz = center
hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2
vs = []
for dy in (-1, 1):
for dz in (-1, 1):
for dx in (-1, 1):
x, z = dx * hx, dz * hz
vs.append(G(cx + x * cos(yaw) - z * sin(yaw), cy + dy * hy, cz + x * sin(yaw) + z * cos(yaw)))
return obj(name, vs, [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)], kind)
# Plinth: two stepped octagons of dark stone with a paving top.
lathe('Plinth', [(1.65, 0.0), (1.65, .28), (1.45, .34), (1.45, .52), (1.3, .58)], 8, 'dark', rotation=pi / 8, jitter=.03, seed=1)
lathe('Plinth_Top', [(1.28, .57), (1.28, .62)], 8, 'paving', rotation=pi / 8)
# Shaft: tapered, slightly irregular limestone blocks.
shaft = [(1.02, .6), (.98, 1.4), (.9, 2.4), (.84, 3.2), (.9, 3.4)]
lathe('Shaft', shaft, 10, 'stone', rotation=pi / 10, jitter=.025, seed=2)
for h in (1.05, 2.25, 3.25):
r = .99 - (h - .6) * .05
lathe('Band_%d' % int(h * 10), [(r + .06, h - .07), (r + .06, h + .07)], 10, 'brass', rotation=pi / 10)
# Platform with crenellations.
lathe('Platform', [(.95, 3.35), (1.25, 3.6), (1.25, 3.85), (1.1, 3.9)], 10, 'stone', rotation=pi / 10, jitter=.02, seed=3)
for k in range(10):
a = 2 * pi * (k + .5) / 10
box('Merlon_%d' % k, (cos(a) * 1.08, 4.08, sin(a) * 1.08), (.42, .42, .26), -a + pi / 2, 'stone')
# Crystal cradle: four brass prongs and a hovering bipyramid crystal.
for k in range(4):
a = pi / 4 + k * pi / 2
pts = [(cos(a) * .55, 3.9, sin(a) * .55), (cos(a) * .42, 4.55, sin(a) * .42), (cos(a) * .22, 5.0, sin(a) * .22)]
for i in range(2):
p0, p1 = Vector(pts[i]), Vector(pts[i + 1])
mid = (p0 + p1) / 2
d = p1 - p0
box('Prong_%d_%d' % (k, i), tuple(mid), (.09, d.length + .05, .09), 0, 'brass')
crystal = [G(0, 5.95, 0)] + [G(cos(2 * pi * k / 6) * .36, 5.05, sin(2 * pi * k / 6) * .36) for k in range(6)] + [G(0, 4.45, 0)]
cf = [(0, 1 + k, 1 + (k + 1) % 6) for k in range(6)] + [(7, 1 + (k + 1) % 6, 1 + k) for k in range(6)]
obj('Crystal', crystal, cf, 'crystal')
# A small door and window slits keep the scale readable from the game camera.
box('Door', (0, .95, 1.0), (.5, .75, .12), 0, 'dark')
for k, h in enumerate((1.7, 2.7)):
box('Slit_%d' % k, (0, h, .93 - (h - 1.7) * .05), (.14, .42, .1), 0, 'dark')
bpy.ops.object.select_all(action='DESELECT')
for ob in parts:
ob.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
tower = bpy.context.object
tower.name = 'LaneTower'
bm = bmesh.new()
bm.from_mesh(tower.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(tower.data)
bm.free()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'tower.blend'))
bpy.ops.export_scene.gltf(filepath=str(OUT / 'tower.glb'), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_yup=True, export_animations=False)
raw = (OUT / 'tower.glb').read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
(P / 'assets/luneraria/tower_manifest.json').write_text(json.dumps({'assets': [{
'id': 'lane_tower', 'resource_path': 'res://assets/luneraria/tower.glb', 'source': 'assets-source/luneraria/tower.blend',
'triangles': triangles, 'height': 5.95, 'sha256': hashlib.sha256(raw).hexdigest(),
'materials': [m['name'] for m in doc['materials']]}]}, indent=2) + '\n')
print('TOWER_BUILD', triangles, 'triangles', flush=True)

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdyqe8uxp766t"
path="res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-1.png-2d037606185a25aabd1841674c9a8676.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ChatGPT-Bild 27. Sept. 2026, 19_46_52-1.png"
dest_files=["res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-1.png-2d037606185a25aabd1841674c9a8676.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cm5ok6wwo5k4c"
path="res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-2.png-ad15328ef82598f8d29650086961c8e2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ChatGPT-Bild 27. Sept. 2026, 19_46_52-2.png"
dest_files=["res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-2.png-ad15328ef82598f8d29650086961c8e2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bjtulyxjl22dy"
path="res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_53-3.png-7e556551f7b3efde4bca6a6f73eb8860.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ChatGPT-Bild 27. Sept. 2026, 19_46_53-3.png"
dest_files=["res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_53-3.png-7e556551f7b3efde4bca6a6f73eb8860.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/luneraria/grotto.glb Normal file

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://g2wd2l3wkvb7"
path="res://.godot/imported/grotto.glb-415ef169549c859f990847e218fcf14a.scn"
[deps]
source_file="res://assets/luneraria/grotto.glb"
dest_files=["res://.godot/imported/grotto.glb-415ef169549c859f990847e218fcf14a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -0,0 +1,18 @@
{
"assets": [
{
"id": "boss_grotto",
"resource_path": "res://assets/luneraria/grotto.glb",
"source": "assets-source/luneraria/grotto.blend",
"triangles": 1888,
"sha256": "92e7a4b4c98ebafb167523fb2816d91e997add201bf223ab6e9c8326bf77daf9",
"materials": [
"ENV_soil",
"ENV_cliff",
"GR_Shadow",
"GR_Crystal",
"ENV_limestone"
]
}
]
}

BIN
assets/luneraria/tower.glb Normal file

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://fqj2uftgmgig"
path="res://.godot/imported/tower.glb-367b5fb07a0c6d34829755c1c6ffa2f0.scn"
[deps]
source_file="res://assets/luneraria/tower.glb"
dest_files=["res://.godot/imported/tower.glb-367b5fb07a0c6d34829755c1c6ffa2f0.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -0,0 +1,19 @@
{
"assets": [
{
"id": "lane_tower",
"resource_path": "res://assets/luneraria/tower.glb",
"source": "assets-source/luneraria/tower.blend",
"triangles": 648,
"height": 5.95,
"sha256": "52de81cafa3712efb78413d28d16fe112330d27af0e66a8c9b31fde7d6dd1442",
"materials": [
"ENV_cliff",
"ENV_paving",
"ENV_limestone",
"ENV_brass",
"TW_Crystal"
]
}
]
}

View file

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="stone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1f2733"/><stop offset="1" stop-color="#0c1016"/></linearGradient>
<linearGradient id="moon" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fbf1d4"/><stop offset="1" stop-color="#c9a562"/></linearGradient>
<linearGradient id="gem" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d8f0ff"/><stop offset=".55" stop-color="#5aa8ff"/><stop offset="1" stop-color="#27508a"/></linearGradient>
<radialGradient id="halo" cx=".5" cy=".46" r=".5"><stop offset="0" stop-color="#9fd0ff" stop-opacity=".45"/><stop offset="1" stop-color="#9fd0ff" stop-opacity="0"/></radialGradient>
</defs>
<circle cx="256" cy="256" r="232" fill="url(#stone)" stroke="#c99a58" stroke-width="12"/>
<circle cx="256" cy="256" r="204" fill="none" stroke="#6b4f2e" stroke-width="4"/>
<circle cx="256" cy="236" r="160" fill="url(#halo)"/>
<path d="M300 70 A186 186 0 1 0 300 442 A214 214 0 0 1 300 70 Z" fill="url(#moon)" stroke="#fff4d8" stroke-width="4"/>
<path d="M256 150 L300 236 L256 360 L212 236 Z" fill="url(#gem)" stroke="#eaf6ff" stroke-width="5" stroke-linejoin="round"/>
<path d="M212 236 L256 262 L300 236 M256 150 L256 360" fill="none" stroke="#eaf6ff" stroke-width="2.5" opacity=".8"/>
<path d="M256 70 l10 22 l22 10 l-22 10 l-10 22 l-10 -22 l-22 -10 l22 -10 Z" fill="#f6e2b0"/>
<path d="M120 400 Q190 372 256 400 T392 400" fill="none" stroke="#6fd3b4" stroke-width="7" stroke-linecap="round" opacity=".85"/>
<path d="M150 432 L178 404 M362 432 L334 404" stroke="#c99a58" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dq03newjr6neo"
path="res://.godot/imported/luneraria.svg-12b34911b283c50c0fc77d4b9fb43e87.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/emblems/luneraria.svg"
dest_files=["res://.godot/imported/luneraria.svg-12b34911b283c50c0fc77d4b9fb43e87.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,6 @@
[gd_resource type="Resource" script_class="LuneBalance" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/luneraria/core/lune_balance.gd" id="1"]
[resource]
script = ExtResource("1")

View file

@ -0,0 +1,65 @@
# Installation, Start, Steuerung und Export
## Start aus dem Quellprojekt (Fedora 44)
```bash
cd saumwacht-godot
godot --path . # Champions of Luneraria (Hauptszene scenes/luneraria.tscn)
godot --path . -- --legacy # archivierter Saumwacht-Modus (scenes/main.tscn)
```
Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebraucht, nicht zum Spielen.
## Eine Partie
1. **Neue Partie** → Bund (Glutpakt oder Wurzelbund) und deine Lane (Top, Mid, Bottom) wählen → **Partie beginnen**. Du spielst in Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot.
2. **Planung:** Truppen kaufen (Knöpfe oder 1–5), mit T/M/B einer Lane zuweisen, mit ↑ aufwerten, mit × zurückgeben. Käufe aus derselben Planung werden voll erstattet, ältere mit 75 %. **Startaufstellung** kauft Front, Nahkampf und Fernkampf. **Bereit** (Leertaste) übernimmt alle Befehle verbindlich.
3. **Kampf:** Er läuft automatisch. Deine Truppen rücken mit der eigenen Monsterwelle über ihre Lane vor. Leertaste oder P pausiert, Tempo 1×/2×/4×.
4. **Abrechnung:** Getötete Monster und Gebäude bringen Gold, dazu kommt das Rundeneinkommen. Deine Truppen kehren geheilt zurück. Türme und Kerne behalten ihren Schaden.
5. Die Partie endet erst, wenn ein Ancient fällt. Es gibt kein Rundenlimit.
## Steuerung
| Eingabe | Wirkung |
| --- | --- |
| 1–5 | Truppe kaufen (Planung) |
| Leertaste | Bereit / im Kampf Pause |
| P | Pause |
| WASD, Pfeiltasten, mittlere Maustaste ziehen | Kamera verschieben |
| Mausrad | Zoom |
| Tab / Lane-Knöpfe links | Kamera auf die nächste Lane |
| O | Kartenübersicht |
| Minimap klicken/ziehen | Kamera springt dorthin |
| F1 | Optionen und Steuerung |
| M / F11 | Ton / Vollbild |
## Prüfungen
```bash
godot --headless --path . res://tests/luneraria/test_lune_core.tscn # Regeln (M1: 91)
godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn # echte Bedienung, volle Partie
godot --headless --path . res://tools/luneraria/sim_match.tscn -- --seeds=1,2,3 --limit=80
godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn
godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn # Aufnahmen
# Legacy (Saumwacht) unverändert:
godot --headless --path . res://tests/test_runner.tscn
godot --headless --path . -- --smoke-test
godot --headless --path . -- --faction-test
godot --headless --path . -- --expedition-test
```
## Assets neu erzeugen
```bash
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_tower.py
python3 tools/luneraria/build_asset_matrix.py
godot --headless --editor --import --path .
```
## Export
Die Pakete für Champions of Luneraria kommen mit M4. Bis dahin baut `tools/build_demo.py` weiterhin die Saumwacht-Demo 0.15.0.
## Benutzereinstellungen
Der neue Projektname legt einen neuen Benutzerordner an (`~/.local/share/godot/app_userdata/Champions of Luneraria`). Beim ersten Start übernimmt `Preferences` einmalig die `preferences.cfg` aus „Saumwacht · Glutpakt“. Die alte Datei bleibt unverändert; fehlt sie, gelten die Standardwerte.

View file

@ -0,0 +1,26 @@
# Architektur- und Migrationsentscheidungen
Stand: 27. September 2026 (M0).
## Bestand (geprüft)
- **Werkzeuge:** Godot 4.7.2 (Fedora-Build; offizielle 4.7.2-Exportvorlagen unter `assets-source/phase6/export-templates`), Blender 5.2.2 LTS, Forward+ mit OpenGL-Kompatibilitätsmodus. Kein Engine-Update.
- **Repository:** Es gab kein Git. Angelegt wurde ein lokales Repository mit dem Baseline-Commit `c2b4476` auf `main` (unveränderter Stand 0.15.0, Phase 20A); die Arbeit läuft auf `luneraria/m1`. Screenshots und die Projekt-Doku außerhalb des Projektordners sind in `../backups/vor-luneraria-2026-09-27-docs-und-screenshots.tar.gz` gesichert. Keine uncommitteten fremden Änderungen; `../.mimocode` gehört zu einem fremden Werkzeug und bleibt unberührt. Ein Stand 20B existiert nicht. Keine CLAUDE.md.
- **Spiel:** eine Szene `scenes/main.tscn`, sieben Autoloads (`AssetLibrary`, `DataCatalog`, `GameState`, `EconomyManager`, `WaveManager`, `Preferences`, `Soundscape`), alle Oberflächen im Code. Kampf in `scripts/combat/`: Verteidiger gegen Angreifer je Lane, die Feindschaft hängt an `is_attacker`, die Lane an `team_id`.
- **Baseline-Tests** (`logs/m0-baseline/`): 209 · 33 · 463 · 206 · 1.665 bestanden.
## Entscheidungen
1. **Neuer Modus neben dem Legacy-Modus.** Die Saumwacht-Regeln (zwei Bretter, 8/20 Wellen, Kern-HP-Entscheid, Arbeiter/Funken) widersprechen den neuen Produktregeln. Sie werden nicht umgeschrieben, sondern als **Legacy-Modus** archiviert: `scenes/main.tscn` und die zugehörigen Tests bleiben lauffähig. Die neue Hauptszene `scenes/luneraria.tscn` startet Champions of Luneraria. Die bisherigen QA-Schalter (`--smoke-test`, `--faction-test`, `--expedition-test`) leitet sie in die Legacy-Szene um. So vermischen sich die Wirtschaftsmodelle nicht (Arbeitsannahme E), und die technischen Regressionen bleiben erhalten.
2. **Neue autoritative Simulation** in `scripts/luneraria/core/` als reine Datenklassen (`RefCounted`) ohne Szenenbaum. Der alte Kampf ist an zwei Bretter und `is_attacker` gebunden. Übernommen werden Einheitenwerte (`DataCatalog`), Schadensmatrix, Fähigkeitsregeln (auf Teamfeindschaft umgeschrieben), Modelle und die Animationsanbindung (`SkeletalFigure.update_combat`, `AnimatedFigure.pose`).
- `LuneBalance` (Resource) enthält alle Zahlen.
- `LuneMap` enthält Lane-Polylinien, 16 Türme und 2 Kerne, Aufmarschflächen, Sperrlogik und die Grotte. Die Karte entsteht per Konstruktion spiegelsymmetrisch zur Diagonale x = z: Team 1 ist das Spiegelbild von Team 0.
- `LuneCombat` rechnet einen festen Schritt in zwei Phasen, die Ergebnisse sind reihenfolgeunabhängig.
- `LuneMatch` verwaltet Teams, Spieler, Gold, Besitz mit eindeutigen IDs, Befehle, Runden, Abrechnung, Sieg und Ereignisprotokoll.
- `LuneWaves` erzeugt datengetriebene, unbegrenzte Wellen mit Mengengrenze.
- `LuneBot` nutzt dieselbe Befehlsschnittstelle wie der Mensch.
3. **Autoritative Matchinstanz von Anfang an.** Oberfläche und Bots senden nur Befehle (`LuneMatch.submit`), die beim Phasenwechsel atomar geprüft werden. Darstellung und Tests lesen denselben Zustand. Zufall kommt nur aus einer `RandomNumberGenerator`-Instanz mit Seed (Wellenrotation, Bots). Das ist die Grundlage für M4 (Host/Join), ohne Bit-Identität über Plattformen zu behaupten.
4. **Darstellung** in `scripts/luneraria/view/`: Kartenaufbau aus Bestandsmaterialien (Kluft-Shader, Kerne, Felsen, Bäume), neu erzeugte Türme (Blender-Generator), eine Einheitenansicht pro Kampfinstanz mit Positionsinterpolation zwischen den Simulationsschritten. Sichtbarkeit und Kamera beeinflussen die Simulation nicht.
5. **Oberfläche** in `scripts/luneraria/ui/` mit dem gemeinsamen `SaumwachtTheme` und seinen Bauteilen. Titel und Menü werden auf Champions of Luneraria umgestellt. Die Eingabeabschirmung aus 20A bleibt.
6. **Benutzereinstellungen:** Der Projektname wird zu „Champions of Luneraria“, dadurch ändert sich der `user://`-Pfad. `Preferences` übernimmt beim ersten Start einmalig die alte `preferences.cfg` aus „Saumwacht · Glutpakt“ und protokolliert das. Fehlt sie, gelten die Standardwerte.
7. **Tests:** Neue Tests liegen unter `tests/luneraria/` und `tools/luneraria/`, die Legacy-Tests bleiben unverändert und werden als „Legacy“ geführt. Neue Referenzen werden erst nach fachlicher Prüfung festgeschrieben.

View file

@ -0,0 +1,72 @@
# Asset-Wiederverwendungsmatrix
Erzeugt mit `python3 tools/luneraria/build_asset_matrix.py` aus den Laufzeitmanifesten. „Volk/Bund“ ist die Datenzuordnung. Die sieben Zielvölker (Menschen, Elfen, Zwerge, Trolle, Feen, Orks, Biestmenschen) haben noch **keine eigenen Modelle**; bestehende Wesen bleiben als eigene Bünde Teil der Welt. Als „vorläufig“ markierte Rollen sind Zuordnungen vorhandener Modelle, keine neu erstellten Völker.
| ID | Name | Pfad | Quelle | Rig / Clips | Dreiecke | bisherige Rolle | neue Rolle | Volk / Bund | Materialzustand | Eingriff |
| --- | --- | --- | --- | --- | ---: | --- | --- | --- | --- | --- |
| core_team0 | Saumkern · Team 0 | res://assets/models/core_team0.glb | assets-source/phase4/blend/core_team0.blend | starre Gelenkgruppen | 36652 | Saumkern Team 0 | Ancient Blau (SW) | — | HD, starre Teile | keiner |
| core_team1 | Saumkern · Team 1 | res://assets/models/core_team1.glb | assets-source/phase4/blend/core_team1.blend | starre Gelenkgruppen | 36848 | Saumkern Team 1 | Ancient Rot (NO) | — | HD, starre Teile | keiner |
| e01 | Splitterling | res://assets/models/enemy_e01.glb | assets-source/phase4/blend/e01.blend | starre Gelenkgruppen | 19228 | Wellengegner | Monsterwelle (teamgebunden, rotierend) | Riftbrut (neutral, pro Team eingefärbt) | HD, starre Teile | Teamkennzeichnung |
| e02 | Schalenläufer | res://assets/models/enemy_e02.glb | assets-source/phase4/blend/e02.blend | starre Gelenkgruppen | 20180 | Wellengegner | Monsterwelle | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e03 | Nebelrest | res://assets/models/enemy_e03.glb | assets-source/phase4/blend/e03.blend | starre Gelenkgruppen | 35898 | Wellengegner | Monsterwelle | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e04 | Fadenschütze | res://assets/models/enemy_e04.glb | assets-source/phase4/blend/e04.blend | starre Gelenkgruppen | 51050 | Wellengegner | Monsterwelle | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e05 | Bruchstampfer | res://assets/models/enemy_e05.glb | assets-source/phase4/blend/e05.blend | starre Gelenkgruppen | 62798 | Wellengegner | Monsterwelle (späte Variante) | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e06 | Knotenrufer | res://assets/models/enemy_e06.glb | assets-source/phase4/blend/e06.blend | starre Gelenkgruppen | 48336 | Wellengegner | Monsterwelle (Unterstützer) | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e07 | Bruchhirsch | res://assets/models/enemy_e07.glb | assets-source/phase4/blend/e07.blend | starre Gelenkgruppen | 33800 | Boss Welle 5 | Reserve (Wellen-Elite ab höheren Indizes) | Riftbrut | HD, starre Teile | offen |
| e08 | Riegelkoloss | res://assets/models/enemy_e08.glb | assets-source/phase4/blend/e08.blend | starre Gelenkgruppen | 81240 | Boss Welle 8 | Reserve (Wellen-Elite) | Riftbrut | HD, starre Teile | offen |
| e09 | Maskenrichter | res://assets/models/enemy_e09.glb | assets-source/phase4/blend/e09.blend | starre Gelenkgruppen | 57310 | Boss Welle 15 | Reserve | Riftbrut | HD, starre Teile | offen |
| e10 | Schlundkrone | res://assets/models/enemy_e10.glb | assets-source/phase4/blend/e10.blend | starre Gelenkgruppen | 64694 | Boss Welle 20 | neutraler Grottenboss und entsandter Boss (×1,7) | Riftbrut / Grotte | HD, starre Teile | umgesetzt (M2) |
| g01 | Kohlenbeißer | res://assets/models/unit_g01.glb | assets-source/phase4/blend/g01.blend | starre Gelenkgruppen | 79780 | Glutpakt Nahkampf | Truppe Nahkampf | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g02 | Schlackenklaue | res://assets/models/unit_g02.glb | assets-source/phase4/blend/g02.blend | starre Gelenkgruppen | 75624 | Glutpakt Nahkampf+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g03 | Schlackenwacht | res://assets/models/unit_g03.glb | assets-source/phase4/blend/g03.blend | starre Gelenkgruppen | 67232 | Glutpakt Tank | Truppe Front | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g04 | Basaltbastion | res://assets/characters/basaltbastion.glb | assets-source/phase15/basaltbastion.blend | 17 Knochen · 9 Clips | 17824 | Glutpakt Tank+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase15) | keiner |
| g05 | Funkenlanze | res://assets/characters/funkenlanze.glb | assets-source/phase18/funkenlanze.blend | 26 Knochen · 9 Clips | 24036 | Glutpakt Fernkampf | Truppe Fernkampf; zusätzlich Held „Funkenlanze“ (vorläufig; Goldring, Namensschild, ×1,25) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase18) | umgesetzt (M2) |
| g06 | Glasballiste | res://assets/characters/glasballiste.glb | assets-source/phase19/glasballiste.blend | 20 Knochen · 9 Clips | 15784 | Glutpakt Fernkampf+ | Truppenaufwertung (Belagerung) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase19) | keiner |
| g07 | Aschenwerfer | res://assets/models/unit_g07.glb | assets-source/phase4/blend/g07.blend | starre Gelenkgruppen | 81492 | Glutpakt Fläche | Truppe Fläche | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g08 | Kraterrufer | res://assets/models/unit_g08.glb | assets-source/phase4/blend/g08.blend | starre Gelenkgruppen | 77130 | Glutpakt Fläche+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g09 | Herdhüter | res://assets/models/unit_g09.glb | assets-source/phase4/blend/g09.blend | starre Gelenkgruppen | 84568 | Glutpakt Unterstützung | Truppe Unterstützung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g10 | Sonnenesse | res://assets/models/unit_g10.glb | assets-source/phase4/blend/g10.blend | starre Gelenkgruppen | 96706 | Glutpakt Unterstützung+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| m01 | Kesselwicht | res://assets/models/unit_m01.glb | assets-source/phase4/blend/m01.blend | starre Gelenkgruppen | 1284 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m02 | Druckhüter | res://assets/models/unit_m02.glb | assets-source/phase4/blend/m02.blend | starre Gelenkgruppen | 1360 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m03 | Scherschreiter | res://assets/models/unit_m03.glb | assets-source/phase4/blend/m03.blend | starre Gelenkgruppen | 932 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m04 | Sechsklingler | res://assets/models/unit_m04.glb | assets-source/phase4/blend/m04.blend | starre Gelenkgruppen | 1398 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m05 | Bolzenknecht | res://assets/models/unit_m05.glb | assets-source/phase4/blend/m05.blend | starre Gelenkgruppen | 1148 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m06 | Linienmeister | res://assets/models/unit_m06.glb | assets-source/phase4/blend/m06.blend | starre Gelenkgruppen | 1432 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m07 | Klangkolben | res://assets/models/unit_m07.glb | assets-source/phase4/blend/m07.blend | starre Gelenkgruppen | 740 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m08 | Resonanzramme | res://assets/models/unit_m08.glb | assets-source/phase4/blend/m08.blend | starre Gelenkgruppen | 1316 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m09 | Taktspindel | res://assets/models/unit_m09.glb | assets-source/phase4/blend/m09.blend | starre Gelenkgruppen | 1160 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m10 | Taktprälat | res://assets/models/unit_m10.glb | assets-source/phase4/blend/m10.blend | starre Gelenkgruppen | 946 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| map_lane_segment | Lane-Segment | res://assets/models/map_lane_segment.glb | assets-source/phase4/blend/map_lane_segment.blend | starre Gelenkgruppen | 10700 | Lane-Kachel alt | nicht verwendet; Lanes neu erzeugt | — | HD, starre Teile | keiner |
| n01 | Saummaske | res://assets/models/unit_n01.glb | assets-source/phase4/blend/n01.blend | starre Gelenkgruppen | 1132 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n02 | Grenzmaske | res://assets/models/unit_n02.glb | assets-source/phase4/blend/n02.blend | starre Gelenkgruppen | 1156 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n03 | Fadentänzer | res://assets/models/unit_n03.glb | assets-source/phase4/blend/n03.blend | starre Gelenkgruppen | 934 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n04 | Saumzertrenner | res://assets/models/unit_n04.glb | assets-source/phase4/blend/n04.blend | starre Gelenkgruppen | 1360 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n05 | Fernflüsterer | res://assets/models/unit_n05.glb | assets-source/phase4/blend/n05.blend | starre Gelenkgruppen | 1118 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n06 | Fernrufer | res://assets/models/unit_n06.glb | assets-source/phase4/blend/n06.blend | starre Gelenkgruppen | 1498 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n07 | Glockenschlepper | res://assets/models/unit_n07.glb | assets-source/phase4/blend/n07.blend | starre Gelenkgruppen | 1274 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n08 | Hohlgeläut | res://assets/models/unit_n08.glb | assets-source/phase4/blend/n08.blend | starre Gelenkgruppen | 1482 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n09 | Maskenkantor | res://assets/models/unit_n09.glb | assets-source/phase4/blend/n09.blend | starre Gelenkgruppen | 1314 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n10 | Vielstimmenkantor | res://assets/models/unit_n10.glb | assets-source/phase4/blend/n10.blend | starre Gelenkgruppen | 1734 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| s01 | Nadelspäher | res://assets/models/unit_s01.glb | assets-source/phase4/blend/s01.blend | starre Gelenkgruppen | 10340 | Söldner | Reserve Angriffschampion | Söldner | HD, starre Teile | offen |
| s02 | Rostschieber | res://assets/models/unit_s02.glb | assets-source/phase4/blend/s02.blend | starre Gelenkgruppen | 12040 | Söldner | Angriffschampion Schutz (Rostschieber) | Söldner | HD, starre Teile | umgesetzt (M2) |
| s03 | Dunstsänger | res://assets/models/unit_s03.glb | assets-source/phase4/blend/s03.blend | starre Gelenkgruppen | 7832 | Söldner | Angriffschampion Druck auf Unterstützer (Dunstsänger) | Söldner | HD, starre Teile | umgesetzt (M2) |
| s04 | Keilbrecher | res://assets/models/unit_s04.glb | assets-source/phase4/blend/s04.blend | starre Gelenkgruppen | 10458 | Söldner | Angriffschampion Belagerung (Keilbrecher) | Söldner | HD, starre Teile | umgesetzt (M2) |
| s05 | Risszehrer | res://assets/models/unit_s05.glb | assets-source/phase4/blend/s05.blend | starre Gelenkgruppen | 10440 | Söldner | Reserve Angriffschampion | Söldner | HD, starre Teile | offen |
| w01 | Dornhopser | res://assets/models/unit_w01.glb | assets-source/phase4/blend/w01.blend | starre Gelenkgruppen | 61376 | Wurzelbund Nahkampf | Truppe Nahkampf | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w02 | Rankenschnapper | res://assets/models/unit_w02.glb | assets-source/phase4/blend/w02.blend | starre Gelenkgruppen | 78020 | Wurzelbund Nahkampf+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w03 | Korkrücken | res://assets/models/unit_w03.glb | assets-source/phase4/blend/w03.blend | starre Gelenkgruppen | 84786 | Wurzelbund Tank | Truppe Front | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w04 | Ringborke | res://assets/characters/ringborke.glb | assets-source/phase11/ringborke.blend | 27 Knochen · 9 Clips | 118064 | Wurzelbund Tank+ | Truppenaufwertung; zusätzlich Held „Ringborke“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase11) | umgesetzt (M2) |
| w05 | Samenschleuder | res://assets/models/unit_w05.glb | assets-source/phase4/blend/w05.blend | starre Gelenkgruppen | 50536 | Wurzelbund Fernkampf | Truppe Fernkampf | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w06 | Zapfenkrone | res://assets/models/unit_w06.glb | assets-source/phase4/blend/w06.blend | starre Gelenkgruppen | 62588 | Wurzelbund Fernkampf+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w07 | Sporenkelch | res://assets/models/unit_w07.glb | assets-source/phase4/blend/w07.blend | starre Gelenkgruppen | 69164 | Wurzelbund Fläche | Truppe Fläche | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w08 | Pilzkathedrale | res://assets/models/unit_w08.glb | assets-source/phase4/blend/w08.blend | starre Gelenkgruppen | 103748 | Wurzelbund Fläche+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w09 | Tauhüter | res://assets/models/unit_w09.glb | assets-source/phase4/blend/w09.blend | starre Gelenkgruppen | 52040 | Wurzelbund Heiler | Truppe Unterstützung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w10 | Quellgeflecht | res://assets/characters/quellgeflecht.glb | assets-source/phase16/quellgeflecht.blend | 17 Knochen · 9 Clips | 14276 | Wurzelbund Heiler+ | Truppenaufwertung; zusätzlich Held „Quellgeflecht“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase16) | umgesetzt (M2) |
| worker | Funkenleser | res://assets/characters/arbeiter.glb | assets-source/phase12/arbeiter.blend | 27 Knochen · 10 Clips | 7184 | Arbeiter/Funken | nicht im neuen Modus (Arbeitsannahme E); Modell bleibt erhalten | — | gewichtetes Skelett (phase12) | keiner |
| menu-stage | Menü-Diorama | res://assets/environment/menu-stage.glb | assets-source/phase20/build_menu_stage.py | statisch | 48660 | Hauptmenü Phase 20A | Hauptmenü (Titel angepasst) | — | Kluft-Shader | keiner |
| verdant-rift | Grüne Kluft (2-Bretter-Arena) | res://assets/environment/verdant-rift.glb | assets-source/phase10/verdant-rift.blend | statisch | — | Spielfeld Saumwacht | Legacy-Modus; Materialien/Shader für die neue Karte wiederverwendet | — | triplanare Kluft-Shader | keine neue Nutzung als Karte |
| compare-rock / compare-tree | Fels, Baum | res://assets/compare/ | assets-source/phase14/build_compare_props.py | statisch | 360 / 852 | Vergleichsszene | Kartendekoration Dschungel | — | einfach | keiner |
| lane_tower | Laneturm | res://assets/luneraria/tower.glb | assets-source/luneraria/build_tower.py | statisch | 648 | — | 16 Lanetürme und Basistürme, Kristall zur Laufzeit in Teamfarbe | — | ENV_*-Kluftshader, Kristall emissiv | neu (M1) |
| boss_grotto | Bossgrotte | res://assets/luneraria/grotto.glb | assets-source/luneraria/build_grotto.py | statisch | 1888 | — | Felsinsel mit Höhle auf der Symmetrieachse, zwei gespiegelte Zugänge | — | ENV_*-Kluftshader, violette Kristalle | neu (M2) |
| luneraria-emblem | Emblem Luneraria | res://assets/ui/emblems/luneraria.svg | SVG | — | — | — | Titel und Menü | — | Vektor | neu (M1) |
| Emblem/Theme | Saumwacht-Theme | scripts/ui/theme/, assets/ui/ | Code / SVG | — | — | Menü 20A | Theme weiterverwendet, Titel/Emblem Luneraria | — | Vektor | Titelschrift/Emblem ergänzen |

73
docs/luneraria/BALANCE.md Normal file
View file

@ -0,0 +1,73 @@
# Balancekonfiguration und Auswertung
Alle Werte stehen in `scripts/luneraria/core/lune_balance.gd`. Die aktive Standardinstanz ist `data/luneraria/balance_default.tres`, die derzeit alle Klassenwerte übernimmt.
## Startwerte (M1) und Begründung
| Wert | Stand | Begründung |
| --- | ---: | --- |
| Startgold | 220 | Wie im Saumwacht-Einstieg; reicht für die Startaufstellung (180/185) plus Rest. |
| Grundeinkommen | 70 + 4 je Runde, Obergrenze +80 | Etwa eine Truppe pro Runde; der Zuwachs hält mit den Wellen mit, ohne endlos zu steigen. |
| Versorgungsgrenze | 12 Truppen je Spieler | Begrenzt aktive Einheiten; später investiert man in Aufwertungen statt in Masse. |
| Verkauf | 75 % (in derselben Planung 100 %) | Aus Saumwacht übernommen; die Rücknahme im selben Planungsschritt ist ein Bedienkomfort. |
| Wellen | 3 Monster je Lane und Team, +1 alle 3 Runden, max. 7 | Pro Runde höchstens 42 Monster auf der Karte. |
| Wellenstärke | HP ×(1 + 0,11·r + 0,0015·r²), Schaden ×(1 + 0,07·r + 0,0015·r²) | Stetig und quadratisch leicht zunehmend, ohne Obergrenze; bei r = 10⁶ endlich (Test). |
| Monsterkopfgeld | Katalogwert ×2 ×(1 + 0,04·r) | Monster sind die Hauptgoldquelle; wächst langsamer als die Stärke. |
| Türme | außen 6.000 / innen 7.500 / Basis 9.000 HP, 90 / 120 Schaden, Reichweite 8,6 m | Eine Welle allein kommt nicht durch. Die Reichweite liegt über der größten Einheitenreichweite (6,5 m + Gebäuderadius), sonst wären Türme ungestraft auszuschießen. |
| Ancient | 12.000 HP | Braucht eine voll durchbrochene Basis und mehrere Runden Druck. |
| Gebäudekopfgeld | 150 / 210 / 270 | Belohnt Durchbrüche gleichmäßig im Team. |
| Kampffenster | 180 s | In 91 Bot-Kampfphasen (8 Seeds) dauerte die längste 130 s, im Mittel 68 s; keine erreichte das Fenster. Es greift nur bei festgefahrenen Kämpfen. |
| Marschtempo | Katalogtempo ×1,6 | Eine Lane (~95 m) ist in ~30 s durchquerbar, damit Angriffe die Basis innerhalb eines Kampfes erreichen. |
## Auswertung M1 (reine Bot-Partien, 1 Mensch = Bot)
[sim-8-seeds.json](logs/m1/sim-8-seeds.json): acht Seeds mit gemischten Bünden und fünf Bot-Profilen (ausgewogen, verteidigung, druck, fernkampf, teamhilfe). Die Ergebnisse stehen in [STATUS.md](STATUS.md).
- Alle acht Partien enden regulär durch einen Ancient-Fall, nach 8–15 Wellen (Blau 5, Rot 3). 83 Kampfphasen enden, weil alle beweglichen Einheiten gefallen sind, 8 durch einen Kernfall, keine durch das Zeitfenster.
- **Exakter Spiegel** (sechsmal derselbe Bund, dasselbe deterministische Profil): Die Partie bleibt über 60 Runden Bit für Bit symmetrisch. Sie wird aber nicht entschieden, weil sich gleich starke Armeen und Wellen gegenseitig aufheben. Das ist der erwartbare Grenzfall identischen Spiels. Kein Timer oder Gebäudeverfall erzwingt ein Ende. Heldenwachstum, Angriffschampions und Boss (M2) liefern die beabsichtigten Asymmetrien.
- Kleine Botstichproben belegen keine ausgewogene Spielerfahrung. Die Bünde und Profile sind noch nicht getrennt ausgewertet (M3).
## M2 · Helden, Items, Champions, Boss
Werte in `scripts/luneraria/core/lune_content.gd`, Begründungen im [Regelwerk](REGELWERK.md) §4.
| Wert | Stand | Begründung |
| --- | ---: | --- |
| Heldenpreis | 260 | Mehr als das Startgold: Ein Held kommt frühestens in Runde 2 und ersetzt keine frühe Armee. |
| Heldenwerte | Ringborke 2.200 HP / 34, Quellgeflecht 1.300 / 28, Funkenlanze 950 / 46 | Aus den Truppenwerten der Aufwertungsstufe mit etwa 1,4-fachem Leben abgeleitet. |
| Stufen | 10; +9 % Leben, +7 % Schaden je Stufe | In 12 Bot-Partien erreichen Helden bis Partieende Stufe 3–6, Talentstufe 2 also erst spät. |
| Items | 180–260 | Ein Item kostet etwa eine aufgewertete Truppe. |
| Champions | 150/150/180, Leben ×2,2, Schaden ×1,6 der Söldnerwerte, Kopfgeld 70 + 6·r | Einmalige Welle etwa zum Preis von zwei Truppen. |
| Grottenboss | 4.200 HP × Wellenstärke, 55 Schaden | Nur mit großer Expedition schaffbar (Szenarien unten). |
| Entsandter Boss | 5.200 HP × Wellenstärke, 70 Schaden, Kopfgeld 520 + 60·r | Das Kopfgeld liegt deutlich über einem Turm (150–270) und über einem Monster (~10–30). |
### Bot-Serie (12 Seeds, [sim-12-seeds.json](logs/m2/sim-12-seeds.json))
- Alle 12 Partien enden regulär nach 7–10 Wellen (Blau 7, Rot 5), keine Kampfphase im Zeitfenster; die längste dauerte 128 s.
- Bossversuche: 36 gescheitert, 11 erobert, 7 entsandt (die übrigen Partien endeten vorher).
- **Befund:** Der Bossjäger-Bot versucht es in jeder Partie ab Runde 5, scheitert bis Runde 7 und siegt in Runde 8. Das Muster ist zu gleichförmig; der Bot schätzt seine Stärke nicht ein (M3).
- Siegquote des Teams mit dem jeweiligen Profil: fernkampf 11/12, druck 9/12, teamhilfe 7/12, verteidigung 5/12, bossjagd 3/12, ausgewogen 1/12. **Vorsicht:** Jede Partie enthält jedes Profil genau einmal, und die Paare fernkampf/ausgewogen, druck/bossjagd und verteidigung/teamhilfe stehen immer in verschiedenen Teams. Die Zahlen sind daher nicht unabhängig; eine saubere Auswertung mit variierter Teamzusammensetzung folgt in M3.
### Bossstrategie im direkten Vergleich ([boss_scenarios.gd](../../tools/luneraria/boss_scenarios.gd))
Identischer Stand nach Runde 7, danach vier Runden mit gewöhnlichen Bots ohne Bossjagd. Wert = eingesetzter Armeewert (Blau / Rot). ΔHP und ΔGold jeweils Blau / Rot über die vier Runden.
| Seed | Szenario | Wert | Grotte | Mid-Türme Blau verloren | Bossrunde | Gebäudeschaden Rot durch Boss | Kopfgeld für Rot | ΔHP Gebäude | ΔGold | Sieger |
| --- | --- | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 3 | kein_versuch | 0 | none | 0 | — | 0 | — | -1.436 / -50.521 | 284 / -235 | Blau |
| 3 | gescheitert | 210 | failed | 0 | — | 0 | — | -1.754 / -50.257 | 284 / -235 | Blau |
| 3 | erfolgreich | 1000 | won | 2 | 9 | 33.219 | 1060 | -15.023 / -35.384 | 543 / 794 | Blau |
| 3 | umkaempft | 1000 / 995 | failed | 0 | — | 0 | — | -1.435 / -51.276 | 494 / -185 | Blau |
| 3 | ueberinvestition | 2495 | won | 2 | 9 | 25.392 | 1060 | -68.295 / -25.392 | -250 / 437 | Rot |
| 7 | kein_versuch | 0 | none | 0 | — | 0 | — | -9.222 / -5.573 | 174 / 139 | offen |
| 7 | gescheitert | 180 | failed | 0 | — | 0 | — | -9.548 / -4.660 | 174 / 139 | offen |
| 7 | erfolgreich | 930 | won | 2 | 9 | 22.793 | 1060 | -67.737 / -22.793 | -302 / 319 | Rot |
| 7 | umkaempft | 930 / 865 | failed | 0 | — | 0 | — | -9.855 / -4.660 | 174 / 139 | offen |
| 7 | ueberinvestition | 2580 | won | 2 | 9 | 20.685 | 1060 | -67.737 / -20.685 | -320 / 277 | Rot |
**Deutung:**
- Eine gescheiterte kleine Expedition kostet wenig.
- In der umkämpften Grotte reiben sich beide Expeditionen auf; kein Team erhält den Anspruch.
- Eine erfolgreiche Eroberung zieht die Mid-Lane so weit ab, dass Blau dort beide Türme verliert. Der Boss richtet danach 20.000–33.000 HP Gebäudeschaden an, stirbt und bringt Rot 1.060 Gold.
- Überinvestition kippt die Partie in beiden Seeds zu Rot, und auch die normale Eroberung verliert bei Seed 7.
- Die Bossstrategie ist damit riskant und nicht dominant. Nach diesen Stichproben wiegt der Boss die geschwächte Lane eher zu wenig auf: ein Balancepunkt für M3 (z. B. mehr Bosswirkung oder schwächerer Grottenboss). Es gibt weder eine kostenlose Sofortrückkehr noch eine Doppelbelohnung (Tests); das Goldrisiko steht im Kopfgeld offen angekündigt.

View file

@ -0,0 +1,55 @@
# QA-Matrix
Testumgebung aller Läufe: Fedora Linux 44 (KDE Plasma, XWayland), AMD Ryzen 5 5600X, NVIDIA GeForce RTX 5060 Ti (Vulkan 1.4.351), Godot 4.7.2 (Fedora-Build), Forward+. **Windows 11 ist nicht geprüft:** Es gibt keine Windows-Hardware in dieser Umgebung.
Legende: ✅ automatisiert geprüft · 👁 in echten Aufnahmen visuell geprüft · ⏳ offen (Meilenstein) · — nicht zutreffend.
## Abnahmepunkte aus dem Masterauftrag (§15)
| # | Anforderung | Stand | Beleg |
| --- | --- | --- | --- |
| 1 | Zwei Teams, sechs Eigentümerrollen, drei Lanes, 16 Türme, 2 Kerne | ✅ M1 | `test_lune_core` · structure |
| 2 | Turmreihenfolge, Basisschutz, Kernsieg; gleichzeitige Kernzerstörung ohne Reihenfolgevorteil | ✅ M1 | `test_lune_core` · gates, victory_and_draw, mirror_fairness |
| 3 | Fortsetzung nach Welle 20, hohe Indizes ohne Final-Wave-Logik, Fehler oder Objektwachstum | ✅ M1 (Wellen bis 10⁶, Runde 21; Menge ≤ 7 je Lane) | `test_lune_core` · endless_waves |
| 4 | Gold exakt einmal, Besitzprüfung, keine Reset-/Despawn-/Beschwörungsfarmen | ✅ M1 (Monster, Gebäude, Truppentod ohne Kopfgeld); Beschwörungen gibt es noch nicht | `test_lune_core` · rewards, commands |
| 5 | Heilung und Persistenz nach der Runde; neue Partie ohne übernommene Werte | ✅ M1 Truppen · ✅ M2 Helden (XP, Stufe, Talente, Items bleiben; Rückkehr geheilt, auch nach dem Tod; neue Partie ohne Held) | `test_lune_core` · persistence; `test_lune_m2` · xp_and_persistence |
| 6 | Truppen ohne Heldeninventar; Talente, drei Itemplätze, Stapeln | ✅ M2 | `test_lune_m2` · heroes_and_items |
| 7 | Verstärkungskauf n → Einsatz n+1, richtige Lane und richtiges Team | ✅ M2 (öffentliche Ankündigung, keine Verschiebung nach Übernahme, Verbrauch nach n+1, Kopfgeld, Belagerungsziel) | `test_lune_m2` · champions |
| 8 | Grottenabzug schwächt die Lane für die ganze Kampfphase | ✅ M2 | `test_lune_m2` · grotto_withdrawal_and_contest |
| 9 | Beide Expeditionen, Scheitern, Erfolg, genau eine Entsendung, Kopfgeld, Verfügbarkeit ab Runde 10 | ✅ M2 (Sieg in Runde 6 → Einsatz 7 → gesperrt 7–9 → frei ab 10; wiederholtes Signal ohne Wirkung) | `test_lune_m2` · boss_*; Szenarien `boss_scenarios` |
| 10 | Laneverlust, Phasenende, Pause, Neustart, Rückkehr ins Menü | ✅ M1/M2 | `verify_m1`, `test_lune_core` · victory (timeout), `test_lune_m2` · lane_loss |
| 11 | Bots ohne Sonderressourcen und ohne Sicht auf private Befehle | ✅ Bots nutzen dieselben Befehle ohne Sondergold; sie lesen nur den eigenen Entwurf, veröffentlichte Gebäudestände, den öffentlichen Bosszustand und Ankündigungen (Code-Durchsicht, `LuneBot`) | `test_lune_core` · commands; `test_lune_m2` · bots_m2 |
| 12 | Netzwerk: doppelte, veraltete und fremde Befehle; synchroner Phasenwechsel, Disconnect | ⏳ M4 | — |
## Echte Anwendung (M1)
`tools/luneraria/verify_m1.tscn`, 38 Prüfungen mit echten Maus- und Tastaturereignissen:
- Titel und Projektname, Lane- und Bundwahl per Klick, Start.
- Eingabeschutz während der Überblendung: Mausrad, 1 und Leertaste erreichen das Spiel nicht.
- Kauf, Startaufstellung, Rückgängig, Laneeinteilung und die Leertaste übernehmen die Planung; im Kampf wird nichts gekauft.
- Truppen kämpfen auf ihrer Lane; Pause hält die Kampfuhr an; Tempo schaltet um.
- Abrechnung und Rückkehr der Truppen.
- HUD innerhalb des Fensters bei 1280 × 720 und 1920 × 1080.
- Menü, neue Partie ohne Altlasten, eine vollständige Partie bis zum Ancient-Fall mit Live-Aufnahme der Belagerung, Ergebnisbildschirm, Rematch.
- Vier Menü-/Partiewechsel ohne wachsende Objektzahl.
Nur die Kampfuhr wird dabei beschleunigt (mehr feste Schritte pro Bild), die Regeln bleiben unverändert.
## Echte Anwendung (M2)
`tools/luneraria/verify_m2.tscn`, 15 Prüfungen. Teil 1 bedient das HUD mit echten Klicks: Reiter, Held anwerben, Held-Lane, Truppe kaufen und in die Grotte schicken, Verstärkungsseite, Rückgängig. Teil 2 spielt eine gewöhnliche Partie ohne Sondergold bis zum Ancient-Fall; der menschliche Platz wird vom Bot-Planer über dieselben Befehle gespielt. Aufgenommen: Held mit Items, Held im Feld, Grottenkampf, Ankündigung, Bossentsendung.
Weitere M2-Belege: `test_lune_m2` 73/73, darunter der Nachweis, dass die alte Arbeiter- und Funkenökonomie nicht mitläuft (Arbeitsannahme E).
## Legacy-Regressionen (Saumwacht)
Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206 · 1.665 · Menü 20A 68. Logs: `logs/m0-baseline/`, `logs/m1/legacy-*.log`.
## Plattformen
| Plattform | Stand |
| --- | --- |
| Fedora 44, Forward+ | ✅ Quellprojekt (Tests, Aufnahmen, Leistung) |
| Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ M4 (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht) |
| Windows 11 | ⏳ nicht geprüft (keine Hardware). Ein exportiertes Paket oder ein Wine-Lauf wäre kein nativer Test. |

View file

@ -0,0 +1,72 @@
# Champions of Luneraria · Regelwerk
Stand: 27. September 2026. Drei Ebenen, getrennt gehalten:
1. **Verbindliche Produktregeln** aus dem Masterauftrag. Änderungen nur per ausdrücklicher Entscheidung des Auftraggebers.
2. **Arbeitsannahmen A–F** aus dem Masterauftrag, als anpassbare Designentscheidungen formuliert. Es sind keine Nutzerzitate.
3. **Eigene Detailentscheidungen** dieser Umsetzung, anpassbar und mit Konfigurationsort.
## 1 · Verbindliche Produktregeln
- Titel: **Champions of Luneraria**.
- Zwei Teams mit je drei eigenständigen Spielerrollen.
- Drei zusammenhängende Lanes: Top, Mid, Bottom.
- Kämpfe laufen automatisch. Es gibt weder direkte Heldensteuerung noch manuelles Last-Hitting.
- Die Partie endet ausschließlich durch Zerstörung eines Ancient-Kerns. Es gibt keine maximale Rundenzahl, keinen Sieg nach einer Wellengrenze, keine Entscheidung nach Kern-HP und keinen Sieger per Zeitlimit.
- Werden beide Kerne im selben Simulationsschritt zerstört, ist das ein Unentschieden, unabhängig von Array-, Render- oder Netzwerkreihenfolge. Ein manueller Testabbruch oder technischer Abbruch ist kein Kern-Sieg.
- Monsterwellen werden mit jeder Welle stärker.
- Je Team und Lane zwei Lanetürme; zusätzliche Türme schützen den Ancient.
- Normale Truppen werden dauerhaft gekauft und aufgewertet; sie haben kein Levelsystem und keine Items.
- Helden werden gekauft, sammeln in der Partie Erfahrung und entwickeln sich über Items und Talente.
- Angriffschampions werden mit Gold als einmalige Verstärkung einer Angriffswelle gekauft.
- Besiegte Monster und Angriffschampions geben Gold.
- Eine gemeinsame Bossgrotte bindet Einheiten, die dafür ihre Lane verlassen.
- Nach erfolgreicher Eroberung verstärkt der Boss einmalig den Angriff des **siegreichen** Teams in der folgenden Runde, gegen das **gegnerische** Team. Er läuft zum gegnerischen Ancient.
- Der entsandte Boss gibt dem verteidigenden Team bei seinem Tod deutlich mehr Gold als ein normaler Gegner.
- Nach einem Bosssieg ist die Grotte drei vollständige Runden gesperrt. Beispiel: Sieg in Runde 6, Einsatz in Runde 7, Sperre 7–9, wieder bekämpfbar ab 10.
- Vorhandene Assets werden bevorzugt erhalten. Inhaltsziel: Elfen, Trolle, Zwerge, Menschen, Feen, Orks, Biestmenschen; bestehende Wesen und Bünde bleiben Teil der Welt.
- Ziel ist taktische Wiederspielbarkeit.
## 2 · Arbeitsannahmen (Masterauftrag, anpassbar)
- **A** Dauerhafte Truppen und Helden rücken mit den eigenen Monsterwellen über ihre Lane vor und greifen danach zulässige gegnerische Gebäude an.
- **B** Jeder Spieler hat eigenes Gold und eine eigene Armee. Seine Lane ist die anfängliche Zuständigkeit; Unterstützung anderer Lanes ist möglich. Das Team ist unabhängig von Volk und Bund.
- **C** Ein aktiver Held pro Spieler mit drei Itemplätzen; mehrere Helden zur Wahl. Normale Truppen bleiben relevant.
- **D** Genau zwei gemeinsame Basistürme pro Team: acht Türme je Team, sechzehn auf der Karte, plus zwei Ancients.
- **E** Die alte Arbeiter- und Funkenökonomie läuft im neuen Modus nicht. Gold finanziert Armee, Aufwertungen, Helden, Items und Angriffschampions.
- **F** Erster Modus: echte Sechs-Spieler-Simulation mit einem Menschen und fünf Bots. Netzwerk folgt als eigener Meilenstein.
## 3 · Detailentscheidungen dieser Umsetzung
Alle Zahlen stehen zentral in `data/luneraria/balance_default.tres` (Klasse `LuneBalance`).
| Thema | Entscheidung |
| --- | --- |
| Runde | Planung → Befehle werden atomar übernommen → automatischer Kampf → Abrechnung → nächste Planung. Die Wellennummer steigt unbegrenzt. |
| Simulation | Feste Schrittweite 1/20 s. Jeder Schritt läuft in zwei Phasen: erst werden alle Treffer, Heilungen und Effekte aus dem Zustand zu Schrittbeginn berechnet, dann gleichzeitig angewendet. Todesfälle eines Schritts gelten gemeinsam. So entsteht kein Vorteil aus der Verarbeitungsreihenfolge. |
| Aufmarsch | Armeen und Wellen starten jede Runde an festen, symmetrischen Aufmarschflächen ihrer Lane. Formationsplätze sind Offsets vor/quer zur Laufrichtung. Keine Front-Teleports. |
| Vormarsch | Ohne Ziel folgen Einheiten ihrer Lane. Sie kommen nicht an einem lebenden gegnerischen Turm ihrer Lane vorbei. Am Laneende greifen sie das nächste zulässige Basisgebäude an. |
| Gebäudesperren | Der innere Turm ist erst nach dem äußeren verwundbar. Die Basistürme sind verwundbar, sobald eine Lane des Teams ganz durchbrochen ist. Der Ancient ist erst verwundbar, wenn beide Basistürme gefallen sind. Die Sperre gilt für jeden Schaden (Fernkampf, Fläche, Fähigkeiten, Boss). Gesperrte Gebäude schießen weiter. |
| Kampfende | Eine Kampfphase endet, wenn keine beweglichen Einheiten mehr leben, wenn ein Kern fällt oder wenn das Zeitfenster abläuft (Standard 180 s). Das Zeitfenster beendet nur die Kampfphase, nie die Partie. Übrige temporäre Einheiten verschwinden ohne Belohnung, dauerhafte kehren zurück. Es gibt keinen Kernschaden durch den Abbruch. |
| Rückkehr | Dauerhafte Truppen und Helden kehren nach jeder Kampfphase vollständig geheilt zurück, auch wenn sie im Kampf gefallen sind. Schilde, Brand, Verlangsamungen, Stärkungen und Abklingzeiten werden auf Startwerte zurückgesetzt. Aufwertungen, Erfahrung, Items und Talente bleiben. |
| Gebäude | Türme und Kerne behalten Schaden; zerstörte Gebäude kehren nicht zurück. Gebäude skalieren nicht mit der Welle. |
| Belohnung | Pro gegnerischem Todesereignis genau eine Belohnung, egal wer tötet. Lane-Killgold geht zur Hälfte an den zuständigen Lane-Spieler des tötenden Teams und zur Hälfte gleichmäßig an alle drei. Der Rest der Ganzzahlteilung geht in stabiler Slotreihenfolge. Gebäude- und Boss-Kopfgelder werden gleichmäßig im Team verteilt. Dauerhafte Truppen und Helden geben kein Kopfgeld. |
| Laneverlust | Kein Spieler scheidet aus. Er baut weiter auf, verteidigt und unterstützt. |
| Pause | Offline darf die Planung auf den Menschen warten; Pause und Tempo 1×/2×/4× gelten nur lokal. |
| QA-Grenze | Testläufe dürfen eine Rundenobergrenze haben. Eine dort nicht beendete Partie wird als „Testlimit erreicht / Partie nicht abgeschlossen“ protokolliert, nie als Sieg oder Unentschieden. |
## 4 · Detailentscheidungen M2 (anpassbar, Werte in `scripts/luneraria/core/lune_content.gd`)
| Thema | Entscheidung |
| --- | --- |
| Helden | Drei Helden aus vorhandenen Skelettmodellen, als **vorläufige Rollenzuordnung**: Ringborke (Front/Schutz), Quellgeflecht (Heilung), Funkenlanze (Fernkampf). Jeder Spieler kann einen aktiven Helden anwerben, unabhängig vom Bund, für 260 Gold. Damit ist ein Held frühestens ab Runde 2 erschwinglich. Ein Held aus derselben Planung lässt sich voll erstatten, danach bleibt er bis Partieende. |
| Erfahrung | Nicht an den letzten Treffer gebunden. Jeder Held, der an einer Kampfphase teilnimmt, erhält 40 + 6 × Runde. Jeder gegnerische Tod im Umkreis von 12 m (Gebäude 14 m) wird zu gleichen Teilen auf alle Helden in der Nähe verteilt: Monster 12 + Runde, Champion 30 + 2 × Runde, Boss 220, Truppe/Held 15, Gebäude 60. So erhalten auch Heiler in der zweiten Reihe Erfahrung. Stufen 1–10, je Stufe +9 % Leben und +7 % Schaden. |
| Talente | Auf Stufe 3 und 6 je eine endgültige Wahl zwischen zwei Richtungen: Eigen- gegen Gruppenschutz oder Einzelziel gegen Fläche, je Held eigene Optionen. |
| Items | Sechs dauerhafte Items, drei Plätze. Jedes Item höchstens einmal pro Held. Kristallsplitter setzt Heldenstufe 3 voraus. Verkauf bringt 50 %, ein Kauf aus derselben Planung wird voll erstattet. Boni verschiedener Items addieren sich; die Schadensminderung ist auf 35 %, das Angriffstempo auf +50 % begrenzt. |
| Reihenfolge im Kampf | Primärtreffer → Grundfähigkeit → Talent → Item-Effekt. Item- und Talenteffekte lösen keine weiteren Treffer-Effekte aus, Heilungen lösen nichts aus; dadurch sind Schleifen ausgeschlossen. Normale Truppen haben kein Inventar. |
| Angriffschampions | Rostschieber (Schutz, 150 Gold), Dunstsänger (Druck auf Unterstützer, 150), Keilbrecher (Belagerung, ×2,5 gegen Gebäude, 180). Ein Kauf in Runde n wird für Runde n+1 auf eine Lane gebucht, höchstens zwei Buchungen pro Runde. Bis zum Ende der Planung lässt sich eine Buchung voll erstatten, danach ist sie weder verschiebbar noch erstattbar. Buchungen werden zu Beginn von n+1 öffentlich angekündigt. Der Champion kämpft einmal und verschwindet. Die Stärke skaliert mit der Einsatzrunde, das Kopfgeld beträgt 70 + 6 × Runde und wird wie Monstergold verteilt. |
| Grotte · Zuweisung | Die Zuweisung „G“ gilt für genau eine Runde. Die Einheiten fehlen ihrer Lane die ganze Kampfphase, starten an festen, gespiegelten Grotteneingängen und kehren bei der Abrechnung auf die Lane ihres Besitzers zurück. In Sperrrunden ist die Zuweisung abgelehnt. |
| Grotte · Kampf | Der neutrale Boss ruht, solange Expeditionen beider Teams leben; die Expeditionen kämpfen zuerst gegeneinander. Erst wenn höchstens ein Team übrig ist, erwacht der Boss mit vollen Lebenspunkten. Tod aller Expeditionen oder Ablauf des Zeitfensters: kein Anspruch. Der nächste Versuch trifft einen vollständig neuen Boss. Todesfälle in der Grotte bringen kein Gold. |
| Anspruch | Genau ein Anspruch pro Sieg; ein wiederholtes Abrechnungssignal ändert nichts. Ziel-Lane: die Lane des beitragenden Spielers mit dem höchsten eingesetzten Armeewert in der Grotte (Held einschließlich Items); bei Gleichstand der niedrigere Slot. Dieser Spieler kann die Lane in der folgenden Planung ändern; ohne Eingabe gilt seine eigene Lane. |
| Entsendung | Automatisch in der folgenden Runde, für das siegreiche Team, gegen den gegnerischen Ancient. Gebäudesperren gelten unverändert. Stärke und Kopfgeld (520 + 60 × Runde) richten sich nach der Einsatzrunde. Das Kopfgeld geht bei seinem Tod gleichmäßig an das verteidigende Team. Überlebt er, verschwindet er ohne Belohnung. |
| Sperre | `next_available_round = Siegrunde + 4`. Beispiel: Sieg in Runde 6, Einsatz in Runde 7, gesperrt in 7–9, bekämpfbar ab Runde 10. Die Sperre hängt am Grottensieg, nicht am Tod des entsandten Bosses. |

73
docs/luneraria/STATUS.md Normal file
View file

@ -0,0 +1,73 @@
# STATUS · Champions of Luneraria
Stand: 28. September 2026 · Branches `luneraria/m1` (M0/M1) und `luneraria/m2` · Version im Projekt 0.15.0 (Saumwacht-Demo), Luneraria-Paket erst mit M4.
| Meilenstein | Zustand |
| --- | --- |
| M0 · Migration vorbereiten | **abgeschlossen** |
| M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** (siehe unten) |
| M2 · Mechanikdurchlauf | **abgeschlossen** (siehe unten) |
| M3 · Qualität und Balance | offen |
| M4 · Netzwerk und Pakete | offen |
## M1 · abgeschlossen
**Implementiert**
- Autoritative Partie mit sechs Spielerrollen (ein Mensch, fünf Bots), drei spiegelsymmetrischen Lanes, 16 Türmen und zwei Ancients.
- Gebäudesperren für jede Schadensquelle.
- Vorrückende Armeen mit eigenen, unbegrenzt wachsenden Monsterwellen.
- Planung mit atomarer Übernahme, Besitz-IDs, Einmal-Belohnung mit verlustfreier Restverteilung, Rückkehr und Heilung der Truppen, persistenter Gebäudeschaden.
- Partieende nur durch Kernfall; bei gleichzeitigem Kernfall Unentschieden.
- Bots über dieselbe Befehlsschnittstelle.
- 3D-Karte aus Bestandsassets plus neuem Turm (Blender), Einheiten mit den vorhandenen Modellen und Animationen.
- HUD mit Planung, Laneübersicht, Minimap, Ergebnis; Titelmenü auf Champions of Luneraria umgestellt.
- Übernahme der alten Benutzereinstellungen. Legacy-Modus bleibt unter `--legacy` und den alten QA-Schaltern erreichbar.
**Automatisiert geprüft**
- Regeln 91/91, darunter der exakte Spiegeltest über 12 Runden.
- Echte Anwendung 38/38, mit einer vollständigen Partie bis zum Ancient-Fall.
- Acht Bot-Seeds enden regulär nach 8–15 Wellen.
- Legacy 209 · 33 · 463 · 206 · 1.665 · 68 unverändert.
**Visuell geprüft:** Menü, Planung (720p/1080p), Kampf Mid/Top, Übersicht, Belagerung eines Ancients, Ergebnis (`screenshots/`).
**Leistung** (RTX 5060 Ti, Ryzen 5 5600X, Forward+, VSync aus, 2×): 114 bewegliche Einheiten und 18 Gebäude. 1080p: 7,6 ms/Bild (p95 10,5 ms), GPU 3,1 ms, ~3.170 Draw Calls, Simulationsschritt 2,4 ms (vor der Optimierung 15,8 ms). Es gibt keine allgemeine FPS-Zusage.
**Ungetestet:** Windows 11, Kompatibilitätsmodus für Luneraria, menschliche Spieltests.
## M2 · abgeschlossen
**Implementiert**
- Drei Helden (vorläufige Rollen der vorhandenen Skelettmodelle Ringborke, Quellgeflecht, Funkenlanze) mit Erfahrung aus Gefechtsnähe und Rundenteilnahme, Stufen, je zwei Talentstufen mit echten Aufgabenwechseln und drei Itemplätzen für sechs Items.
- Drei Angriffschampions (Schutz, Druck auf Unterstützer, Belagerung) mit Buchung n → n+1 und öffentlicher Ankündigung.
- Bossgrotte mit ruhendem Boss bei zwei Expeditionen, Scheitern, Eroberung, genau einer Entsendung für das siegreiche Team gegen den gegnerischen Ancient, hohem Kopfgeld für die Verteidiger und Sperre ab `Sieg + 4`.
- Wahl der Ziel-Lane durch den beitragenden Spieler; Grottenaufträge gelten für genau eine Runde.
- HUD mit Reitern Truppen / Held / Verstärkung, Heldenporträt aus dem echten Modell, Grottenstatus, Ankündigungen und Minimap-Grotte.
- Neues Grotten-Asset (Blender). Bots mit Helden, Items, Talenten, Champions und Bossjagd.
**Automatisiert geprüft**
- Regeln M2 73/73 und M1 91/91 (mit Spiegeltest).
- Echte Anwendung M2 15/15 und M1 38/38.
- 12 Bot-Seeds enden regulär nach 7–10 Wellen.
- Szenariovergleich der Bossstrategie (2 Seeds × 5 Varianten).
**Visuell geprüft:** Held mit Items im HUD, Held im Feld, Grottenkampf, Ankündigung, Bossentsendung (`screenshots/m2-*`).
**Ungetestet:** menschliche Spieltests der neuen Systeme, Windows 11.
## Bekannt
- Ein exakt gespiegeltes Match bleibt unentschieden, wird also nicht beendet (Testlimit). Das ist ein Balancebefund für M3.
- Die Figuren der Monsterwellen sind nur über Teamring und Lichtfarbe dem Team zugeordnet.
- Bäume und Felsen stammen aus dem Vergleichspack und sind einfach; das Kartenbild ist funktional, noch nicht endgültig (M3).
- Der Bossjäger-Bot greift zu früh an (Fehlschläge in Runde 5–7, Sieg in Runde 8); nach den Szenarien wiegt der Boss die geschwächte Lane eher zu wenig auf (M3).
- Die Auswertung der Bot-Profile ist verzerrt, weil in jeder Partie dieselben Profilpaare gegeneinander stehen (M3: variierte Teams).
- Beim Beenden meldet Godot eine noch belegte Ressource (Hintergrundladen der Menübühne); ohne Folgen im Spiel.
## Blockiert
- Nativer Windows-11-Test: keine Windows-Hardware.
## Nächster Schritt
M3: mehrere Seeds und Teamzusammensetzungen, Seitentausch, Gegenstrategien, hohe Wellenindizes als Belastungstest, Boss- und Profilbalance, visuelle Überarbeitung der Karte, danach Repräsentanten der fehlenden Völker.

View file

@ -0,0 +1,4 @@
{
"checks": 91,
"failures": []
}

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
ABILITY_EVENT_PASS: 33 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,250 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
ERROR: Attempting to initialize the wrong RID
at: get_or_null (./core/templates/rid_owner.h:240)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "mem" is null.
at: initialize_rid (./core/templates/rid_owner.h:284)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_count (servers/rendering/dummy/storage/mesh_storage.h:74)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
EXPEDITION_MATCH: Standard gegen Standard; wave 20; cores [669, 669]; outcome -1
EXPEDITION_MATCH: Standard gegen Lernmodus; wave 10; cores [2038, 0]; outcome 0
EXPEDITION_MATCH: Leeres Baufeld gegen Standard; wave 7; cores [0, 3000]; outcome 1
EXPEDITION_PASS: 206 checks; 0 failures; report /home/martin/.local/share/godot/app_userdata/Saumwacht · Glutpakt/phase8-verification.json
WARNING: 6 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 RID allocations of type 'N13RendererDummy9DummyMeshE' were leaked at exit.
EXIT 0

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
FACTION_MATCH: Wurzelbund gegen Glutpakt wave 20 cores [1244, 1709]
FACTION_MATCH: Glutpakt gegen Wurzelbund wave 20 cores [1709, 1244]
FACTION_MATCH: Wurzelbund Spiegel wave 20 cores [1244, 1244]
FACTION_MATCH: Wurzelbund Kurzpartie wave 8 cores [3000, 3000]
FACTION_PASS: 1665 checks; 0 failures; /home/martin/.local/share/godot/app_userdata/Saumwacht · Glutpakt/phase10-verification.json
EXIT 0

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
EXPORT_SMOKE_PASS: 463 checks; 0 failures; engine 4.7.2-stable (fedora); cores [3000, 1035]
SMOKE_CAPTURES: /home/martin/.local/share/godot/app_userdata/Saumwacht · Glutpakt/phase8-smoke
EXIT 0

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
PASS: 209 checks across economy, combat and the full match lifecycle.
EXIT 0

View file

@ -0,0 +1,10 @@
{
"checks": 38,
"failures": [],
"object_counts": [
3850.0,
3850.0,
3850.0,
3850.0
]
}

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
LUNE_BENCH {"buildings":18,"cpu":"AMD Ryzen 5 5600X 6-Core Processor","draw_calls_mean":3175.86281588448,"engine":"4.7.2-stable (fedora)","frame_ms_mean":7.22646841155234,"frame_ms_p95":10.294,"frames":1108,"gpu":"NVIDIA GeForce RTX 5060 Ti","gpu_ms_mean":2.09521750809914,"mobile_units_at_start":114,"mobile_units_mean_measured":108.60559566787,"renderer":"forward_plus","scene_build_ms":198.002,"sim_speed":"2x (40 steps/s)","sim_step_ms":2.37125,"static_mem_mb":120.703545570374,"video_mem_mb":658.710235595703,"window":"(1280, 720)"}
WARNING: 24 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
LUNE_BENCH {"buildings":18,"cpu":"AMD Ryzen 5 5600X 6-Core Processor","draw_calls_mean":3172.21948912015,"engine":"4.7.2-stable (fedora)","frame_ms_mean":7.56970955534533,"frame_ms_p95":10.505,"frames":1057,"gpu":"NVIDIA GeForce RTX 5060 Ti","gpu_ms_mean":3.06410596431599,"mobile_units_at_start":114,"mobile_units_mean_measured":108.593188268685,"renderer":"forward_plus","scene_build_ms":221.213,"sim_speed":"2x (40 steps/s)","sim_step_ms":2.403405,"static_mem_mb":120.915734291077,"video_mem_mb":765.283081054688,"window":"(1920, 1080)"}
WARNING: 24 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
ABILITY_EVENT_PASS: 33 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,250 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
ERROR: Attempting to initialize the wrong RID
at: get_or_null (./core/templates/rid_owner.h:240)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "mem" is null.
at: initialize_rid (./core/templates/rid_owner.h:284)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_count (servers/rendering/dummy/storage/mesh_storage.h:74)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
EXPEDITION_MATCH: Standard gegen Standard; wave 20; cores [669, 669]; outcome -1
EXPEDITION_MATCH: Standard gegen Lernmodus; wave 10; cores [2038, 0]; outcome 0
EXPEDITION_MATCH: Leeres Baufeld gegen Standard; wave 7; cores [0, 3000]; outcome 1
EXPEDITION_PASS: 206 checks; 0 failures; report /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase8-verification.json
WARNING: 6 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 RID allocations of type 'N13RendererDummy9DummyMeshE' were leaked at exit.
EXIT 0

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
FACTION_MATCH: Wurzelbund gegen Glutpakt wave 20 cores [1244, 1709]
FACTION_MATCH: Glutpakt gegen Wurzelbund wave 20 cores [1709, 1244]
FACTION_MATCH: Wurzelbund Spiegel wave 20 cores [1244, 1244]
FACTION_MATCH: Wurzelbund Kurzpartie wave 8 cores [3000, 3000]
FACTION_PASS: 1665 checks; 0 failures; /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase10-verification.json
EXIT 0

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
EXPORT_SMOKE_PASS: 463 checks; 0 failures; engine 4.7.2-stable (fedora); cores [3000, 1035]
SMOKE_CAPTURES: /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase8-smoke
EXIT 0

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
PASS: 209 checks across economy, combat and the full match lifecycle.
EXIT 0

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
MENU_VERIFY_PASS: 68 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,9 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
LUNE_FULL_MATCH: seed 2 -> Blau after 8 rounds
LUNE_CORE_PASS: 91 checks; 0 failures
WARNING: 10 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
EXIT 0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
R01 cleared 46.7s buildings [9, 9] lost [] gold [123, 173, 123, 128, 123, 128]
R02 cleared 53.7s buildings [9, 9] lost [] gold [140, 105, 115, 125, 125, 150]
R03 cleared 55.5s buildings [9, 9] lost [] gold [160, 170, 150, 125, 160, 175]
R04 cleared 69.1s buildings [8, 9] lost ["blau_top_outer"] gold [182, 142, 132, 182, 172, 257]
R05 cleared 74.3s buildings [8, 9] lost [] gold [178, 183, 163, 133, 153, 153]
R06 cleared 70.1s buildings [7, 8] lost ["rot_mid_outer", "blau_top_inner"] gold [203, 223, 183, 243, 253, 203]
R07 cleared 73.3s buildings [7, 7] lost ["rot_mid_inner"] gold [270, 235, 270, 170, 195, 205]
R08 cleared 79.9s buildings [6, 7] lost ["blau_base_1"] gold [245, 185, 165, 275, 245, 280]
R09 core 86.9s buildings [4, 7] lost ["blau_base_2", "blau_ancient"] gold [57, 106, 66, 171, 176, 176]
SIM_MATCH seed=1 Sieg Rot after 9 rounds (Ancient von Blau zerstört.) in 7091 ms
R01 cleared 46.7s buildings [9, 9] lost [] gold [128, 123, 128, 173, 128, 123]
R02 cleared 53.7s buildings [9, 9] lost [] gold [125, 125, 150, 105, 125, 115]
R03 cleared 54.7s buildings [9, 9] lost [] gold [125, 160, 175, 165, 165, 115]
R04 cleared 73.1s buildings [9, 8] lost ["rot_top_outer"] gold [182, 172, 257, 137, 157, 187]
R05 cleared 81.9s buildings [8, 7] lost ["blau_mid_outer", "rot_top_inner"] gold [203, 223, 223, 218, 183, 193]
R06 cleared 67.3s buildings [8, 7] lost [] gold [158, 178, 133, 138, 153, 133]
R07 cleared 74.7s buildings [7, 6] lost ["blau_mid_inner", "rot_base_1"] gold [260, 285, 310, 225, 235, 255]
R08 core 92.9s buildings [7, 3] lost ["rot_bot_outer", "rot_base_2", "rot_ancient"] gold [207, 197, 217, 79, 105, 64]
SIM_MATCH seed=2 Sieg Blau after 8 rounds (Ancient von Rot zerstört.) in 6499 ms
R01 cleared 41.9s buildings [9, 9] lost [] gold [173, 128, 123, 128, 123, 173]
R02 cleared 46.7s buildings [9, 9] lost [] gold [105, 125, 115, 135, 140, 105]
R03 cleared 59.8s buildings [9, 9] lost [] gold [165, 165, 115, 175, 160, 170]
R04 cleared 61.4s buildings [9, 9] lost [] gold [137, 157, 187, 137, 182, 142]
R05 cleared 54.9s buildings [9, 9] lost [] gold [168, 133, 143, 133, 178, 183]
R06 cleared 57.9s buildings [9, 9] lost [] gold [163, 173, 173, 173, 153, 173]
R07 cleared 58.3s buildings [9, 9] lost [] gold [180, 185, 225, 185, 205, 185]
R08 cleared 61.9s buildings [8, 9] lost ["blau_bot_outer"] gold [200, 200, 155, 250, 255, 250]
R09 cleared 71.3s buildings [8, 9] lost [] gold [213, 218, 188, 198, 168, 198]
R10 cleared 74.2s buildings [7, 9] lost ["blau_bot_inner"] gold [215, 250, 215, 300, 265, 300]
R11 cleared 83.7s buildings [5, 9] lost ["blau_top_outer", "blau_base_2"] gold [228, 218, 218, 348, 358, 413]
R12 core 86.9s buildings [1, 9] lost ["blau_mid_outer", "blau_top_inner", "blau_base_1", "blau_ancient"] gold [101, 106, 133, 348, 318, 323]
SIM_MATCH seed=3 Sieg Rot after 12 rounds (Ancient von Blau zerstört.) in 9910 ms
R01 cleared 44.6s buildings [9, 9] lost [] gold [128, 123, 173, 123, 128, 123]
R02 cleared 46.5s buildings [9, 9] lost [] gold [135, 140, 105, 115, 125, 125]
R03 cleared 55.1s buildings [9, 9] lost [] gold [175, 160, 170, 150, 125, 160]
R04 cleared 63.1s buildings [9, 9] lost [] gold [137, 182, 142, 132, 132, 122]
R05 cleared 62.3s buildings [9, 9] lost [] gold [133, 178, 183, 163, 173, 153]
R06 cleared 59.7s buildings [9, 9] lost [] gold [173, 153, 173, 133, 213, 148]
R07 cleared 72.1s buildings [9, 8] lost ["rot_bot_outer"] gold [235, 255, 235, 160, 190, 165]
R08 cleared 62.5s buildings [8, 8] lost ["blau_top_outer"] gold [180, 180, 180, 205, 210, 215]
R09 cleared 74.8s buildings [8, 7] lost ["rot_bot_inner"] gold [268, 263, 268, 238, 223, 178]
R10 cleared 80.3s buildings [8, 7] lost [] gold [235, 205, 235, 210, 265, 185]
R11 core 117.5s buildings [7, 4] lost ["blau_mid_outer", "rot_base_2", "rot_base_1", "rot_ancient"] gold [288, 348, 303, 223, 178, 178]
SIM_MATCH seed=4 Sieg Blau after 11 rounds (Ancient von Rot zerstört.) in 9299 ms
R01 cleared 42.1s buildings [9, 9] lost [] gold [123, 128, 123, 128, 173, 128]
R02 cleared 50.3s buildings [9, 9] lost [] gold [115, 125, 125, 150, 105, 125]
R03 cleared 55.1s buildings [9, 9] lost [] gold [150, 125, 160, 175, 165, 165]
R04 cleared 66.9s buildings [9, 9] lost [] gold [132, 132, 122, 207, 137, 157]
R05 cleared 60.3s buildings [9, 9] lost [] gold [163, 173, 153, 198, 168, 133]
R06 cleared 61.9s buildings [9, 9] lost [] gold [133, 213, 148, 178, 163, 173]
R07 cleared 87.3s buildings [9, 8] lost ["rot_top_outer"] gold [210, 240, 215, 180, 180, 185]
R08 cleared 89.3s buildings [9, 8] lost [] gold [205, 210, 165, 165, 200, 200]
R09 cleared 80.3s buildings [8, 7] lost ["blau_bot_outer", "rot_top_inner"] gold [308, 293, 303, 218, 263, 268]
R10 cleared 84.1s buildings [8, 7] lost [] gold [220, 195, 195, 185, 190, 185]
R11 cleared 84.8s buildings [7, 6] lost ["blau_bot_inner", "rot_base_1"] gold [363, 343, 358, 323, 313, 308]
R12 cleared 83.7s buildings [7, 6] lost [] gold [267, 237, 237, 232, 272, 232]
R13 cleared 95.7s buildings [6, 5] lost ["rot_mid_outer", "blau_base_2"] gold [303, 338, 333, 388, 393, 393]
R14 cleared 82.1s buildings [6, 5] lost [] gold [390, 295, 305, 310, 320, 375]
R15 core 130.3s buildings [6, 3] lost ["rot_base_2", "rot_ancient"] gold [411, 511, 291, 216, 311, 351]
SIM_MATCH seed=5 Sieg Blau after 15 rounds (Ancient von Rot zerstört.) in 14879 ms
R01 cleared 48.3s buildings [9, 9] lost [] gold [128, 173, 128, 123, 128, 123]
R02 cleared 52.5s buildings [9, 9] lost [] gold [150, 105, 125, 115, 135, 140]
R03 cleared 60.7s buildings [9, 9] lost [] gold [175, 165, 165, 115, 175, 160]
R04 cleared 64.5s buildings [9, 9] lost [] gold [207, 137, 157, 187, 137, 182]
R05 cleared 59.1s buildings [9, 9] lost [] gold [198, 168, 133, 143, 133, 178]
R06 cleared 58.5s buildings [9, 8] lost ["rot_mid_outer"] gold [228, 213, 223, 173, 173, 153]
R07 cleared 72.7s buildings [8, 8] lost ["blau_top_outer"] gold [230, 155, 165, 275, 235, 255]
R08 cleared 86.1s buildings [7, 7] lost ["rot_mid_inner", "blau_top_inner"] gold [285, 245, 250, 230, 250, 250]
R09 cleared 72.4s buildings [7, 6] lost ["rot_bot_outer"] gold [218, 248, 273, 243, 203, 203]
R10 cleared 74.8s buildings [7, 6] lost [] gold [195, 220, 215, 270, 245, 250]
R11 cleared 92.9s buildings [7, 5] lost ["rot_bot_inner"] gold [288, 343, 278, 283, 218, 278]
R12 core 89.1s buildings [7, 2] lost ["rot_base_2", "rot_base_1", "rot_ancient"] gold [328, 288, 348, 108, 163, 87]
SIM_MATCH seed=6 Sieg Blau after 12 rounds (Ancient von Rot zerstört.) in 10244 ms
R01 cleared 41.3s buildings [9, 9] lost [] gold [123, 128, 123, 173, 123, 128]
R02 cleared 51.5s buildings [9, 9] lost [] gold [115, 135, 140, 105, 115, 125]
R03 cleared 52.5s buildings [9, 9] lost [] gold [115, 175, 160, 170, 150, 125]
R04 cleared 57.7s buildings [9, 9] lost [] gold [187, 137, 182, 142, 132, 132]
R05 cleared 57.5s buildings [9, 9] lost [] gold [143, 133, 178, 183, 163, 173]
R06 cleared 61.7s buildings [9, 9] lost [] gold [173, 173, 153, 173, 133, 213]
R07 cleared 61.1s buildings [9, 9] lost [] gold [225, 185, 205, 185, 160, 190]
R08 cleared 57.5s buildings [9, 9] lost [] gold [155, 200, 205, 200, 155, 160]
R09 cleared 72.9s buildings [9, 9] lost [] gold [188, 218, 193, 218, 188, 173]
R10 cleared 72.1s buildings [8, 9] lost ["blau_bot_outer"] gold [215, 250, 240, 280, 285, 265]
R11 cleared 84.4s buildings [5, 9] lost ["blau_mid_outer", "blau_top_outer", "blau_bot_inner"] gold [218, 218, 238, 398, 453, 408]
R12 core 98.9s buildings [0, 9] lost ["blau_mid_inner", "blau_top_inner", "blau_base_2", "blau_base_1", "blau_ancient"] gold [156, 101, 123, 463, 563, 528]
SIM_MATCH seed=7 Sieg Rot after 12 rounds (Ancient von Blau zerstört.) in 10269 ms
R01 cleared 41.9s buildings [9, 9] lost [] gold [173, 123, 128, 123, 128, 173]
R02 cleared 47.5s buildings [9, 9] lost [] gold [105, 115, 125, 125, 150, 105]
R03 cleared 55.5s buildings [9, 9] lost [] gold [170, 150, 125, 160, 175, 165]
R04 cleared 55.1s buildings [9, 9] lost [] gold [142, 132, 132, 122, 207, 137]
R05 cleared 54.5s buildings [9, 9] lost [] gold [183, 163, 173, 153, 198, 168]
R06 cleared 57.7s buildings [9, 9] lost [] gold [173, 133, 213, 148, 178, 163]
R07 cleared 58.7s buildings [9, 9] lost [] gold [185, 160, 190, 165, 180, 180]
R08 cleared 54.9s buildings [9, 9] lost [] gold [200, 155, 160, 165, 165, 200]
R09 cleared 66.5s buildings [9, 8] lost ["rot_top_outer"] gold [268, 238, 223, 178, 168, 213]
R10 cleared 87.5s buildings [9, 7] lost ["rot_top_inner"] gold [280, 280, 335, 210, 200, 215]
R11 cleared 87.7s buildings [9, 6] lost ["rot_bot_outer"] gold [323, 403, 303, 238, 218, 228]
R12 core 103.0s buildings [9, 1] lost ["rot_base_1", "rot_mid_outer", "rot_bot_inner", "rot_base_2", "rot_ancient"] gold [418, 493, 458, 143, 113, 118]
SIM_MATCH seed=8 Sieg Blau after 12 rounds (Ancient von Rot zerstört.) in 10274 ms
WARNING: 6 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
EXIT 0

View file

@ -0,0 +1,15 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
M1_MENU_READY_MS 733
M1_SHOT planning-1280x720
M1_SHOT planning-1920x1080
M1_SHOT combat-mid-live
M1_SHOT settlement
M1_SHOT combat-top
M1_SHOT overview-combat
M1_SHOT core-siege
M1_SHOT result
M1_MATCH_RESULT Ancient von Rot zerstört. after 10 waves
M1_VERIFY_PASS: 38 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,44 @@
{
"boss_log": [
{
"event": "failed",
"round": 5
},
{
"event": "failed",
"round": 6
},
{
"event": "failed",
"round": 7
},
{
"chooser": 4,
"event": "won",
"round": 8,
"team": 1
},
{
"event": "deployed",
"lane": "top",
"round": 9,
"team": 1
},
{
"chooser": 4,
"event": "won",
"round": 12,
"team": 1
}
],
"checks": 15,
"failures": [],
"rounds": 12,
"seen": [
"grotto",
"hero_field",
"hero_items",
"announce",
"boss"
]
}

View file

@ -0,0 +1,4 @@
{
"checks": 73,
"failures": []
}

View file

@ -0,0 +1,85 @@
[
{
"blau_gold_delta": 284,
"blau_hp_after_r8": 0.0,
"blau_hp_delta": -1436.5,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "none",
"phase": "vorbei",
"rot_gold_delta": -235,
"rot_hp_delta": -50521.0198,
"rot_sent_value": 0,
"scenario": "kein_versuch",
"sent_value": 0,
"winner": 0
},
{
"blau_gold_delta": 284,
"blau_hp_after_r8": -317.199999999997,
"blau_hp_delta": -1753.7,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "vorbei",
"rot_gold_delta": -235,
"rot_hp_delta": -50257.2862,
"rot_sent_value": 0,
"scenario": "gescheitert",
"sent_value": 210,
"winner": 0
},
{
"blau_gold_delta": 543,
"blau_hp_after_r8": -13227.8841,
"blau_hp_delta": -15022.5341,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 794,
"rot_hp_delta": -35384.1102,
"rot_hp_lost_boss_round": 33218.6196,
"rot_sent_value": 0,
"scenario": "erfolgreich",
"sent_value": 1000,
"winner": 0
},
{
"blau_gold_delta": 494,
"blau_hp_after_r8": -401.375,
"blau_hp_delta": -1434.51880000001,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "vorbei",
"rot_gold_delta": -185,
"rot_hp_delta": -51276.0262,
"rot_sent_value": 995,
"scenario": "umkaempft",
"sent_value": 1000,
"winner": 0
},
{
"blau_gold_delta": -250,
"blau_hp_after_r8": -36433.06827,
"blau_hp_delta": -68294.9678,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 437,
"rot_hp_delta": -25391.9446,
"rot_hp_lost_boss_round": 25391.9446,
"rot_sent_value": 0,
"scenario": "ueberinvestition",
"sent_value": 2495,
"winner": 1
}
]

View file

@ -0,0 +1,85 @@
[
{
"blau_gold_delta": 174,
"blau_hp_after_r8": -3771.781,
"blau_hp_delta": -9222.031,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "none",
"phase": "läuft",
"rot_gold_delta": 139,
"rot_hp_delta": -5573.1798,
"rot_sent_value": 0,
"scenario": "kein_versuch",
"sent_value": 0,
"winner": -2
},
{
"blau_gold_delta": 174,
"blau_hp_after_r8": -4097.3806,
"blau_hp_delta": -9547.6306,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "läuft",
"rot_gold_delta": 139,
"rot_hp_delta": -4659.805,
"rot_sent_value": 0,
"scenario": "gescheitert",
"sent_value": 180,
"winner": -2
},
{
"blau_gold_delta": -302,
"blau_hp_after_r8": -16247.2932,
"blau_hp_delta": -67736.578,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 319,
"rot_hp_delta": -22792.746,
"rot_hp_lost_boss_round": 22792.746,
"rot_sent_value": 0,
"scenario": "erfolgreich",
"sent_value": 930,
"winner": 1
},
{
"blau_gold_delta": 174,
"blau_hp_after_r8": -4404.387,
"blau_hp_delta": -9854.637,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "läuft",
"rot_gold_delta": 139,
"rot_hp_delta": -4659.805,
"rot_sent_value": 865,
"scenario": "umkaempft",
"sent_value": 930,
"winner": -2
},
{
"blau_gold_delta": -320,
"blau_hp_after_r8": -38337.2773,
"blau_hp_delta": -67736.578,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 277,
"rot_hp_delta": -20684.7692,
"rot_hp_lost_boss_round": 20684.7692,
"rot_sent_value": 0,
"scenario": "ueberinvestition",
"sent_value": 2580,
"winner": 1
}
]

View file

@ -0,0 +1,9 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
LUNE_FULL_MATCH: seed 2 -> Blau after 7 rounds
LUNE_CORE_PASS: 91 checks; 0 failures
WARNING: 10 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
EXIT 0

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
LUNE_M2_PASS: 65 checks; 0 failures
WARNING: 14 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
EXIT 0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
M1_MENU_READY_MS 792
M1_SHOT planning-1280x720
M1_SHOT planning-1920x1080
M1_SHOT combat-mid-live
M1_SHOT settlement
M1_SHOT combat-top
M1_SHOT overview-combat
M1_SHOT core-siege
M1_SHOT result
M1_MATCH_RESULT Ancient von Rot zerstört. after 7 waves
M1_VERIFY_PASS: 38 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,16 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
M2APP_SHOT m2-hud-hero-round1
M2APP_SHOT m2-grotto-fight
M2APP_SHOT m2-hero-field
M2APP_SHOT m2-hero-items
M2APP_SHOT m2-announcements
M2APP_SHOT m2-boss-deployed
M2APP_BOSS_LOG [{ "round": 5, "event": "failed" }, { "round": 6, "event": "failed" }, { "round": 7, "event": "failed" }, { "round": 8, "event": "won", "team": 1, "chooser": 4 }, { "round": 9, "event": "deployed", "team": 1, "lane": "top" }, { "round": 12, "event": "won", "team": 1, "chooser": 4 }]
M2APP_VERIFY_PASS: 15 checks; 0 failures
WARNING: 8 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
EXIT 0

View file

@ -0,0 +1,20 @@
{
"buildings": 18,
"cpu": "AMD Ryzen 5 5600X 6-Core Processor",
"draw_calls_mean": 3175.86281588448,
"engine": "4.7.2-stable (fedora)",
"frame_ms_mean": 7.22646841155234,
"frame_ms_p95": 10.294,
"frames": 1108,
"gpu": "NVIDIA GeForce RTX 5060 Ti",
"gpu_ms_mean": 2.09521750809914,
"mobile_units_at_start": 114,
"mobile_units_mean_measured": 108.60559566787,
"renderer": "forward_plus",
"scene_build_ms": 198.002,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 2.37125,
"static_mem_mb": 120.703545570374,
"video_mem_mb": 658.710235595703,
"window": "(1280, 720)"
}

View file

@ -0,0 +1,20 @@
{
"buildings": 18,
"cpu": "AMD Ryzen 5 5600X 6-Core Processor",
"draw_calls_mean": 3172.21948912015,
"engine": "4.7.2-stable (fedora)",
"frame_ms_mean": 7.56970955534533,
"frame_ms_p95": 10.505,
"frames": 1057,
"gpu": "NVIDIA GeForce RTX 5060 Ti",
"gpu_ms_mean": 3.06410596431599,
"mobile_units_at_start": 114,
"mobile_units_mean_measured": 108.593188268685,
"renderer": "forward_plus",
"scene_build_ms": 221.213,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 2.403405,
"static_mem_mb": 120.915734291077,
"video_mem_mb": 765.283081054688,
"window": "(1920, 1080)"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cxfgbyo316gip"
path="res://.godot/imported/combat-mid-live.png-1baea78d38cdbbdf1b2c012c8459c83c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/combat-mid-live.png"
dest_files=["res://.godot/imported/combat-mid-live.png-1baea78d38cdbbdf1b2c012c8459c83c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdpr2aef1mtyw"
path="res://.godot/imported/combat-mid.png-47877f493d55e3cb715252d7b2b177f7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/combat-mid.png"
dest_files=["res://.godot/imported/combat-mid.png-47877f493d55e3cb715252d7b2b177f7.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bcesqxwj7xds5"
path="res://.godot/imported/combat-top.png-409d757457196da3b4d7dde0737a858f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/combat-top.png"
dest_files=["res://.godot/imported/combat-top.png-409d757457196da3b4d7dde0737a858f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdnafrcapn3qp"
path="res://.godot/imported/core-siege.png-6a2f95e61f8e13a0d0145605efd83a90.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/core-siege.png"
dest_files=["res://.godot/imported/core-siege.png-6a2f95e61f8e13a0d0145605efd83a90.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdogy4irokv2y"
path="res://.godot/imported/menu-setup.png-1991aae81ee557037e9c72f26ad60780.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/menu-setup.png"
dest_files=["res://.godot/imported/menu-setup.png-1991aae81ee557037e9c72f26ad60780.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dpmpwywqtf8gb"
path="res://.godot/imported/menu-title.png-c5fe9b889c2b85b3983f3c07b80447e5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/menu-title.png"
dest_files=["res://.godot/imported/menu-title.png-c5fe9b889c2b85b3983f3c07b80447e5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c4cj5wabcwwpo"
path="res://.godot/imported/overview-combat.png-047b1bec10668f137599feea356858c9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/overview-combat.png"
dest_files=["res://.godot/imported/overview-combat.png-047b1bec10668f137599feea356858c9.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://1vimfsrdmwes"
path="res://.godot/imported/overview-planning.png-36712c5e2948fd3a6d16bb3a7612d671.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/overview-planning.png"
dest_files=["res://.godot/imported/overview-planning.png-36712c5e2948fd3a6d16bb3a7612d671.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://irf7f3pko78d"
path="res://.godot/imported/planning-1280x720.png-4961a39d8036e60c703f21f94b767a05.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/planning-1280x720.png"
dest_files=["res://.godot/imported/planning-1280x720.png-4961a39d8036e60c703f21f94b767a05.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://eg2x2tnm2r4a"
path="res://.godot/imported/planning-1920x1080.png-9474e0516c4f03723ac6abf839ff75d9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/planning-1920x1080.png"
dest_files=["res://.godot/imported/planning-1920x1080.png-9474e0516c4f03723ac6abf839ff75d9.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cv4mo6gqrgr7l"
path="res://.godot/imported/planning.png-ea15c73d57d5147150129852dec1ced2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/planning.png"
dest_files=["res://.godot/imported/planning.png-ea15c73d57d5147150129852dec1ced2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://coio2a87vbvp4"
path="res://.godot/imported/result.png-9d37465fb26c73ab171191d98bea3f04.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/result.png"
dest_files=["res://.godot/imported/result.png-9d37465fb26c73ab171191d98bea3f04.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cunkl1amyr2p2"
path="res://.godot/imported/settlement.png-fde2624ce641379c962c75deb3114891.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/settlement.png"
dest_files=["res://.godot/imported/settlement.png-fde2624ce641379c962c75deb3114891.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -2,7 +2,7 @@
"checks": 68, "checks": 68,
"engine": "4.7.2-stable (fedora)", "engine": "4.7.2-stable (fedora)",
"failures": [], "failures": [],
"formations_ready_ms": 895, "formations_ready_ms": 1089,
"menu_instantiate_ms": 515, "menu_instantiate_ms": 571,
"renderer": "forward_plus" "renderer": "forward_plus"
} }

View file

@ -14,10 +14,10 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="Saumwacht · Glutpakt" config/name="Champions of Luneraria"
config/description="Glutpakt und Wurzelbund · 20 Wellen gegen die Rivalen-KI" config/description="3 gegen 3 auf drei Lanes · Autobattler · Partie endet mit dem Fall eines Ancients"
config/version="0.15.0" config/version="0.15.0"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/luneraria.tscn"
config/features=PackedStringArray("4.7", "Forward Plus") config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://assets/icons/resources/res_core.png" config/icon="res://assets/icons/resources/res_core.png"

6
scenes/luneraria.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/luneraria/lune_main.gd" id="1"]
[node name="Luneraria" type="Node"]
script = ExtResource("1")

View file

@ -14,8 +14,22 @@ func _ready() -> void:
read_settings() read_settings()
apply() apply()
## The project was renamed from "Saumwacht · Glutpakt"; its user folder changed with it.
const LEGACY_FOLDER := "Saumwacht · Glutpakt"
var migrated_from: String = ""
func _legacy_path() -> String:
var own: String = ProjectSettings.globalize_path("user://")
var parent: String = own.trim_suffix("/").get_base_dir()
return parent.path_join(LEGACY_FOLDER).path_join("preferences.cfg")
func read_settings() -> void: func read_settings() -> void:
var config := ConfigFile.new() var config := ConfigFile.new()
if storage_path == "user://preferences.cfg" and not FileAccess.file_exists(storage_path) and FileAccess.file_exists(_legacy_path()):
# One-time copy of the old options; the old file stays untouched.
if config.load(_legacy_path()) == OK and config.save(storage_path) == OK:
migrated_from = _legacy_path()
print("PREFERENCES: übernommen aus ", migrated_from)
if config.load(storage_path) != OK: if config.load(storage_path) != OK:
return return
volume = clampf(float(config.get_value("audio", "volume", .65)), 0.0, 1.0) volume = clampf(float(config.get_value("audio", "volume", .65)), 0.0, 1.0)

View file

@ -0,0 +1,69 @@
class_name LuneBalance
extends Resource
## Every tunable number of Champions of Luneraria. The default instance lives in
## data/luneraria/balance_default.tres; tests may load variants, bots never get extras.
@export_group("Simulation")
## Fixed combat step in seconds.
@export var sim_step: float = 0.05
## Ends only the current combat phase, never the match (see REGELWERK).
@export var combat_time_limit: float = 180.0
## Lane marching speed relative to the catalogue speed of a unit.
@export var march_speed_factor: float = 1.6
@export var aggro_radius: float = 7.0
## Targets further away than this are dropped and re-acquired.
@export var leash_radius: float = 10.0
@export var lane_half_width: float = 2.6
@export_group("Economy")
@export var start_gold: int = 220
@export var base_income: int = 70
@export var income_per_round: int = 4
@export var income_growth_cap: int = 80
@export var supply_cap: int = 12
@export_range(0.0, 1.0) var sell_refund: float = 0.75
## Share of lane kill gold for the responsible lane player; the rest splits evenly in the team.
@export_range(0.0, 1.0) var lane_share: float = 0.5
@export_group("Waves")
@export var wave_base_count: int = 3
## One more monster per lane every N rounds, up to wave_max_count.
@export var wave_count_every: int = 3
@export var wave_max_count: int = 7
@export var wave_hp_growth: float = 0.11
@export var wave_damage_growth: float = 0.07
@export var wave_growth_quadratic: float = 0.0015
@export var monster_bounty_scale: float = 2.0
@export var bounty_growth: float = 0.04
## Monster types in rotation order; later indices unlock later (see LuneWaves).
@export var monster_pool: PackedStringArray = PackedStringArray(["e01", "e02", "e04", "e03", "e06", "e05"])
@export_group("Buildings")
@export var tower_outer_hp: float = 6000.0
@export var tower_inner_hp: float = 7500.0
@export var base_tower_hp: float = 9000.0
@export var core_hp: float = 12000.0
@export var tower_damage: float = 90.0
@export var base_tower_damage: float = 120.0
## Must exceed the longest unit reach (6.5 m + building radius) so towers cannot be out-ranged.
@export var tower_range: float = 8.6
@export var tower_attack_interval: float = 1.0
@export var building_radius: float = 1.3
@export var bounty_outer_tower: int = 150
@export var bounty_inner_tower: int = 210
@export var bounty_base_tower: int = 270
@export_group("Map")
## Inner / outer tower position as a fraction of the lane length from the own base.
@export var inner_tower_fraction: float = 0.17
@export var outer_tower_fraction: float = 0.35
@export var tower_side_offset: float = 3.4
static func default_balance() -> LuneBalance:
var path := "res://data/luneraria/balance_default.tres"
if ResourceLoader.exists(path):
return load(path) as LuneBalance
return LuneBalance.new()
func round_income(round_number: int) -> int:
return base_income + mini(income_growth_cap, income_per_round * maxi(0, round_number - 1))

View file

@ -0,0 +1 @@
uid://daf3k13ys862

View file

@ -0,0 +1,157 @@
class_name LuneBot
extends RefCounted
## Plans through LuneMatch.submit exactly like the human: same costs, same caps, same phase.
## Reads only public state (committed buildings, round, boss state, announcements) and its
## own draft, never other players' drafts.
const PROFILES: Dictionary = {
"ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0,
"hero": "held_ringborke", "hero_round": 3, "items": ["basaltschild", "glutklinge", "rankenmantel"], "talents": [0, 1], "champion": ""},
"verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0,
"hero": "held_ringborke", "hero_round": 4, "items": ["rankenmantel", "basaltschild", "quellamulett"], "talents": [0, 0], "champion": ""},
"druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0,
"hero": "held_funkenlanze", "hero_round": 3, "items": ["glutklinge", "funkenfokus", "kristallsplitter"], "talents": [1, 1], "champion": "ch_belagerung"},
"fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0,
"hero": "held_funkenlanze", "hero_round": 4, "items": ["funkenfokus", "glutklinge", "kristallsplitter"], "talents": [0, 0], "champion": "ch_druck"},
"teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35,
"hero": "held_quellgeflecht", "hero_round": 3, "items": ["quellamulett", "rankenmantel", "basaltschild"], "talents": [1, 0], "champion": "ch_schutz"},
"bossjagd": {"front": .35, "melee": .25, "ranged": .20, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0,
"hero": "held_ringborke", "hero_round": 3, "items": ["glutklinge", "basaltschild", "rankenmantel"], "talents": [1, 1], "champion": "",
"boss_from": 5, "boss_troops": 7},
}
static func profile_for(match_state: LuneMatch, slot: int) -> String:
var names: Array = PROFILES.keys()
return names[(slot * 7 + match_state.seed_value) % names.size()]
static func plan(match_state: LuneMatch, slot: int, profile_name: String = "") -> void:
var name: String = profile_name if not profile_name.is_empty() else profile_for(match_state, slot)
var profile: Dictionary = PROFILES[name]
var player: Dictionary = match_state.players[slot]
var ids: Array[String] = match_state.base_ids(player.bund)
var roles: Dictionary = {}
for id: String in ids:
roles[match_state.role_of(id)] = id
_plan_hero(match_state, slot, profile)
# Heroes get one planned item every second round before the army is topped up.
var hero: Dictionary = player.draft.hero
if not hero.is_empty() and (hero.items as Array).size() < LuneContent.ITEM_SLOTS and match_state.round_number % 2 == 0:
for item_id: String in profile.items:
if not (hero.items as Array).has(item_id):
match_state.submit(slot, {"type": "item_buy", "item_id": item_id})
break
# Champion bots invest in a pushing wave every third round before topping up the army.
if match_state.round_number % 3 == 0:
_plan_champion(match_state, slot, profile)
for guard: int in range(40):
var draft: Dictionary = player.draft
var roster: Array = draft.roster
var acted: bool = false
if roster.size() >= int(profile.upgrade_from) or roster.size() >= match_state.balance.supply_cap:
acted = _try_upgrade(match_state, slot, roster)
if not acted and roster.size() < match_state.balance.supply_cap:
var role: String = _needed_role(match_state, roster, profile)
var lane: String = player.lane
if float(profile.support_lane) > 0.0 and match_state.rng.randf() < float(profile.support_lane):
lane = _weakest_lane(match_state, int(player.team))
acted = match_state.submit(slot, {"type": "buy", "type_id": roles[role], "lane": lane}).ok
if not acted and roster.size() < int(profile.upgrade_from):
acted = _try_upgrade(match_state, slot, roster)
if not acted:
break
_plan_items(match_state, slot, profile)
_plan_boss(match_state, slot, profile)
match_state.set_ready(slot)
static func _plan_hero(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var player: Dictionary = match_state.players[slot]
var hero: Dictionary = player.draft.hero
if hero.is_empty():
if match_state.round_number >= int(profile.hero_round):
match_state.submit(slot, {"type": "hero_buy", "hero_id": profile.hero})
return
var level: int = LuneContent.level_for(int(hero.xp))
var talents: Array = profile.talents
for tier: int in range(LuneContent.TALENT_LEVELS.size()):
if (hero.talents as Array).size() == tier and level >= LuneContent.TALENT_LEVELS[tier]:
match_state.submit(slot, {"type": "talent", "tier": tier, "choice": talents[tier]})
static func _plan_items(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var hero: Dictionary = match_state.players[slot].draft.hero
if hero.is_empty():
return
for item_id: String in profile.items:
# Keep a reserve for the army: items only from surplus gold.
if int(match_state.players[slot].draft.gold) >= int(LuneContent.ITEMS[item_id].cost) + 60:
match_state.submit(slot, {"type": "item_buy", "item_id": item_id})
static func _plan_champion(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var champion: String = str(profile.get("champion", ""))
if champion.is_empty() or match_state.round_number < 3:
return
var player: Dictionary = match_state.players[slot]
if int(player.draft.gold) >= int(LuneContent.CHAMPIONS[champion].cost) + 80:
match_state.submit(slot, {"type": "champion_book", "champion_id": champion, "lane": player.lane})
## Only boss-hunting bots go to the grotto, and only with a strong own army; the boss state
## and lock are public information.
static func _plan_boss(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var player: Dictionary = match_state.players[slot]
var claim: Dictionary = match_state.boss.claim
if not claim.is_empty() and int(claim.chooser_slot) == slot and int(claim.deploy_round) == match_state.round_number:
var lane: String = _weakest_lane(match_state, 1 - int(player.team))
match_state.submit(slot, {"type": "boss_lane", "lane": lane})
if not profile.has("boss_from") or match_state.round_number < int(profile.boss_from) or not match_state.grotto_available():
return
var roster: Array = player.draft.roster
if roster.size() < int(profile.boss_troops):
return
var picks: Array = roster.duplicate()
picks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a.seq) < int(b.seq) if int(a.invested) == int(b.invested) else int(a.invested) > int(b.invested))
for index: int in range(mini(int(profile.boss_troops) - 1, picks.size())):
match_state.submit(slot, {"type": "assign", "uid": picks[index].uid, "lane": LuneMatch.GROTTO})
var hero: Dictionary = player.draft.hero
if not hero.is_empty():
match_state.submit(slot, {"type": "assign", "uid": hero.uid, "lane": LuneMatch.GROTTO})
static func _needed_role(match_state: LuneMatch, roster: Array, profile: Dictionary) -> String:
var counts: Dictionary = {}
for entry: Dictionary in roster:
var role: String = match_state.role_of(entry.type_id)
counts[role] = int(counts.get(role, 0)) + 1
var best: String = "front"
var best_gap: float = -INF
for role: String in LuneMatch.ROW_ORDER:
var gap: float = float(profile[role]) * float(roster.size() + 1) - float(counts.get(role, 0))
if gap > best_gap + 0.0001:
best = role
best_gap = gap
return best
static func _try_upgrade(match_state: LuneMatch, slot: int, roster: Array) -> bool:
var best_uid: String = ""
var best_seq: int = 1 << 30
var best_cost: int = 1 << 30
for entry: Dictionary in roster:
var upgrade: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade.is_empty():
continue
var cost: int = int(DataCatalog.get_unit(upgrade).cost)
if cost < best_cost or (cost == best_cost and int(entry.seq) < best_seq):
best_cost = cost
best_seq = int(entry.seq)
best_uid = str(entry.uid)
if best_uid.is_empty():
return false
return match_state.submit(slot, {"type": "upgrade", "uid": best_uid}).ok
static func _weakest_lane(match_state: LuneMatch, team: int) -> String:
var weakest: String = "mid"
var fewest: int = 99
for lane: String in LuneMap.LANES:
var towers: int = match_state.towers_alive(team, lane)
if towers < fewest:
fewest = towers
weakest = lane
return weakest

View file

@ -0,0 +1 @@
uid://dy31vyfmvjk0s

View file

@ -0,0 +1,641 @@
class_name LuneCombat
extends RefCounted
## One automatic combat phase on all three lanes. Every step first gathers targets,
## movement, hits, heals and effects from the state at the start of the step, then applies
## them together, so processing order never decides who strikes first. Deaths of a step
## resolve together; two Ancients falling in the same step is a draw.
var map: LuneMap
var balance: LuneBalance
var round_number: int = 1
var units: Array[LuneUnit] = []
var buildings: Dictionary = {}
var elapsed: float = 0.0
var steps: int = 0
var finished: bool = false
var end_reason: String = ""
var destroyed_cores: Array[int] = []
var events: Array[Dictionary] = []
## Neutral grotto boss (team 2) and the resulting claim for this combat phase.
const NEUTRAL: int = 2
var grotto_boss: LuneUnit = null
var grotto_resolved: bool = true
var grotto_claim_team: int = -1
var grotto_active_team: int = -1
var grotto_outcome: String = "none"
var _next_id: int = 1
var _team_count: Array[int] = [0, 0, 0]
## Units without a target look for one every RETARGET steps, staggered by team_index.
const RETARGET: int = 4
var _event_serial: int = 0
var _pending_damage: Dictionary = {}
var _pending_heal: Dictionary = {}
var _pending_effects: Array = []
var _pending_dots: Array = []
var _pending_shields: Array = []
# Per-step caches: building gates, march limits and a spatial grid of mobile units.
var _attackable: Dictionary = {}
var _limits: Dictionary = {}
var _grid: Dictionary = {}
const CELL: float = 4.0
## Optional section timings (microseconds) for performance work; off by default.
var profiling: bool = false
var profile: Dictionary = {}
var _mark: int = 0
func _lap(section: String) -> void:
if profiling:
var now: int = Time.get_ticks_usec()
profile[section] = int(profile.get(section, 0)) + now - _mark
_mark = now
func _init(match_map: LuneMap, config: LuneBalance, number: int) -> void:
map = match_map
balance = config
round_number = number
# ---------------------------------------------------------------- setup
func add_building(definition: Dictionary, hp: float) -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = "core" if definition.tier == "core" else "tower"
unit.team = int(definition.team)
unit.lane = str(definition.lane)
unit.building_id = str(definition.id)
unit.team_index = _team_count[unit.team]
_team_count[unit.team] += 1
unit.def = {"id": definition.id, "display_name": definition.id, "max_hp": definition.max_hp, "damage": definition.damage,
"attack_range": definition.range, "attack_type": 1, "armor_type": 1, "speed": 0.0, "ability": "", "bounty": definition.bounty,
"tier": definition.tier}
unit.position = definition.position
unit.previous_position = unit.position
unit.max_hp = float(definition.max_hp)
unit.hp = clampf(hp, 0.0, unit.max_hp)
unit.alive = unit.hp > 0.0
units.append(unit)
buildings[unit.building_id] = unit
return unit
func spawn(kind: String, team: int, lane: String, definition: Dictionary, progress: float, lateral: float, slot: int = -1, uid: String = "") -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = kind
unit.team = team
unit.lane = lane
unit.def = definition
unit.owner_slot = slot
unit.persistent_uid = uid
unit.team_index = _team_count[team]
_team_count[team] += 1
unit.progress = progress
unit.lateral = lateral
unit.position = map.lane_point(team, lane, progress, lateral)
unit.previous_position = unit.position
var forward: Vector2 = map.lane_forward(team, lane, progress)
unit.facing = atan2(forward.x, forward.y)
unit.max_hp = float(definition.get("max_hp", 1.0))
unit.hp = unit.max_hp
_start_ability(unit)
units.append(unit)
return unit
func spawn_at(kind: String, team: int, zone: String, definition: Dictionary, at: Vector2, slot: int = -1, uid: String = "") -> LuneUnit:
var unit: LuneUnit = spawn(kind, team, "mid", definition, 0.0, 0.0, slot, uid)
unit.zone = zone
unit.lane = zone
unit.position = at
unit.previous_position = at
return unit
func add_grotto_boss(definition: Dictionary) -> LuneUnit:
grotto_boss = spawn_at("boss", NEUTRAL, "grotto", definition, map.grotto_boss_point())
grotto_boss.dormant = true
grotto_resolved = false
return grotto_boss
func alive_map() -> Dictionary:
var result: Dictionary = {}
for id: String in buildings:
result[id] = (buildings[id] as LuneUnit).alive
return result
func building_attackable(unit: LuneUnit, gates: Dictionary) -> bool:
if not unit.alive:
return false
if _attackable.has(unit.building_id):
return _attackable[unit.building_id]
return LuneMap.is_attackable(map.building_def(unit.building_id), gates)
func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void:
_attackable.clear()
for id: String in buildings:
_attackable[id] = LuneMap.is_attackable(map.building_def(id), gates)
_limits.clear()
for team: int in range(2):
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var limit: float = path.length
for id: String in buildings:
var building: LuneUnit = buildings[id]
if building.alive and building.kind == "tower" and building.team != team and building.lane == lane:
limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5)
_limits[team * 3 + LuneMap.LANES.find(lane)] = limit
_grid = {0: {}, 1: {}, 2: {}}
for unit: LuneUnit in living:
if unit.is_mobile():
var cells: Dictionary = _grid[unit.team]
var key := Vector2i(floori(unit.position.x / CELL), floori(unit.position.y / CELL))
if not cells.has(key):
cells[key] = []
(cells[key] as Array).append(unit)
static func hostile_teams(team: int) -> Array:
return [0, 1] if team == NEUTRAL else [1 - team, NEUTRAL]
## Mobile units of the given teams near a point; the caller checks distance.
func _near_mobile(center: Vector2, radius: float, teams: Array) -> Array:
var result: Array = []
var reach: int = ceili(radius / CELL)
var cx: int = floori(center.x / CELL)
var cy: int = floori(center.y / CELL)
for side: int in teams:
var cells: Dictionary = _grid[side]
if cells.is_empty():
continue
for dx: int in range(-reach, reach + 1):
for dy: int in range(-reach, reach + 1):
var bucket: Variant = cells.get(Vector2i(cx + dx, cy + dy))
if bucket != null:
result.append_array(bucket)
return result
# ---------------------------------------------------------------- step
func step() -> void:
if finished:
return
var dt: float = balance.sim_step
elapsed += dt
steps += 1
_pending_damage.clear()
_pending_heal.clear()
_pending_effects.clear()
_pending_dots.clear()
_pending_shields.clear()
_mark = Time.get_ticks_usec()
var gates: Dictionary = alive_map()
var living: Array[LuneUnit] = []
for unit: LuneUnit in units:
if unit.alive:
living.append(unit)
unit.previous_position = unit.position
_prepare_caches(living, gates)
_lap("caches")
for unit: LuneUnit in living:
var dot: float = unit.advance_status(dt)
if dot > 0.0:
_queue_damage(unit, dot, gates)
_lap("status")
for unit: LuneUnit in living:
if unit.is_mobile():
_ability_step(unit, living)
_lap("abilities")
for unit: LuneUnit in living:
if unit.is_mobile() or (unit.kind == "tower"):
var keep: bool = unit.target != null and unit.target.alive
if keep or (steps + unit.team_index) % RETARGET == 0:
unit.target = _choose_target(unit, living, gates)
_lap("targets")
var moves: Dictionary = {}
for unit: LuneUnit in living:
if unit.is_mobile():
moves[unit.id] = _plan_move(unit, living, dt)
_lap("plan_move")
for unit: LuneUnit in living:
if moves.has(unit.id):
var plan: Array = moves[unit.id]
var next: Vector2 = plan[0]
var heading: Vector2 = next - unit.position
unit.position = next
if heading.length_squared() > 0.000001:
unit.facing = atan2(heading.x, heading.y)
if unit.zone == "lane":
_update_progress(unit, float(plan[1]))
_lap("apply_move")
for unit: LuneUnit in living:
_attack(unit, living, gates, dt)
_lap("attacks")
for unit: LuneUnit in living:
if _pending_heal.has(unit.id):
unit.heal(_pending_heal[unit.id])
for entry: Array in _pending_shields:
(entry[0] as LuneUnit).grant_shield(entry[1], entry[2])
for entry: Array in _pending_effects:
(entry[0] as LuneUnit).apply_effect(entry[1], entry[2], entry[3], entry[4])
for entry: Array in _pending_dots:
(entry[0] as LuneUnit).apply_dot(entry[1], entry[2], entry[3], entry[4])
for unit: LuneUnit in living:
if _pending_damage.has(unit.id):
unit.take(_pending_damage[unit.id])
for unit: LuneUnit in living:
if unit.hp <= 0.0 and unit.alive:
unit.alive = false
unit.death_time = elapsed
unit.target = null
_record_death(unit)
_update_grotto()
_lap("resolve")
_check_end()
## Expeditions fight each other first; the boss wakes when at most one team remains.
func _update_grotto() -> void:
if grotto_resolved or grotto_boss == null:
return
var teams_alive: Array[int] = []
for unit: LuneUnit in units:
if unit.alive and unit.zone == "grotto" and unit.team != NEUTRAL and not teams_alive.has(unit.team):
teams_alive.append(unit.team)
if grotto_boss.dormant and teams_alive.size() <= 1:
grotto_boss.dormant = false
grotto_active_team = teams_alive[0] if teams_alive.size() == 1 else -1
if not grotto_boss.alive:
grotto_resolved = true
grotto_claim_team = grotto_active_team
grotto_outcome = "success" if grotto_claim_team >= 0 else "failed"
elif teams_alive.is_empty():
grotto_resolved = true
grotto_outcome = "failed"
func _check_end() -> void:
if not destroyed_cores.is_empty():
finished = true
end_reason = "core"
return
var mobile_alive: bool = not grotto_resolved
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile() and unit.zone == "lane":
mobile_alive = true
break
if not mobile_alive:
finished = true
end_reason = "cleared"
elif elapsed >= balance.combat_time_limit - 0.000001:
finished = true
end_reason = "timeout"
func _record_death(unit: LuneUnit) -> void:
_event_serial += 1
var event: Dictionary = {"serial": _event_serial, "step": steps, "time": elapsed, "team": unit.team, "lane": unit.lane,
"zone": unit.zone, "kind": unit.kind, "unit_id": unit.id, "def_id": str(unit.def.get("id", "")), "bounty": 0,
"owner_slot": unit.owner_slot, "persistent_uid": unit.persistent_uid}
if unit.kind in ["monster", "champion", "boss"]:
event.bounty = int(unit.def.get("bounty", 0))
elif unit.is_building():
event.bounty = int(unit.def.get("bounty", 0))
event.building_id = unit.building_id
if unit.kind == "core" and not destroyed_cores.has(unit.team):
destroyed_cores.append(unit.team)
# Participation XP: every opposing hero near the death shares it, whoever landed the hit.
var xp: int = 0
match unit.kind:
"monster": xp = 12 + round_number
"champion": xp = 30 + 2 * round_number
"boss": xp = LuneContent.BOSS_XP
"troop", "hero": xp = 15
"tower", "core": xp = LuneContent.BUILDING_XP
var heroes: Array[String] = []
var radius: float = LuneContent.XP_RADIUS + (2.0 if unit.is_building() else 0.0)
for other: LuneUnit in units:
if other.kind == "hero" and other.alive and other.zone == unit.zone and other.team != unit.team and other.position.distance_to(unit.position) <= radius:
heroes.append(other.persistent_uid)
event.xp = xp
event.xp_heroes = heroes
events.append(event)
# ---------------------------------------------------------------- targeting and movement
func _hostile(unit: LuneUnit, other: LuneUnit) -> bool:
return other.alive and other.team != unit.team and other.zone == unit.zone and not other.dormant and not unit.dormant
func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> LuneUnit:
var current: LuneUnit = unit.target
if current != null and _hostile(unit, current) and unit.position.distance_to(current.position) <= balance.leash_radius:
if not current.is_building() or building_attackable(current, gates):
if unit.kind != "tower" or unit.position.distance_to(current.position) <= unit.attack_range():
return current
var radius: float = unit.attack_range() if unit.kind == "tower" else balance.aggro_radius
var best: LuneUnit = null
var best_distance: float = INF
var priority: String = str(unit.def.get("priority", ""))
if priority == "building":
# Siege champions go for the nearest attackable building before anything else.
for id: String in buildings:
var building: LuneUnit = buildings[id]
if _hostile(unit, building) and building_attackable(building, gates):
var reach_d: float = unit.position.distance_to(building.position) - balance.building_radius
if reach_d <= balance.aggro_radius and reach_d < best_distance:
best = building
best_distance = reach_d
if best != null:
return best
for other: LuneUnit in _near_mobile(unit.position, radius, hostile_teams(unit.team)):
if not _hostile(unit, other):
continue
var d: float = unit.position.distance_to(other.position)
# Pressure champions treat support and ranged enemies as 6 m closer.
if priority == "support" and other.role() in ["support", "ranged"]:
d -= 6.0
if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)):
best = other
best_distance = d
if best != null or unit.kind == "tower":
return best
# Buildings: nearby attackable ones; at the end of a lane the whole base is in reach.
for id: String in buildings:
var other: LuneUnit = buildings[id]
if not _hostile(unit, other) or not building_attackable(other, gates):
continue
var d: float = unit.position.distance_to(other.position) - balance.building_radius
if (d <= radius or unit.reached_base) and d < best_distance:
best = other
best_distance = d
return best
func _march_limit(unit: LuneUnit) -> float:
var key: int = unit.team * 3 + LuneMap.LANES.find(unit.lane)
if _limits.has(key):
return _limits[key]
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = path.length
for id: String in buildings:
var building: LuneUnit = buildings[id]
if building.alive and building.kind == "tower" and building.team != unit.team and building.lane == unit.lane:
limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5)
return limit
## Returns [next position, marching progress]. Marching advances the lane parameter
## directly, so lateral formation offsets cannot stall a unit on the inside of a bend.
func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
var step_length: float = unit.speed() * balance.march_speed_factor * unit.movement_factor() * dt
var goal: Vector2 = unit.position
var travel: float = 0.0
var marched: float = unit.progress
if unit.target != null:
var reach: float = unit.attack_range() + (balance.building_radius if unit.target.is_building() else 0.0)
var gap: float = unit.position.distance_to(unit.target.position) - reach
if gap > 0.0:
goal = unit.target.position
travel = minf(step_length, gap + 0.02)
elif unit.zone == "grotto":
if unit.kind != "boss" and grotto_boss != null and grotto_boss.alive:
var gap_boss: float = unit.position.distance_to(grotto_boss.position) - unit.attack_range()
if gap_boss > 0.0:
goal = grotto_boss.position
travel = minf(step_length, gap_boss + 0.02)
elif unit.zone == "lane":
var limit: float = _march_limit(unit)
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
var off: float = unit.position.distance_to(anchor)
if off > 1.2:
# Rejoin the formation line after a chase before advancing again.
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
elif unit.progress < limit - 0.01:
marched = minf(unit.progress + step_length, limit)
goal = map.lane_point(unit.team, unit.lane, marched, unit.lateral)
travel = unit.position.distance_to(goal)
elif off > 0.05:
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
var next: Vector2 = unit.position
if travel > 0.0 and goal.distance_squared_to(unit.position) > 0.000001:
next += (goal - unit.position).normalized() * minf(travel, unit.position.distance_to(goal))
# Separation from allies so bodies do not stack; opponents may still close to melee range.
var push := Vector2.ZERO
for other: LuneUnit in _near_mobile(unit.position, 1.1, [unit.team]):
if other == unit or other.zone != unit.zone:
continue
var offset: Vector2 = unit.position - other.position
var distance: float = offset.length()
if distance < 1.1:
if distance < 0.0001:
# Mirrored teams sidestep in mirrored directions.
offset = Vector2.RIGHT if unit.id < other.id else Vector2.LEFT
if unit.team == 1:
offset = LuneMap.mirror(offset)
distance = 0.001
push += offset.normalized() * (1.1 - distance) * 2.0
next += push.limit_length(0.9) * dt
next.x = clampf(next.x, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
next.y = clampf(next.y, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
return [next, marched]
func _update_progress(unit: LuneUnit, marched: float) -> void:
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = _march_limit(unit)
if unit.target != null:
# After a chase the unit continues from where it now stands, never further back.
var blue: Vector2 = unit.position if unit.team == 0 else LuneMap.mirror(unit.position)
marched = maxf(marched, minf(path.project(blue), limit))
unit.progress = maxf(unit.progress, minf(marched, maxf(limit, unit.progress)))
if unit.progress >= path.length - 0.6:
unit.reached_base = true
# ---------------------------------------------------------------- attacks
func _attack(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary, dt: float) -> void:
unit.in_range = false
var target: LuneUnit = unit.target
if target == null or not target.alive or unit.kind == "core":
unit.attack_progress = 0.0
return
var reach: float = unit.attack_range() + (balance.building_radius if target.is_building() else 0.0) + 0.035
if unit.position.distance_to(target.position) > reach:
unit.attack_progress = 0.0
return
if target.is_building() and not building_attackable(target, gates):
unit.attack_progress = 0.0
return
unit.in_range = true
var interval: float = balance.tower_attack_interval if unit.kind == "tower" else 1.0
unit.attack_progress += dt * unit.attack_speed_factor() / interval
while unit.attack_progress >= 1.0 - 0.000001:
unit.attack_progress = maxf(0.0, unit.attack_progress - 1.0)
unit.hit_count += 1
unit.attack_serial += 1
var damage: float = float(unit.def.get("damage", 0.0)) * (1.0 + unit.damage_bonus())
if unit.hit_count % 4 == 0:
if unit.ability() == "g05":
damage *= 2.1 if unit.has_talent("durchschlag") else 1.5
elif unit.ability() == "g06":
damage *= 1.75
if unit.hit_count % 3 == 0 and unit.has_talent("stammschlag"):
damage *= 1.6
if target.is_building():
damage *= float(unit.def.get("siege_factor", 1.0))
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
_on_hit(unit, target, living, gates)
# Item effects come last and never trigger further on-hit effects.
var item_splash: float = float(unit.def.get("item_splash", 0.0))
if item_splash > 0.0:
_splash(unit, target, living, gates, item_splash, 1.5, 2)
func _queue_damage(target: LuneUnit, amount: float, gates: Dictionary) -> void:
if amount <= 0.0 or not target.alive:
return
# The building gate applies to every source: ranged, splash, DoT and abilities.
if target.is_building() and not building_attackable(target, gates):
return
_pending_damage[target.id] = float(_pending_damage.get(target.id, 0.0)) + amount
func _queue_heal(target: LuneUnit, amount: float) -> void:
_pending_heal[target.id] = float(_pending_heal.get(target.id, 0.0)) + amount
# ---------------------------------------------------------------- abilities (ported from the Saumwacht rules)
func _start_ability(unit: LuneUnit) -> void:
unit.ability_ready = 6.0
match unit.ability():
"w03", "w04": unit.ability_ready = 5.0
"w09", "w10": unit.ability_ready = 4.0
"s02", "ch_schutz": unit.grant_shield(unit.max_hp * 0.15)
"e08": unit.grant_shield(unit.max_hp * 0.20)
func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vector2, radius: float, maximum: int, excluded: LuneUnit = null, health_priority: bool = false) -> Array[LuneUnit]:
var result: Array[LuneUnit] = []
# Buildings can be splash victims; they are few, so check them directly.
var pool: Array = _near_mobile(center, radius, [unit.team] if allied else hostile_teams(unit.team))
if not allied:
for id: String in buildings:
pool.append(buildings[id])
for other: LuneUnit in pool:
if not other.alive or other == excluded or other.zone != unit.zone:
continue
if (other.team == unit.team) != allied or other.dormant:
continue
if allied and other.is_building():
continue
if other.position.distance_to(center) <= radius + 0.000001:
result.append(other)
result.sort_custom(func(a: LuneUnit, b: LuneUnit) -> bool:
if health_priority and not is_equal_approx(a.hp / a.max_hp, b.hp / b.max_hp):
return a.hp / a.max_hp < b.hp / b.max_hp
var ad: float = a.position.distance_to(center)
var bd: float = b.position.distance_to(center)
return a.id < b.id if is_equal_approx(ad, bd) else ad < bd)
if result.size() > maximum:
result.resize(maximum)
return result
func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
var regen: float = float(unit.def.get("regen", 0.0))
if regen > 0.0 and unit.hp < unit.max_hp:
_queue_heal(unit, unit.max_hp * regen * balance.sim_step)
if unit.has_talent("schutzwurzeln") and unit.combat_time + 0.000001 >= unit.aura_ready:
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 4):
_pending_effects.append([ally, "bark_guard", "reduction", 0.10, 4.5])
unit.aura_ready = unit.combat_time + 4.0
if unit.combat_time + 0.000001 < unit.ability_ready:
return
var ability: String = unit.ability()
var success: bool = false
match ability:
"w03", "w04":
if unit.hp < unit.max_hp:
_queue_heal(unit, 100.0 if ability == "w04" else 35.0)
success = true
"w09", "w10":
var upgraded: bool = ability == "w10"
var radius: float = 4.5 if upgraded else 4.0
var count: int = 3 if upgraded else 1
var amount: float = 80.0 if upgraded else 65.0
if unit.has_talent("tiefe_quelle"):
count = 1
amount = 190.0
elif unit.has_talent("weite_quelle"):
radius = 5.5
count = 5
amount = 65.0
for ally: LuneUnit in _nearby(unit, living, true, unit.position, radius, count, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, amount)
if unit.has_talent("quellschild"):
_pending_shields.append([ally, 70.0, 4.0])
success = true
"ch_schutz":
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0, 4)
for ally: LuneUnit in allies:
_pending_shields.append([ally, 120.0, 6.0])
success = not allies.is_empty()
"g09", "g10":
var upgraded: bool = ability == "g10"
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0 if upgraded else 3.0, 4 if upgraded else 3, null, true)
for ally: LuneUnit in allies:
_pending_effects.append([ally, "hearth_warmth", "damage", 0.15 if upgraded else 0.10, 4.0])
if upgraded:
_queue_heal(ally, 45.0)
success = not allies.is_empty()
"e06":
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 100, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 30.0 * float(unit.def.get("hp_factor", 1.0)))
success = true
break
"s03", "e09":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 6.0 if ability == "e09" else 3.0, 3 if ability == "e09" else 4)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "dissonance", "attack_slow", 0.15, 3.0])
success = not victims.is_empty()
"s05":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 5.0, 1)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "rift_wound", "healing_reduction", 0.30, 4.0])
success = not victims.is_empty()
if success:
unit.ability_ready = unit.combat_time + (5.0 if ability in ["w03", "w04"] else (4.0 if ability in ["w09", "w10"] else 6.0))
unit.ability_serial += 1
func _on_hit(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> void:
var ability: String = unit.ability()
var hits: int = unit.hit_count
if unit.has_talent("wurzelbeben") and hits % 3 == 0:
_splash(unit, primary, living, gates, 0.50, 2.0, 3)
if unit.has_talent("splitterschuss") and hits % 4 == 0:
_splash(unit, primary, living, gates, 0.45, 2.0, 3)
if unit.has_talent("keimsaat") and hits % 2 == 0:
_pending_dots.append([primary, "seed_burn", 9.0, 4.0, int(unit.def.attack_type)])
match ability:
"w01", "w02":
if hits % 3 == 0:
_pending_effects.append([primary, "root_grasp", "movement_slow", 0.30 if ability == "w02" else 0.20, 3.0 if ability == "w02" else 2.0])
if ability == "w02":
_pending_effects.append([primary, "root_grasp_attack", "attack_slow", 0.10, 3.0])
"w05", "w06":
if hits % 4 == 0:
_pending_dots.append([primary, "seed_burn", 10.0 if ability == "w06" else 3.0, 4.0, int(unit.def.attack_type)])
"w07", "w08":
if hits % 3 == 0:
_splash(unit, primary, living, gates, 0.80 if ability == "w08" else 0.60, 2.5 if ability == "w08" else 2.0, 4 if ability == "w08" else 3)
"g01", "g02":
if hits % 3 == 0:
_pending_dots.append([primary, "burn", 12.0 if ability == "g02" else 4.0, 3.0, int(unit.def.attack_type)])
"g07": _splash(unit, primary, living, gates, 0.30, 1.5, 3)
"g08": _splash(unit, primary, living, gates, 0.40, 2.0, 4)
"e05":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.40, 1.5, 2)
"e07":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.0, 3)
"e10":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.5, 4)
"s04":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.50, 2.0, 3)
func _splash(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary, factor: float, radius: float, maximum: int) -> void:
for victim: LuneUnit in _nearby(unit, living, false, primary.position, radius, maximum, primary):
_queue_damage(victim, victim.mitigated(float(unit.def.damage) * factor, int(unit.def.attack_type)), gates)
# ---------------------------------------------------------------- queries for views and tests
func mobile_count(team: int = -1) -> int:
var count: int = 0
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile() and (team < 0 or unit.team == team):
count += 1
return count

View file

@ -0,0 +1 @@
uid://rj0ekj8y2rsa

View file

@ -0,0 +1,159 @@
class_name LuneContent
extends RefCounted
## M2 content: heroes, talents, items, attack champions and the grotto boss.
## All figures reuse existing models; hero and champion roles are provisional assignments
## of those models ("vorläufig"), not newly created peoples.
# ---------------------------------------------------------------- heroes
const HEROES: Dictionary = {
"held_ringborke": {"name": "Ringborke", "title": "Wächterin der Wurzeln", "model": "w04", "cost": 260, "role": "Front / Schutz",
"provisional": true, "def": {"max_hp": 2200.0, "damage": 34.0, "attack_type": 1, "armor_type": 1, "attack_range": 1.4, "speed": 2.4, "ability": "w04"},
"summary": "Hält die Front, heilt sich selbst und schützt nahe Verbündete."},
"held_quellgeflecht": {"name": "Quellgeflecht", "title": "Hüterin der Quelle", "model": "w10", "cost": 260, "role": "Unterstützung / Heilung",
"provisional": true, "def": {"max_hp": 1300.0, "damage": 28.0, "attack_type": 2, "armor_type": 2, "attack_range": 4.5, "speed": 2.0, "ability": "w10"},
"summary": "Heilt Verbündete in 4,5 m und stärkt die Armee im Gefecht."},
"held_funkenlanze": {"name": "Funkenlanze", "title": "Schützin des Glutpakts", "model": "g05", "cost": 260, "role": "Fernkampf / Schaden",
"provisional": true, "def": {"max_hp": 950.0, "damage": 46.0, "attack_type": 1, "armor_type": 0, "attack_range": 6.0, "speed": 2.0, "ability": "g05"},
"summary": "Hoher Einzelschaden aus der zweiten Reihe."},
}
## Cumulative XP needed for levels 1..10.
const LEVEL_XP: Array[int] = [0, 120, 280, 480, 720, 1000, 1320, 1680, 2080, 2520]
const HP_PER_LEVEL: float = 0.09
const DAMAGE_PER_LEVEL: float = 0.07
const XP_PARTICIPATION: int = 40
const XP_PER_ROUND: int = 6
const XP_RADIUS: float = 12.0
const TALENT_LEVELS: Array[int] = [3, 6]
## Two tiers of two mutually exclusive choices per hero; each changes the hero's job.
const TALENTS: Dictionary = {
"held_ringborke": [
[{"id": "borkenhaut", "name": "Borkenhaut", "text": "Eigenschutz: +15 % Schadensminderung für die Heldin."},
{"id": "schutzwurzeln", "name": "Schutzwurzeln", "text": "Gruppenschutz: alle 4 s erhalten bis zu 4 Verbündete in 4 m 10 % Schadensminderung für 4,5 s."}],
[{"id": "stammschlag", "name": "Stammschlag", "text": "Einzelziel: jeder 3. Treffer +60 % Schaden."},
{"id": "wurzelbeben", "name": "Wurzelbeben", "text": "Fläche: jeder 3. Treffer 50 % Schaden an bis zu 3 Gegner in 2 m."}]],
"held_quellgeflecht": [
[{"id": "tiefe_quelle", "name": "Tiefe Quelle", "text": "Einzelziel: heilt den schwächsten Verbündeten um 190 statt drei um je 80."},
{"id": "weite_quelle", "name": "Weite Quelle", "text": "Fläche: heilt bis zu 5 Verbündete in 5,5 m um je 65."}],
[{"id": "quellschild", "name": "Quellschild", "text": "Schutz: Geheilte erhalten 4 s lang einen Schild von 70."},
{"id": "keimsaat", "name": "Keimsaat", "text": "Druck: jeder 2. Treffer setzt Keimbrand, 9 Schaden/s für 4 s."}]],
"held_funkenlanze": [
[{"id": "durchschlag", "name": "Durchschlag", "text": "Einzelziel: jeder 4. Treffer ×2,1 statt ×1,5."},
{"id": "splitterschuss", "name": "Splitterschuss", "text": "Fläche: jeder 4. Treffer zusätzlich 45 % an bis zu 3 Gegner in 2 m."}],
[{"id": "fernsicht", "name": "Fernsicht", "text": "+1,5 m Reichweite."},
{"id": "glutsalve", "name": "Glutsalve", "text": "+20 % Angriffstempo."}]],
}
# ---------------------------------------------------------------- items
## Permanent, unique per hero (no two of the same item), three slots. Bonuses of different
## items add up within their category; caps: damage reduction 35 %, attack tempo +50 %.
## Order in combat: primary hit -> base ability -> talent -> item effect. Item effects never
## trigger other on-hit effects, heals never trigger anything, so no loops are possible.
const ITEMS: Dictionary = {
"basaltschild": {"name": "Basaltschild", "cost": 180, "text": "+350 Lebenspunkte", "hp_flat": 350.0},
"glutklinge": {"name": "Glutklinge", "cost": 180, "text": "+18 % Schaden", "damage": 0.18},
"rankenmantel": {"name": "Rankenmantel", "cost": 200, "text": "+10 % Schadensminderung", "reduction": 0.10},
"funkenfokus": {"name": "Funkenfokus", "cost": 200, "text": "+15 % Angriffstempo", "attack_speed": 0.15},
"quellamulett": {"name": "Quellamulett", "cost": 220, "text": "Regeneriert 1,5 % der Lebenspunkte pro Sekunde", "regen": 0.015},
"kristallsplitter": {"name": "Kristallsplitter", "cost": 260, "text": "Jeder Treffer: 25 % an bis zu 2 Gegner in 1,5 m · ab Stufe 3", "splash": 0.25, "min_level": 3},
}
const ITEM_SLOTS: int = 3
const ITEM_REFUND: float = 0.5
# ---------------------------------------------------------------- attack champions
## Bought in round n for one lane, fight once in round n+1, then vanish. Killing one pays gold.
const CHAMPIONS: Dictionary = {
"ch_schutz": {"name": "Rostschieber", "role": "Schutz", "model": "s02", "cost": 150, "base": "s02",
"text": "Schildträger: startet mit Schild, gibt alle 6 s bis zu 4 Verbündeten in 4 m einen Schild von 120.", "view_scale": 1.25},
"ch_druck": {"name": "Dunstsänger", "role": "Druck auf Unterstützer", "model": "s03", "cost": 150, "base": "s03",
"text": "Sucht Unterstützer und Fernkämpfer und verlangsamt deren Angriffe.", "priority": "support", "view_scale": 1.25},
"ch_belagerung": {"name": "Keilbrecher", "role": "Belagerung", "model": "s04", "cost": 180, "base": "s04",
"text": "Greift bevorzugt Gebäude an und verursacht dort ×2,5 Schaden.", "priority": "building", "siege_factor": 2.5, "view_scale": 1.3},
}
const CHAMPION_HP: float = 2.2
const CHAMPION_DAMAGE: float = 1.6
const CHAMPION_BOUNTY: int = 70
const CHAMPION_BOUNTY_PER_ROUND: int = 6
const MAX_BOOKINGS: int = 2
# ---------------------------------------------------------------- boss
const BOSS_MODEL: String = "e10"
const BOSS_GROTTO_HP: float = 4200.0
const BOSS_GROTTO_DAMAGE: float = 55.0
const BOSS_DEPLOY_HP: float = 5200.0
const BOSS_DEPLOY_DAMAGE: float = 70.0
const BOSS_BOUNTY: int = 520
const BOSS_BOUNTY_PER_ROUND: int = 60
## Victory in round n: deployment in n+1, locked in n+1..n+3, available again from n+4.
const BOSS_LOCK_ROUNDS: int = 3
const BOSS_XP: int = 220
const BUILDING_XP: int = 60
static func level_for(xp: int) -> int:
var level: int = 1
for index: int in range(LEVEL_XP.size()):
if xp >= LEVEL_XP[index]:
level = index + 1
return level
static func hero_definition(hero: Dictionary) -> Dictionary:
var spec: Dictionary = HEROES[hero.hero_id]
var definition: Dictionary = (spec.def as Dictionary).duplicate(true)
var level: int = level_for(int(hero.xp))
definition.id = spec.model
definition.hero_id = hero.hero_id
definition.display_name = spec.name
definition.max_hp = float(definition.max_hp) * (1.0 + HP_PER_LEVEL * float(level - 1))
definition.damage = float(definition.damage) * (1.0 + DAMAGE_PER_LEVEL * float(level - 1))
definition.level = level
definition.view_scale = 1.25
var damage_bonus: float = 0.0
var reduction: float = 0.0
var attack_speed: float = 0.0
for item_id: String in hero.items:
var item: Dictionary = ITEMS[item_id]
definition.max_hp = float(definition.max_hp) + float(item.get("hp_flat", 0.0))
damage_bonus += float(item.get("damage", 0.0))
reduction += float(item.get("reduction", 0.0))
attack_speed += float(item.get("attack_speed", 0.0))
definition.regen = float(definition.get("regen", 0.0)) + float(item.get("regen", 0.0))
definition.item_splash = float(definition.get("item_splash", 0.0)) + float(item.get("splash", 0.0))
var talents: Array = hero.get("talents", [])
if talents.has("borkenhaut"):
reduction += 0.15
if talents.has("fernsicht"):
definition.attack_range = float(definition.attack_range) + 1.5
if talents.has("glutsalve"):
attack_speed += 0.20
definition.talents = talents.duplicate()
definition.damage_bonus = damage_bonus
definition.reduction_bonus = reduction
definition.attack_speed_bonus = minf(0.5, attack_speed)
return definition
static func champion_definition(champion_id: String, round_number: int, balance: LuneBalance) -> Dictionary:
var spec: Dictionary = CHAMPIONS[champion_id]
var base: Dictionary = DataCatalog.SENDS[spec.base].duplicate(true)
base.id = spec.model
base.champion_id = champion_id
base.display_name = spec.name
base.max_hp = float(base.max_hp) * CHAMPION_HP * LuneWaves.strength(balance, round_number, balance.wave_hp_growth)
base.damage = float(base.damage) * CHAMPION_DAMAGE * LuneWaves.strength(balance, round_number, balance.wave_damage_growth)
base.bounty = CHAMPION_BOUNTY + CHAMPION_BOUNTY_PER_ROUND * round_number
base.priority = str(spec.get("priority", ""))
base.siege_factor = float(spec.get("siege_factor", 1.0))
base.view_scale = float(spec.view_scale)
if champion_id == "ch_schutz":
base.ability = "ch_schutz"
return base
static func boss_definition(round_number: int, balance: LuneBalance, deployed: bool) -> Dictionary:
var base: Dictionary = DataCatalog.ENEMIES[BOSS_MODEL].duplicate(true)
base.display_name = "Schlundkrone"
base.max_hp = (BOSS_DEPLOY_HP if deployed else BOSS_GROTTO_HP) * LuneWaves.strength(balance, round_number, balance.wave_hp_growth)
base.damage = (BOSS_DEPLOY_DAMAGE if deployed else BOSS_GROTTO_DAMAGE) * LuneWaves.strength(balance, round_number, balance.wave_damage_growth)
base.bounty = BOSS_BOUNTY + BOSS_BOUNTY_PER_ROUND * round_number if deployed else 0
base.is_boss = true
base.view_scale = 1.7
base.speed = 1.6
return base

View file

@ -0,0 +1 @@
uid://blkpu61mfghb2

View file

@ -0,0 +1,176 @@
class_name LuneMap
extends RefCounted
## Map data in ground coordinates Vector2(x, z): Blau (team 0) in the south-west,
## Rot (team 1) in the north-east. Rot is the mirror image of Blau across x = z
## (mirror(v) = Vector2(v.y, v.x)), Bottom is Top mirrored across the Mid line.
## Both hold by construction, so strecken, towers and spawns are functionally equal.
const LANES: Array[String] = ["top", "mid", "bot"]
const LANE_NAMES: Dictionary = {"top": "Top", "mid": "Mid", "bot": "Bottom"}
# First halves from the Blau end to the centre line; the Rot half is mirrored.
const TOP_HALF: Array[Vector2] = [Vector2(-24, 22), Vector2(-26, 6), Vector2(-26.5, -12), Vector2(-25, -25)]
const MID_HALF: Array[Vector2] = [Vector2(-21, 21), Vector2(-11, 10), Vector2(0, 0)]
const CORE_BLUE := Vector2(-30, 30)
const BASE_TOWERS_BLUE: Array[Vector2] = [Vector2(-28.8, 24.6), Vector2(-24.6, 28.8)]
## Neutral boss grotto, north-west of Mid and on the symmetry axis (M2).
const GROTTO := Vector2(-11.5, -11.5)
const HALF_EXTENT: float = 36.0
var balance: LuneBalance
var lanes: Dictionary = {}
var buildings: Array[Dictionary] = []
class LanePath:
extends RefCounted
var points: PackedVector2Array
var cumulative: PackedFloat32Array
var length: float = 0.0
func _init(source: Array[Vector2]) -> void:
points = PackedVector2Array(source)
cumulative.append(0.0)
for i: int in range(1, points.size()):
length += points[i].distance_to(points[i - 1])
cumulative.append(length)
func point_at(s: float) -> Vector2:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return points[i - 1].lerp(points[i], clampf((d - cumulative[i - 1]) / span, 0.0, 1.0))
return points[points.size() - 1]
func tangent_at(s: float) -> Vector2:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
return (points[i] - points[i - 1]).normalized()
return Vector2.RIGHT
## Point and unit tangent at s in one pass.
func sample(s: float) -> Array:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return [a.lerp(b, clampf((d - cumulative[i - 1]) / span, 0.0, 1.0)), (b - a) / span]
return [points[points.size() - 1], Vector2.RIGHT]
func project(position: Vector2) -> float:
var best: float = INF
var best_s: float = 0.0
for i: int in range(1, points.size()):
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var ab: Vector2 = b - a
var t: float = clampf((position - a).dot(ab) / maxf(0.0001, ab.length_squared()), 0.0, 1.0)
var d: float = position.distance_squared_to(a + ab * t)
if d < best:
best = d
best_s = cumulative[i - 1] + ab.length() * t
return best_s
static func mirror(v: Vector2) -> Vector2:
return Vector2(v.y, v.x)
static func mirror_mid(v: Vector2) -> Vector2:
return Vector2(-v.y, -v.x)
static func full_lane(half: Array[Vector2]) -> Array[Vector2]:
var result: Array[Vector2] = half.duplicate()
for i: int in range(half.size() - 2, -1, -1):
result.append(mirror(half[i]))
return result
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
var bot_half: Array[Vector2] = []
for point: Vector2 in TOP_HALF:
bot_half.append(mirror_mid(point))
lanes["top"] = LanePath.new(full_lane(TOP_HALF))
lanes["mid"] = LanePath.new(full_lane(MID_HALF))
lanes["bot"] = LanePath.new(full_lane(bot_half))
_build_buildings()
## Lane coordinate s for a team's own marching progress p (0 at its own base).
func s_for(team: int, lane: String, progress: float) -> float:
var path: LanePath = lanes[lane]
return progress if team == 0 else path.length - progress
func progress_for(team: int, lane: String, s: float) -> float:
var path: LanePath = lanes[lane]
return s if team == 0 else path.length - s
## Blau frame point, mirrored for Rot so both sides are exact images.
func lane_point(team: int, lane: String, progress: float, lateral: float = 0.0) -> Vector2:
var path: LanePath = lanes[lane]
var sampled: Array = path.sample(progress)
var tangent: Vector2 = sampled[1]
var blue: Vector2 = sampled[0] + Vector2(-tangent.y, tangent.x) * lateral
return blue if team == 0 else Vector2(blue.y, blue.x)
func lane_forward(team: int, lane: String, progress: float) -> Vector2:
var path: LanePath = lanes[lane]
var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length))
return tangent if team == 0 else mirror(tangent)
## Grotto deployment points: one mirrored entrance per team, boss slightly inside.
func grotto_entry(team: int) -> Vector2:
var blue: Vector2 = GROTTO + Vector2(1.2, 4.6)
return blue if team == 0 else mirror(blue)
func grotto_boss_point() -> Vector2:
return GROTTO + Vector2(-1.5, -1.5)
func core_position(team: int) -> Vector2:
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)
func _build_buildings() -> void:
buildings.clear()
for team: int in range(2):
for lane: String in LANES:
var path: LanePath = lanes[lane]
for tier: String in ["inner", "outer"]:
var fraction: float = balance.inner_tower_fraction if tier == "inner" else balance.outer_tower_fraction
var progress: float = path.length * fraction
buildings.append({"id": "%s_%s_%s" % ["blau" if team == 0 else "rot", lane, tier], "team": team, "lane": lane,
"tier": tier, "progress": progress, "position": lane_point(team, lane, progress, balance.tower_side_offset),
"max_hp": balance.tower_inner_hp if tier == "inner" else balance.tower_outer_hp,
"damage": balance.tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_inner_tower if tier == "inner" else balance.bounty_outer_tower})
for index: int in range(2):
var point: Vector2 = BASE_TOWERS_BLUE[index]
buildings.append({"id": "%s_base_%d" % ["blau" if team == 0 else "rot", index + 1], "team": team, "lane": "base",
"tier": "base", "progress": -1.0, "position": point if team == 0 else mirror(point),
"max_hp": balance.base_tower_hp, "damage": balance.base_tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_base_tower})
buildings.append({"id": "%s_ancient" % ("blau" if team == 0 else "rot"), "team": team, "lane": "base", "tier": "core",
"progress": -1.0, "position": core_position(team), "max_hp": balance.core_hp, "damage": 0.0, "range": 0.0, "bounty": 0})
var _index: Dictionary = {}
func building_def(id: String) -> Dictionary:
if _index.is_empty():
for entry: Dictionary in buildings:
_index[entry.id] = entry
return _index.get(id, {})
## Gate rule shared by every damage source. alive maps building id -> bool.
static func is_attackable(definition: Dictionary, alive: Dictionary) -> bool:
var prefix: String = "blau" if int(definition.team) == 0 else "rot"
match str(definition.tier):
"outer":
return true
"inner":
return not alive.get("%s_%s_outer" % [prefix, definition.lane], false)
"base":
for lane: String in LANES:
if not alive.get("%s_%s_outer" % [prefix, lane], false) and not alive.get("%s_%s_inner" % [prefix, lane], false):
return true
return false
"core":
return not alive.get(prefix + "_base_1", false) and not alive.get(prefix + "_base_2", false)
return false

View file

@ -0,0 +1 @@
uid://bqnx0kaccktd5

View file

@ -0,0 +1,621 @@
class_name LuneMatch
extends RefCounted
## Authoritative match: six player slots in two teams, persistent ownership, planning
## drafts that are validated and committed atomically, combat, settlement and victory.
## A match ends only when an Ancient falls. There is no round limit and no HP decision.
enum Phase { PLANNING, COMBAT, OVER }
const ONGOING := -2
const DRAW := -1
const GROTTO := "grotto"
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "support"}
const HERO_ROLES: Dictionary = {"held_ringborke": "front", "held_quellgeflecht": "support", "held_funkenlanze": "ranged"}
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
var balance: LuneBalance
var map: LuneMap
var rng := RandomNumberGenerator.new()
var seed_value: int = 0
var round_number: int = 1
var phase: int = Phase.PLANNING
var winner: int = ONGOING
var end_note: String = ""
var players: Array[Dictionary] = []
var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
## Public boss state. claim = {team, won_round, deploy_round, chooser_slot, lane} or {}.
var boss: Dictionary = {}
var boss_log: Array[Dictionary] = []
## Remainder rotation per team so both teams follow the same distribution pattern.
var _reward_rotation: Array[int] = [0, 0]
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
map = LuneMap.new(balance)
## options: seed, human_slot (-1 for six bots), bunds (Array of six).
func setup(options: Dictionary = {}) -> void:
seed_value = int(options.get("seed", 1))
rng.seed = seed_value
round_number = 1
phase = Phase.PLANNING
winner = ONGOING
end_note = ""
history.clear()
last_settlement = {}
_reward_rotation = [0, 0]
boss = {"next_available_round": 1, "claim": {}}
boss_log.clear()
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
for slot: int in range(6):
var bund: String = str(bunds[slot]) if slot < bunds.size() else ("glutpakt" if (slot + seed_value) % 2 == 0 else "wurzelbund")
var player := {"slot": slot, "team": slot / 3, "lane": LuneMap.LANES[slot % 3], "is_human": slot == human,
"name": "Du" if slot == human else BOT_NAMES[(slot + seed_value) % BOT_NAMES.size()], "bund": bund,
"gold": balance.start_gold, "roster": [], "hero": {}, "bookings": [], "ready": slot != human,
"earned": 0, "spent": 0, "next_seq": 1}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
building_hp.clear()
for definition: Dictionary in map.buildings:
building_hp[definition.id] = float(definition.max_hp)
func _snapshot(player: Dictionary) -> Dictionary:
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true), "next_seq": int(player.next_seq),
"hero": (player.hero as Dictionary).duplicate(true), "new_bookings": [], "boss_lane": ""}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
func base_ids(bund: String) -> Array[String]:
return DataCatalog.faction_ids(bund)
func grotto_available() -> bool:
return round_number >= int(boss.next_available_round)
# ---------------------------------------------------------------- planning commands
func submit(slot: int, command: Dictionary) -> Dictionary:
if phase != Phase.PLANNING:
return _result(false, "Befehle sind nur in der Planung möglich.")
if slot < 0 or slot >= players.size():
return _result(false, "Unbekannter Spieler.")
var player: Dictionary = players[slot]
var draft: Dictionary = player.draft
var before: Dictionary = draft.duplicate(true)
var result: Dictionary = _apply(player, draft, command)
if result.ok:
(player.undo as Array).append(before)
return result
func undo(slot: int) -> Dictionary:
var player: Dictionary = players[slot]
if phase != Phase.PLANNING or (player.undo as Array).is_empty():
return _result(false, "Nichts rückgängig zu machen.")
player.draft = (player.undo as Array).pop_back()
return _result(true, "Letzte Planung zurückgenommen.")
func _find(roster: Array, uid: String) -> int:
for index: int in range(roster.size()):
if str(roster[index].uid) == uid:
return index
return -1
func _next_seq(player: Dictionary, draft: Dictionary) -> int:
var seq: int = int(draft.get("next_seq", player.next_seq))
draft.next_seq = seq + 1
return seq
func _valid_destination(lane: String) -> bool:
return LuneMap.LANES.has(lane) or (lane == GROTTO and grotto_available())
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
var hero: Dictionary = draft.hero
match str(command.get("type", "")):
"buy":
var type_id: String = str(command.get("type_id", ""))
if not base_ids(player.bund).has(type_id):
return _result(false, "Diese Truppe gehört nicht zu deinem Bund.")
if roster.size() >= balance.supply_cap:
return _result(false, "Versorgungsgrenze erreicht (%d Truppen)." % balance.supply_cap)
var cost: int = int(DataCatalog.get_unit(type_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
var lane: String = str(command.get("lane", player.lane))
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
# Per-player numbering keeps both teams' ids in the same order; seq sorts numerically.
var seq: int = _next_seq(player, draft)
roster.append({"uid": "p%d-%d" % [player.slot, seq], "seq": seq, "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name)
"upgrade":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
var upgrade_id: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade_id.is_empty():
return _result(false, "Bereits vollständig aufgewertet.")
var cost: int = int(DataCatalog.get_unit(upgrade_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
entry.type_id = upgrade_id
entry.invested = int(entry.invested) + cost
return _result(true, "Zu %s aufgewertet." % DataCatalog.get_unit(upgrade_id).display_name)
"sell":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
# A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * balance.sell_refund)
draft.gold = int(draft.gold) + refund
roster.remove_at(index)
return _result(true, "Für %d Gold zurückgegeben." % refund)
"assign":
var uid: String = str(command.get("uid", ""))
var lane: String = str(command.get("lane", ""))
if not _valid_destination(lane):
return _result(false, "Die Grotte ist bis Runde %d gesperrt." % int(boss.next_available_round) if lane == GROTTO else "Ungültige Zuweisung.")
if not hero.is_empty() and str(hero.uid) == uid:
hero.lane = lane
else:
var index: int = _find(roster, uid)
if index < 0:
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "In die Bossgrotte entsandt." if lane == GROTTO else "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
"hero_buy":
var hero_id: String = str(command.get("hero_id", ""))
if not LuneContent.HEROES.has(hero_id):
return _result(false, "Unbekannter Held.")
if not hero.is_empty():
return _result(false, "Pro Spieler ist ein aktiver Held erlaubt.")
var cost: int = int(LuneContent.HEROES[hero_id].cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
var seq: int = _next_seq(player, draft)
draft.hero = {"uid": "h%d-%d" % [player.slot, seq], "seq": seq, "hero_id": hero_id, "lane": player.lane, "xp": 0,
"items": [], "talents": [], "invested": cost, "fresh": true, "fresh_items": []}
return _result(true, "%s angeworben." % LuneContent.HEROES[hero_id].name)
"hero_cancel":
if hero.is_empty() or not bool(hero.get("fresh", false)):
return _result(false, "Nur ein Held aus dieser Planung lässt sich zurückgeben.")
var refund: int = int(hero.invested)
for item_id: String in hero.items:
refund += int(LuneContent.ITEMS[item_id].cost)
draft.gold = int(draft.gold) + refund
draft.hero = {}
return _result(true, "Held zurückgegeben (%d Gold)." % refund)
"item_buy":
var item_id: String = str(command.get("item_id", ""))
if command.has("uid") and (hero.is_empty() or str(command.uid) != str(hero.uid)):
return _result(false, "Normale Truppen tragen keine Items.")
if hero.is_empty():
return _result(false, "Items benötigen einen Helden.")
if not LuneContent.ITEMS.has(item_id):
return _result(false, "Unbekanntes Item.")
var item: Dictionary = LuneContent.ITEMS[item_id]
if (hero.items as Array).has(item_id):
return _result(false, "%s trägt dein Held bereits; Items sind einzigartig." % item.name)
if (hero.items as Array).size() >= LuneContent.ITEM_SLOTS:
return _result(false, "Alle %d Itemplätze sind belegt." % LuneContent.ITEM_SLOTS)
if LuneContent.level_for(int(hero.xp)) < int(item.get("min_level", 1)):
return _result(false, "%s erfordert Stufe %d." % [item.name, int(item.min_level)])
if int(draft.gold) < int(item.cost):
return _result(false, "Es fehlen %d Gold." % (int(item.cost) - int(draft.gold)))
draft.gold = int(draft.gold) - int(item.cost)
(hero.items as Array).append(item_id)
(hero.fresh_items as Array).append(item_id)
return _result(true, "%s ausgerüstet." % item.name)
"item_sell":
var item_id: String = str(command.get("item_id", ""))
if hero.is_empty() or not (hero.items as Array).has(item_id):
return _result(false, "Dein Held trägt dieses Item nicht.")
var cost: int = int(LuneContent.ITEMS[item_id].cost)
var fresh: bool = (hero.fresh_items as Array).has(item_id)
var refund: int = cost if fresh else floori(float(cost) * LuneContent.ITEM_REFUND)
(hero.items as Array).erase(item_id)
(hero.fresh_items as Array).erase(item_id)
draft.gold = int(draft.gold) + refund
return _result(true, "Item für %d Gold verkauft." % refund)
"talent":
if hero.is_empty():
return _result(false, "Talente benötigen einen Helden.")
var tier: int = int(command.get("tier", -1))
var choice: int = int(command.get("choice", -1))
var talents: Array = hero.talents
if tier != talents.size() or tier < 0 or tier >= LuneContent.TALENT_LEVELS.size() or choice < 0 or choice > 1:
return _result(false, "Dieses Talent ist jetzt nicht wählbar.")
if LuneContent.level_for(int(hero.xp)) < LuneContent.TALENT_LEVELS[tier]:
return _result(false, "Talentstufe %d erfordert Heldenstufe %d." % [tier + 1, LuneContent.TALENT_LEVELS[tier]])
var option: Dictionary = LuneContent.TALENTS[hero.hero_id][tier][choice]
talents.append(option.id)
return _result(true, "Talent %s gewählt." % option.name)
"champion_book":
var champion_id: String = str(command.get("champion_id", ""))
var lane: String = str(command.get("lane", player.lane))
if not LuneContent.CHAMPIONS.has(champion_id) or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Buchung.")
if (draft.new_bookings as Array).size() >= LuneContent.MAX_BOOKINGS:
return _result(false, "Höchstens %d Angriffschampions pro Runde." % LuneContent.MAX_BOOKINGS)
var cost: int = int(LuneContent.CHAMPIONS[champion_id].cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
var seq: int = _next_seq(player, draft)
(draft.new_bookings as Array).append({"uid": "c%d-%d" % [player.slot, seq], "seq": seq, "champion_id": champion_id, "lane": lane, "cost": cost})
return _result(true, "%s für die nächste Runde auf %s gebucht." % [LuneContent.CHAMPIONS[champion_id].name, LuneMap.LANE_NAMES[lane]])
"champion_cancel":
var uid: String = str(command.get("uid", ""))
var bookings: Array = draft.new_bookings
for index: int in range(bookings.size()):
if str(bookings[index].uid) == uid:
draft.gold = int(draft.gold) + int(bookings[index].cost)
bookings.remove_at(index)
return _result(true, "Buchung zurückgenommen.")
return _result(false, "Nur Buchungen aus dieser Planung lassen sich zurücknehmen.")
"boss_lane":
var claim: Dictionary = boss.claim
var lane: String = str(command.get("lane", ""))
if claim.is_empty() or int(claim.deploy_round) != round_number or int(claim.chooser_slot) != int(player.slot):
return _result(false, "Du bestimmst die Boss-Lane nicht.")
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.boss_lane = lane
return _result(true, "Der Boss greift über %s an." % LuneMap.LANE_NAMES[lane])
return _result(false, "Unbekannter Befehl.")
func _result(ok: bool, message: String) -> Dictionary:
return {"ok": ok, "message": message}
func set_ready(slot: int, value: bool = true) -> void:
if phase == Phase.PLANNING:
players[slot].ready = value
func all_ready() -> bool:
for player: Dictionary in players:
if not bool(player.ready):
return false
return true
## Public information for everyone: champion bookings and the boss deployment of this round.
func announcements() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for player: Dictionary in players:
for booking: Dictionary in player.bookings:
if int(booking.deploy_round) == round_number:
result.append({"kind": "champion", "team": player.team, "slot": player.slot, "lane": booking.lane, "champion_id": booking.champion_id})
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.deploy_round) == round_number:
result.append({"kind": "boss", "team": claim.team, "slot": claim.chooser_slot, "lane": claim.lane,
"bounty": LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * round_number})
return result
# ---------------------------------------------------------------- phase changes
## Validates every draft again and commits all of them at once, then starts combat.
func begin_combat() -> bool:
if phase != Phase.PLANNING:
return false
for player: Dictionary in players:
if not _draft_valid(player):
player.draft = _snapshot(player)
for player: Dictionary in players:
var draft: Dictionary = player.draft
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(draft.gold))
player.gold = int(draft.gold)
player.next_seq = int(draft.get("next_seq", player.next_seq))
player.roster = (draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
player.hero = (draft.hero as Dictionary).duplicate(true)
if not (player.hero as Dictionary).is_empty():
player.hero.erase("fresh")
player.hero.fresh_items = []
for booking: Dictionary in draft.new_bookings:
var committed: Dictionary = booking.duplicate()
committed.deploy_round = round_number + 1
(player.bookings as Array).append(committed)
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.chooser_slot) == int(player.slot) and not str(draft.boss_lane).is_empty():
claim.lane = draft.boss_lane
player.undo = []
combat = LuneCombat.new(map, balance, round_number)
for definition: Dictionary in map.buildings:
if float(building_hp[definition.id]) > 0.0:
combat.add_building(definition, float(building_hp[definition.id]))
_spawn_waves()
_spawn_champions()
_spawn_deployed_boss()
_spawn_armies()
phase = Phase.COMBAT
return true
func _draft_valid(player: Dictionary) -> bool:
var draft: Dictionary = player.draft
if int(draft.gold) < 0 or (draft.roster as Array).size() > balance.supply_cap:
return false
var allowed: Array[String] = base_ids(player.bund)
for entry: Dictionary in draft.roster:
var id: String = str(entry.type_id)
var base: String = id
for candidate: String in allowed:
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
base = candidate
if not allowed.has(base) or not _valid_destination(str(entry.lane)):
return false
var hero: Dictionary = draft.hero
if not hero.is_empty():
var items: Array = hero.items
if items.size() > LuneContent.ITEM_SLOTS or not _valid_destination(str(hero.lane)):
return false
for index: int in range(items.size()):
if items.find(items[index]) != index or not LuneContent.ITEMS.has(items[index]):
return false
if (hero.talents as Array).size() > LuneContent.TALENT_LEVELS.size():
return false
return (draft.new_bookings as Array).size() <= LuneContent.MAX_BOOKINGS
func _spawn_waves() -> void:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
for team: int in range(2):
for lane: String in LuneMap.LANES:
for index: int in range(wave.size()):
var row: int = index / 3
var lateral: float = (float(index % 3) - 1.0) * 1.3
combat.spawn("monster", team, lane, wave[index].duplicate(true), 11.0 - float(row) * 1.4, lateral)
func _spawn_champions() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
var index: int = 0
for slot: int in range(team * 3, team * 3 + 3):
for booking: Dictionary in players[slot].bookings:
if int(booking.deploy_round) == round_number and booking.lane == lane:
var definition: Dictionary = LuneContent.champion_definition(booking.champion_id, round_number, balance)
combat.spawn("champion", team, lane, definition, 13.0, (float(index % 3) - 1.0) * 1.4, slot, str(booking.uid))
index += 1
func _spawn_deployed_boss() -> void:
var claim: Dictionary = boss.claim
if claim.is_empty() or int(claim.deploy_round) != round_number:
return
var definition: Dictionary = LuneContent.boss_definition(round_number, balance, true)
combat.spawn("boss", int(claim.team), str(claim.lane), definition, 14.0, 0.0, int(claim.chooser_slot), "boss-%d" % round_number)
boss_log.append({"round": round_number, "event": "deployed", "team": claim.team, "lane": claim.lane})
func _army_entries(slot: int) -> Array:
var player: Dictionary = players[slot]
var entries: Array = []
for entry: Dictionary in player.roster:
entries.append({"slot": slot, "uid": entry.uid, "seq": int(entry.seq), "lane": entry.lane, "role": role_of(entry.type_id),
"invested": int(entry.invested), "definition": _troop_definition(entry.type_id), "kind": "troop"})
var hero: Dictionary = player.hero
if not hero.is_empty():
var invested: int = int(hero.invested)
for item_id: String in hero.items:
invested += int(LuneContent.ITEMS[item_id].cost)
var definition: Dictionary = LuneContent.hero_definition(hero)
definition.role = HERO_ROLES[hero.hero_id]
entries.append({"slot": slot, "uid": hero.uid, "seq": int(hero.seq), "lane": hero.lane, "role": definition.role,
"invested": invested, "definition": definition, "kind": "hero"})
return entries
func _troop_definition(type_id: String) -> Dictionary:
var definition: Dictionary = DataCatalog.get_unit(type_id)
definition.role = role_of(type_id)
return definition
func _sorted(entries: Array) -> Array:
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(a.role)
var rb: int = ROW_ORDER.find(b.role)
return a.seq < b.seq if ra == rb else ra < rb)
return entries
func _spawn_armies() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
# The lane owner's troops form the first rows; supporting players follow behind.
var owner: int = slot_of(team, lane)
var ordered: Array = []
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
ordered.append_array(_sorted(_army_entries(slot).filter(func(e: Dictionary) -> bool: return e.lane == lane)))
for index: int in range(ordered.size()):
var entry: Dictionary = ordered[index]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
combat.spawn(entry.kind, team, lane, entry.definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, entry.slot, str(entry.uid))
# Grotto expeditions leave their lanes for the whole combat phase.
var expedition: bool = false
for team: int in range(2):
var ordered: Array = []
for slot: int in range(team * 3, team * 3 + 3):
ordered.append_array(_sorted(_army_entries(slot).filter(func(e: Dictionary) -> bool: return e.lane == GROTTO)))
var entry_point: Vector2 = map.grotto_entry(team)
var inward: Vector2 = (map.grotto_boss_point() - entry_point).normalized()
var across := Vector2(-inward.y, inward.x) * (1.0 if team == 0 else -1.0)
for index: int in range(ordered.size()):
var entry: Dictionary = ordered[index]
var at: Vector2 = entry_point - inward * float(index / 3) * 1.2 + across * (float(index % 3) - 1.0) * 1.1
combat.spawn_at(entry.kind, team, "grotto", entry.definition, at, entry.slot, str(entry.uid))
expedition = true
if expedition:
combat.add_grotto_boss(LuneContent.boss_definition(round_number, balance, false))
func role_of(type_id: String) -> String:
var id: String = type_id
for base: String in ROLES:
if base == id or str(DataCatalog.get_unit(base).get("upgrade_id", "")) == id:
return ROLES[base]
return "melee"
func step_combat() -> void:
if phase != Phase.COMBAT or combat == null:
return
combat.step()
if combat.finished:
_settle()
## Runs the current combat phase to its end (tests, bots-only simulations).
func finish_combat() -> void:
while phase == Phase.COMBAT:
step_combat()
func _settle() -> void:
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
var lane_kills: Dictionary = {}
var buildings_lost: Array[String] = []
var xp_gain: Dictionary = {}
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying if dying < 2 else -1
var bounty: int = int(event.bounty)
if event.kind == "boss" and event.zone == "lane" and bounty > 0 and killer >= 0:
# The defenders of a deployed boss share its bounty evenly.
_split(gains, killer, bounty)
elif event.kind in ["monster", "champion"] and bounty > 0 and event.zone == "lane" and killer >= 0:
var owner: int = slot_of(killer, str(event.lane))
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
gains[owner] += lane_part
_split(gains, killer, bounty - lane_part)
lane_kills[event.lane] = int(lane_kills.get(event.lane, 0)) + 1
elif event.kind in ["tower", "core"]:
buildings_lost.append(str(event.building_id))
_split(gains, killer, bounty)
var heroes: Array = event.get("xp_heroes", [])
if not heroes.is_empty():
var share: int = maxi(1, int(event.xp) / heroes.size())
for uid: String in heroes:
xp_gain[uid] = int(xp_gain.get(uid, 0)) + share
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
elif unit.kind == "hero":
xp_gain[unit.persistent_uid] = int(xp_gain.get(unit.persistent_uid, 0)) + LuneContent.XP_PARTICIPATION + LuneContent.XP_PER_ROUND * round_number
for player: Dictionary in players:
var hero: Dictionary = player.hero
if not hero.is_empty() and xp_gain.has(hero.uid):
hero.xp = int(hero.xp) + int(xp_gain[hero.uid])
var income: int = balance.round_income(round_number)
for player: Dictionary in players:
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
player.gold = int(player.gold) + total
player.earned = int(player.earned) + total
# Deployed bookings are consumed; they never return as property.
player.bookings = (player.bookings as Array).filter(func(b: Dictionary) -> bool: return int(b.deploy_round) > round_number)
# The claim is decided from this round's expedition before its units go home.
var boss_result: String = _settle_boss()
for player: Dictionary in players:
# An expedition is an order for one round: units return to their owner's lane.
for entry: Dictionary in player.roster:
if entry.lane == GROTTO:
entry.lane = player.lane
if not (player.hero as Dictionary).is_empty() and player.hero.lane == GROTTO:
player.hero.lane = player.lane
last_settlement = {"round": round_number, "gains": gains, "income": income, "buildings_lost": buildings_lost,
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills, "xp": xp_gain, "boss": boss_result}
history.append({"round": round_number, "end_reason": combat.end_reason, "combat_time": combat.elapsed,
"buildings": _alive_count(), "gold": players.map(func(p: Dictionary) -> int: return int(p.gold)),
"lost": buildings_lost.duplicate(), "boss": boss_result,
"hero_levels": players.map(func(p: Dictionary) -> int: return 0 if (p.hero as Dictionary).is_empty() else LuneContent.level_for(int(p.hero.xp)))})
if not combat.destroyed_cores.is_empty():
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
end_note = "Beide Ancients fielen im selben Schritt." if winner == DRAW else "Ancient von %s zerstört." % ("Blau" if combat.destroyed_cores[0] == 0 else "Rot")
phase = Phase.OVER
return
round_number += 1
phase = Phase.PLANNING
for player: Dictionary in players:
player.draft = _snapshot(player)
player.undo = []
player.ready = not bool(player.is_human)
## Boss bookkeeping at settlement. Returns a short outcome label for reports.
## Idempotent: a repeated settlement signal for the same combat changes nothing.
func _settle_boss() -> String:
var fought: int = combat.round_number
if int(boss.get("settled_round", 0)) == fought:
return "repeat"
boss.settled_round = fought
var result: String = "none"
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.deploy_round) == fought:
# The deployment happens exactly once; afterwards the claim is gone.
boss.claim = {}
result = "deployed"
if combat.grotto_outcome == "success" and (boss.claim as Dictionary).is_empty() and fought >= int(boss.next_available_round):
var team: int = combat.grotto_claim_team
var chooser: int = _boss_chooser(team)
boss.claim = {"team": team, "won_round": fought, "deploy_round": fought + 1, "chooser_slot": chooser,
"lane": players[chooser].lane}
boss.next_available_round = fought + 1 + LuneContent.BOSS_LOCK_ROUNDS
boss_log.append({"round": fought, "event": "won", "team": team, "chooser": chooser})
result = "won"
elif combat.grotto_outcome == "failed":
boss_log.append({"round": fought, "event": "failed"})
result = "failed" if result == "none" else result
return result
## Contributing lane player of the winning team with the highest army value in the grotto;
## ties go to the lower slot.
func _boss_chooser(team: int) -> int:
var best: int = team * 3
var best_value: int = -1
for slot: int in range(team * 3, team * 3 + 3):
var value: int = 0
for entry: Dictionary in _army_entries(slot):
if entry.lane == GROTTO:
value += int(entry.invested)
if value > best_value:
best = slot
best_value = value
return best
## Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
func _split(gains: Array[int], team: int, amount: int) -> void:
if amount <= 0 or team < 0:
return
var share: int = amount / 3
var rest: int = amount % 3
for offset: int in range(3):
gains[team * 3 + offset] += share
for index: int in range(rest):
gains[team * 3 + (_reward_rotation[team] + index) % 3] += 1
_reward_rotation[team] = (_reward_rotation[team] + rest) % 3
func _alive_count() -> Array[int]:
var counts: Array[int] = [0, 0]
for id: String in building_hp:
if float(building_hp[id]) > 0.0:
counts[0 if id.begins_with("blau") else 1] += 1
return counts
func building_alive(id: String) -> bool:
return float(building_hp.get(id, 0.0)) > 0.0
## Live state for displays: during combat the combat instance is authoritative.
func building_alive_now(id: String) -> bool:
if phase == Phase.COMBAT and combat != null and combat.buildings.has(id):
return (combat.buildings[id] as LuneUnit).alive
return building_alive(id)
func towers_alive(team: int, lane: String, live: bool = false) -> int:
var prefix: String = "blau" if team == 0 else "rot"
var probe: Callable = building_alive_now if live else building_alive
return int(probe.call("%s_%s_outer" % [prefix, lane])) + int(probe.call("%s_%s_inner" % [prefix, lane]))
func core_hp(team: int) -> float:
return float(building_hp["blau_ancient" if team == 0 else "rot_ancient"])
func gold_of(slot: int) -> int:
return int(players[slot].draft.gold) if phase == Phase.PLANNING else int(players[slot].gold)

Some files were not shown because too many files have changed in this diff Show more