Commit
Theria
refactor: rename the faction noun from Volk to tribe
modified CHANGELOG.md
@@ -35,13 +35,13 @@ protocol version.
### Added
- A practice match is now a Volk-versus-Volk choice: `--hero` accepts any hero of either
Volk, and the chosen hero's Volk fields the player's team while the opposing Volk fills
- A practice match is now a tribe-versus-tribe choice: `--hero` accepts any hero of either
tribe, and the chosen hero's tribe fields the player's team while the opposing tribe fills
the bots — so the Verdani are now playable, not just an opponent. The default still
seats the Solane against the Verdani. Which heroes form which Volk is recorded once in
seats the Solane against the Verdani. Which heroes form which tribe is recorded once in
the ability catalog and read by the client, so the rosters cannot drift apart.
- A second hero roster, the **Verdani** — jungle venom-and-shadow shifters (snake,
spider, chameleon) — joins the Solane as the opposing Volk, authored on the same
spider, chameleon) — joins the Solane as the opposing tribe, authored on the same
ability primitives as a deliberate foil: the snake is a venom striker with the
longest single-target lock, the spider a trapper laying the widest, lowest-power
ground webs, and the chameleon an ambusher carrying the single heaviest hit of either
@@ -64,7 +64,7 @@ protocol version.
one (the Lion by default, or `--hero cheetah`/`--hero hyena`) and bots drive the rest.
A hosted or joined match stays a one-hero-per-team duel until multi-hero play reaches
the wire.
- The first roster of distinct heroes — the **Solane**, a Volk of savanna big-cat
- The first roster of distinct heroes — the **Solane**, a tribe of savanna big-cat
shifters: a **Lion** frontline bruiser (short-range pokes, a heavy melee strike, and
the deepest self-sustain), a **Cheetah** burst skirmisher (long-range pokes and a
fast, repeatable single-target shred), and a **Hyena** zone controller (the widest
modified README.md
@@ -86,9 +86,9 @@ godot --path .
```
A connect screen opens: choose **Practice** for a single-machine match, **Host** to
start a listen-server, or type an address and **Join** one. Practice is a Volk-vs-Volk
match: `--hero` names the hero you drive — any hero of either Volk — and that hero's
Volk fields your team while the opposing Volk fills the bots, so `--hero snake` puts you
start a listen-server, or type an address and **Join** one. Practice is a tribe-vs-tribe
match: `--hero` names the hero you drive — any hero of either tribe — and that hero's
tribe fields your team while the opposing tribe fills the bots, so `--hero snake` puts you
on the Verdani against the Solane, and the default lion keeps the Solane against the
Verdani. Bots drive the other five seats. A hosted or joined match is still a
one-hero-per-team duel on the lion until multi-hero play
modified src/client/main.gd
@@ -72,14 +72,14 @@ const CREEP_HP_BAR_OFFSET := Vector2(-35.0, -55.0)
const HP_BAR_BG := Color(0.0, 0.0, 0.0, 0.6)
const HP_BAR_FG := Color(0.4, 0.85, 0.4)
## The Volk the player's team falls back to in a LOCAL practice match when `--hero`
## names no known hero. The rosters themselves live in `AbilityData.VOLK` — the single
## source of which heroes form which Volk — and `_start_local` seats the player's chosen
## Volk against the opposing one, so the match exercises both rosters and all four
## The tribe the player's team falls back to in a LOCAL practice match when `--hero`
## names no known hero. The rosters themselves live in `AbilityData.TRIBE` — the single
## source of which heroes form which tribe — and `_start_local` seats the player's chosen
## tribe against the opposing one, so the match exercises both rosters and all four
## targeting modes. HOST/CLIENT still seat the one-per-team duel (DUEL_KIT below): the
## wire identifies a hero by its team, so a networked squad waits on the protocol step
## that gives each client a controlled-entity id.
const DEFAULT_VOLK := "solane"
const DEFAULT_TRIBE := "solane"
## The kit both heroes mirror in a HOST/CLIENT duel — the one-per-team walking
## skeleton the netcode is built around until the multi-hero wire step lands.
@@ -127,11 +127,11 @@ var _bot := BotController.new()
var _hero_id: int = 0
var _bot_id: int = 0
## LOCAL: the hero the player drives, from `--hero` — any hero of either Volk. Its
## Volk fills the player's team and the opposing Volk the bot team, so the choice also
## picks the match-up. Falls back to the first hero of the default Volk if unset or
## LOCAL: the hero the player drives, from `--hero` — any hero of either tribe. Its
## tribe fills the player's team and the opposing tribe the bot team, so the choice also
## picks the match-up. Falls back to the first hero of the default tribe if unset or
## unrecognised. Ignored by HOST/CLIENT, which seat the duel.
var _player_hero: String = AbilityData.VOLK[DEFAULT_VOLK][0]
var _player_hero: String = AbilityData.TRIBE[DEFAULT_TRIBE][0]
## LOCAL: every bot-driven hero this match — the player's two squadmates and the
## three opponents — each stepped from its own BotController decision.
var _bot_ids: Array[int] = []
@@ -290,23 +290,23 @@ func _close_menu_and_enter() -> void:
_enter_match()
## Practice: a Volk-vs-Volk match. `--hero` names the hero the player drives; that
## hero's Volk (per `AbilityData.VOLK`) fills the player's team and the opposing Volk the
## Practice: a tribe-vs-tribe match. `--hero` names the hero the player drives; that
## hero's tribe (per `AbilityData.TRIBE`) fills the player's team and the opposing tribe the
## bot team, one hero per roster kit. The player drives the matching seat; the other five
## are bot-driven, so both rosters are on the field at once. An unknown name falls back
## to the default Volk's first hero, so a typo starts a valid match instead of crashing.
## to the default tribe's first hero, so a typo starts a valid match instead of crashing.
func _start_local() -> void:
_sim = _new_world()
var player_volk := AbilityData.volk_of(_player_hero)
if player_volk == "":
var fallback: String = AbilityData.VOLK[DEFAULT_VOLK][0]
var player_tribe := AbilityData.tribe_of(_player_hero)
if player_tribe == "":
var fallback: String = AbilityData.TRIBE[DEFAULT_TRIBE][0]
push_warning("unknown --hero %s; defaulting to %s" % [_player_hero, fallback])
_player_hero = fallback
player_volk = DEFAULT_VOLK
player_tribe = DEFAULT_TRIBE
var player_roster: Array[String] = []
player_roster.assign(AbilityData.VOLK[player_volk])
player_roster.assign(AbilityData.TRIBE[player_tribe])
var bot_roster: Array[String] = []
bot_roster.assign(AbilityData.VOLK[AbilityData.opposing_volk(player_volk)])
bot_roster.assign(AbilityData.TRIBE[AbilityData.opposing_tribe(player_tribe)])
_seat_squad(HERO_TEAM, HERO_SPEED, player_roster, player_roster.find(_player_hero))
_seat_squad(BOT_TEAM, BOT_SPEED, bot_roster, -1)
modified src/sim/ability_data.gd
@@ -10,7 +10,7 @@ extends RefCounted
## whole catalog — all four targeting modes, the three effects, a per-form resource,
## and the human/animal transform. It stays as the reference the schema tests drive.
##
## The first Volk's roster is authored on top of it: the **Solane** — savanna big-cat
## The first tribe's roster is authored on top of it: the **Solane** — savanna big-cat
## shifters. Three mirror heroes, each a human kit plus an animal kit, built
## from the same DAMAGE/HEAL/TRANSFORM primitives but given distinct identities through
## their targeting mix, their tuning, and their resource economy:
@@ -21,17 +21,17 @@ extends RefCounted
## - **Hyena** — a zone controller: the widest ground areas in both forms for
## attrition, on a baseline pool.
##
## The opposing Volk, the **Verdani** — jungle venom-and-shadow shifters — is authored
## The opposing tribe, the **Verdani** — jungle venom-and-shadow shifters — is authored
## on the same primitives, a deliberate foil to the Solane archetypes:
## - **Snake** — a venom striker: a long single-target lock, a cheap low-cooldown
## Fang Strike, and a heavy Venom Coil payoff, on a mid-tier pool.
## - **Spider** — a trapper: the longest, widest, lowest-power ground webs in the
## game for pure attrition, on the deepest, slowest-regen pool.
## - **Chameleon** — an ambusher: a short hard skillshot and the single heaviest hit
## in either Volk, on the leanest, fastest-refilling pool.
## in either tribe, on the leanest, fastest-refilling pool.
## In a practice match the player's squad fields the Solane and the bot squad the
## Verdani, so both rosters and all four targeting modes are exercised at once. The two
## Volk are still effect-mirrors (DAMAGE/HEAL/TRANSFORM); the venom/web flavor is carried
## tribes are still effect-mirrors (DAMAGE/HEAL/TRANSFORM); the venom/web flavor is carried
## by their targeting mix, tuning, and economy until a richer effect schema lands.
## Ability rows keyed by catalog id. Each row is parsed on demand into a typed
@@ -141,7 +141,7 @@ const ABILITIES := {
"cost": 35,
"cooldown_ticks": 150,
"effect": AbilitySpec.EFFECT_HEAL,
"power": 150, # the deepest heal in the Volk: the bruiser's staying power
"power": 150, # the deepest heal in the tribe: the bruiser's staying power
},
12:
{
@@ -180,7 +180,7 @@ const ABILITIES := {
"cost": 35,
"cooldown_ticks": 48,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 160, # the hardest single hit in the Volk
"power": 160, # the hardest single hit in the tribe
},
15:
{
@@ -201,7 +201,7 @@ const ABILITIES := {
"form": AbilitySpec.FORM_HUMAN,
"slot": 0,
"target_kind": AbilitySpec.TARGET_SKILLSHOT,
"range": 750.0, # the longest reach in the Volk
"range": 750.0, # the longest reach in the tribe
"radius": 50.0, # but a tight line: it must be aimed
"cost": 20,
"cooldown_ticks": 24,
@@ -241,7 +241,7 @@ const ABILITIES := {
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 280.0,
"cost": 15,
"cooldown_ticks": 18, # the shortest cooldown in the Volk: harass on repeat
"cooldown_ticks": 18, # the shortest cooldown in the tribe: harass on repeat
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 70,
},
@@ -329,7 +329,7 @@ const ABILITIES := {
"slot": 2,
"target_kind": AbilitySpec.TARGET_GROUND,
"range": 320.0,
"radius": 210.0, # the widest area in the Volk
"radius": 210.0, # the widest area in the tribe
"cost": 35,
"cooldown_ticks": 44,
"effect": AbilitySpec.EFFECT_DAMAGE,
@@ -392,7 +392,7 @@ const ABILITIES := {
"form": AbilitySpec.FORM_ANIMAL,
"slot": 0,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 360.0, # the longest single-target lock in either Volk
"range": 360.0, # the longest single-target lock in either tribe
"cost": 15,
"cooldown_ticks": 18, # cheap and fast: harass on repeat
"effect": AbilitySpec.EFFECT_DAMAGE,
@@ -435,7 +435,7 @@ const ABILITIES := {
"cost": 30,
"cooldown_ticks": 38,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 50, # the lowest per-hit power in either Volk: pure attrition
"power": 50, # the lowest per-hit power in either tribe: pure attrition
},
51:
{
@@ -482,7 +482,7 @@ const ABILITIES := {
"slot": 2,
"target_kind": AbilitySpec.TARGET_GROUND,
"range": 340.0,
"radius": 220.0, # the widest area in either Volk
"radius": 220.0, # the widest area in either tribe
"cost": 35,
"cooldown_ticks": 46,
"effect": AbilitySpec.EFFECT_DAMAGE,
@@ -563,7 +563,7 @@ const ABILITIES := {
"cost": 35,
"cooldown_ticks": 52,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 165, # the single heaviest hit in either Volk
"power": 165, # the single heaviest hit in either tribe
},
65:
{
@@ -599,7 +599,7 @@ const KITS := {
AbilitySpec.FORM_ANIMAL: {0: 4, 2: 5, 3: 6},
},
},
# --- Solane (savanna big-cats), the v0.1 mirror Volk ---------------------
# --- Solane (savanna big-cats), the v0.1 mirror tribe ---------------------
"lion":
{
# A bruiser: a generous pool that spends slowly, to back the deep heal and
@@ -644,7 +644,7 @@ const KITS := {
AbilitySpec.FORM_ANIMAL: {0: 33, 2: 34, 3: 35},
},
},
# --- Verdani (jungle venom-and-shadow), the opposing Volk ----------------
# --- Verdani (jungle venom-and-shadow), the opposing tribe ----------------
"snake":
{
# A striker: a precise mid-tier pool, between the Cheetah's lean and the
@@ -678,7 +678,7 @@ const KITS := {
"chameleon":
{
# An ambusher: the leanest pool on the fastest regen, to land a burst and
# refill for the next one — the most boom-and-bust economy of either Volk.
# refill for the next one — the most boom-and-bust economy of either tribe.
"resource":
{
AbilitySpec.FORM_HUMAN: {"max": 70, "regen_ticks": 7},
@@ -693,12 +693,12 @@ const KITS := {
}
## The Völker: each Volk's hero roster, in seating order. The single source of which
## heroes form which Volk — the client reads it to seat a Volk-vs-Volk match, and the
## The tribes: each tribe's hero roster, in seating order. The single source of which
## heroes form which tribe — the client reads it to seat a tribe-vs-tribe match, and the
## roster order fixes each hero's squad slot. The wildkin reference kit is deliberately
## in no Volk. v0.1 ships two Völker; a match pairs one against another (see
## `opposing_volk`).
const VOLK := {
## in no tribe. v0.1 ships two tribes; a match pairs one against another (see
## `opposing_tribe`).
const TRIBE := {
"solane": ["lion", "cheetah", "hyena"],
"verdani": ["snake", "spider", "chameleon"],
}
@@ -720,20 +720,20 @@ static func kit(kit_id: String) -> Dictionary:
return KITS.get(kit_id, {})
## The Volk a hero kit belongs to, or "" if the kit is in no Volk (the wildkin reference
## The tribe a hero kit belongs to, or "" if the kit is in no tribe (the wildkin reference
## kit, or an unknown name). A pure lookup over the roster data.
static func volk_of(kit_id: String) -> String:
for volk in VOLK:
if (VOLK[volk] as Array).has(kit_id):
return volk
static func tribe_of(kit_id: String) -> String:
for tribe in TRIBE:
if (TRIBE[tribe] as Array).has(kit_id):
return tribe
return ""
## The Volk a given Volk is matched against — the next other Volk in declaration order.
## v0.1 fields exactly two, so this is simply "the other one"; returns `volk` itself if
## it is the only Volk defined.
static func opposing_volk(volk: String) -> String:
for other in VOLK:
if other != volk:
## The tribe a given tribe is matched against — the next other tribe in declaration order.
## v0.1 fields exactly two, so this is simply "the other one"; returns `tribe` itself if
## it is the only tribe defined.
static func opposing_tribe(tribe: String) -> String:
for other in TRIBE:
if other != tribe:
return other
return volk
return tribe
modified test/unit/test_local_seating.gd
@@ -1,29 +1,29 @@
extends GutTest
## The Völker roster data that drives a LOCAL Volk-vs-Volk match: `AbilityData.VOLK`
## records which heroes form which Volk, `volk_of` maps a hero back to its Volk, and
## `opposing_volk` names the side it is matched against. `main._start_local` composes
## these to seat the player's chosen Volk against the opposing one — so `--hero snake`
## The tribe roster data that drives a LOCAL tribe-vs-tribe match: `AbilityData.TRIBE`
## records which heroes form which tribe, `tribe_of` maps a hero back to its tribe, and
## `opposing_tribe` names the side it is matched against. `main._start_local` composes
## these to seat the player's chosen tribe against the opposing one — so `--hero snake`
## now fields the Verdani for the player. Pure data, checked without a live client.
const SOLANE: Array[String] = ["lion", "cheetah", "hyena"]
const VERDANI: Array[String] = ["snake", "spider", "chameleon"]
func test_each_hero_maps_back_to_its_volk() -> void:
assert_eq(AbilityData.volk_of("hyena"), "solane", "a Solane hero reports the Solane")
assert_eq(AbilityData.volk_of("snake"), "verdani", "a Verdani hero reports the Verdani")
assert_eq(AbilityData.volk_of("wildkin"), "", "the reference kit belongs to no Volk")
assert_eq(AbilityData.volk_of("griffin"), "", "an unknown name belongs to no Volk")
func test_each_hero_maps_back_to_its_tribe() -> void:
assert_eq(AbilityData.tribe_of("hyena"), "solane", "a Solane hero reports the Solane")
assert_eq(AbilityData.tribe_of("snake"), "verdani", "a Verdani hero reports the Verdani")
assert_eq(AbilityData.tribe_of("wildkin"), "", "the reference kit belongs to no tribe")
assert_eq(AbilityData.tribe_of("griffin"), "", "an unknown name belongs to no tribe")
func test_the_two_volk_oppose_each_other() -> void:
assert_eq(AbilityData.opposing_volk("solane"), "verdani", "the Solane face the Verdani")
assert_eq(AbilityData.opposing_volk("verdani"), "solane", "and the Verdani the Solane")
func test_the_two_tribe_oppose_each_other() -> void:
assert_eq(AbilityData.opposing_tribe("solane"), "verdani", "the Solane face the Verdani")
assert_eq(AbilityData.opposing_tribe("verdani"), "solane", "and the Verdani the Solane")
func test_volk_rosters_seat_three_heroes_each_in_order() -> void:
assert_eq(AbilityData.VOLK["solane"], SOLANE, "the Solane seating order")
assert_eq(AbilityData.VOLK["verdani"], VERDANI, "the Verdani seating order")
func test_tribe_rosters_seat_three_heroes_each_in_order() -> void:
assert_eq(AbilityData.TRIBE["solane"], SOLANE, "the Solane seating order")
assert_eq(AbilityData.TRIBE["verdani"], VERDANI, "the Verdani seating order")
# The seat a hero lands in is its index here — the slot _start_local hands the player.
var spider_seat := (AbilityData.VOLK["verdani"] as Array).find("spider")
var spider_seat := (AbilityData.TRIBE["verdani"] as Array).find("spider")
assert_eq(spider_seat, 1, "the spider is the second Verdani seat")
modified test/unit/test_solane.gd
@@ -1,5 +1,5 @@
extends GutTest
## Data checks on the Solane roster — the first Volk's three hero kits (Lion, Cheetah,
## Data checks on the Solane roster — the first tribe's three hero kits (Lion, Cheetah,
## Hyena). The ability executor itself is proven by test_ability.gd against the wildkin
## kit; these tests prove the *content* is wired correctly: the right ability sits in
## the right slot and form, the tuned numbers land, the transforms flip, and the three
modified test/unit/test_verdani.gd
@@ -1,5 +1,5 @@
extends GutTest
## Data checks on the Verdani roster — the second Volk's three hero kits (Snake, Spider,
## Data checks on the Verdani roster — the second tribe's three hero kits (Snake, Spider,
## Chameleon), the jungle foil to the Solane. The executor itself is proven by
## test_ability.gd; these tests prove the *content*: the right ability sits in the right
## slot and form, the tuned numbers land, the transforms flip, the three economies are
@@ -70,7 +70,7 @@ func test_verdani_kits_use_disjoint_ability_ids() -> void:
func test_verdani_ids_are_clear_of_the_solane() -> void:
# Both Volk are on the field at once in a practice match, so their catalog ids must
# Both tribes are on the field at once in a practice match, so their catalog ids must
# not collide — a Solane id and a Verdani id resolving to one row would cross-wire
# the two rosters.
var sim := SimCore.new()
@@ -111,7 +111,7 @@ func test_spider_web_nest_zones_the_widest_area() -> void:
sim.spawn_creeps = false
var id := _verdani_animal(sim, "spider", 0, Vector2.ZERO)
sim.state.get_entity(id).attack_damage = 0 # isolate Web Nest from the auto-attack
# Web Nest: GROUND, range 340, radius 220 — the widest in either Volk. Land it at
# Web Nest: GROUND, range 340, radius 220 — the widest in either tribe. Land it at
# (300,0) and bracket an enemy just inside the radius against one just outside.
var inside := sim.add_entity(1, Vector2(300.0, 210.0), 0.0, 600) # 210 from centre -> hit
var outside := sim.add_entity(1, Vector2(300.0, 235.0), 0.0, 600) # 235 from centre -> spared
@@ -134,7 +134,7 @@ func test_chameleon_ambush_is_the_heaviest_single_hit() -> void:
cast.target_id = enemy
sim.step({id: cast})
assert_eq(
sim.state.get_entity(enemy).hp, 435, "Ambush deals its 165 — the hardest hit in either Volk"
sim.state.get_entity(enemy).hp, 435, "Ambush deals its 165 — the hardest hit in either tribe"
)
assert_eq(sim.state.get_entity(id).resource, 35, "and spends its 35 from the lean 70 pool")