feat(@projects): define tree component types

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-03 13:31:15 -04:00
parent c76edd4c2f
commit 2c2c1e4ef5
4 changed files with 329 additions and 26 deletions

View file

@ -0,0 +1,49 @@
/**
* Shared types for the tree-style design pages (/tech-tree, /culture-tree).
*
* Both pages render the same structure: a per-era column layout of items
* with tier ordering, prereq SVG lines, and category-tab filtering. The
* concrete data (techs vs culture policies) is normalised to TreeItem at
* the page boundary; the generic TreeView component knows nothing about
* either domain.
*/
export interface TreeUnlocks {
buildings?: string[];
units?: string[];
improvements?: string[];
lenses?: string[];
mechanics?: Array<{ key: string; label: string; value?: number }>;
}
export interface TreeItem {
id: string;
name: string;
description: string;
/** Tab key — must match a CategoryDef.id. */
category: string;
era: number;
tier: number;
cost: number;
requires: string[];
unlocks: TreeUnlocks;
flavor?: string;
}
export interface TreeEra {
id: string;
display_name: string;
trigger: { type: string; required_techs?: number };
}
export interface CategoryDef {
/** Raw value matched against TreeItem.category. The "All" sentinel uses id="All". */
id: string;
/** Human-friendly tab label. */
label: string;
/** Hex color string for headers/pills. */
color: string;
}
/** Sentinel category id for the "show everything" tab. */
export const ALL_CATEGORY_ID = "All";

View file

@ -417,19 +417,31 @@ export function TechTreePage(): React.ReactElement {
</TabBar>
<EraBar>
{ERAS.map((era, i) => (
<EraCell key={era.id}>
<EraNum>Era {i + 1}</EraNum>
<EraName>{era.display_name}</EraName>
<EraThreshold>
{era.trigger.type === "game_start"
? "starts game"
: era.trigger.required_techs !== undefined
? `${era.trigger.required_techs} techs`
: era.trigger.type}
</EraThreshold>
</EraCell>
))}
{ERAS.map((era, i) => {
const eraNum = i + 1;
const inEra = ALL_TECHS.filter(tk => tk.era === eraNum).length;
const matchInEra = activeTab === "All"
? null
: ALL_TECHS.filter(tk => tk.era === eraNum && tk.domain === activeTab).length;
const unlockLabel =
era.trigger.type === "game_start"
? "starts game"
: era.trigger.required_techs !== undefined
? `unlock: ${era.trigger.required_techs} researched`
: era.trigger.type;
return (
<EraCell key={era.id}>
<EraNum>Era {eraNum}</EraNum>
<EraName>{era.display_name}</EraName>
<EraThreshold>
{matchInEra !== null
? `${matchInEra} of ${inEra} in this tab`
: `${inEra} techs in era`}
</EraThreshold>
<EraThreshold>{unlockLabel}</EraThreshold>
</EraCell>
);
})}
</EraBar>
<TreeScroll>

View file

@ -218,16 +218,21 @@ fn pick_for_city(
let dominance_gold_floor_t = super::thresholds::dominance_gold_floor(strategic_axes);
let capital_walls_min_age_turns_t =
super::thresholds::capital_walls_min_age_turns(strategic_axes);
// Tier-progression unit selection (p0-39). Highest-tier buildable military
// unit for this player; falls back to `warrior` when the catalog is empty
// (fixtures predating p0-39) or no higher-tier gate is met.
let melee_id = pick_best_melee(
// Tier-progression unit selection (p0-39 + p1-36 cycle 3 dispatcher).
// The dispatcher routes by clan archetype: Deepforge → siege/walker first,
// Goldvein → ranged first, etc. Returns an affinity-matched unit when one
// is buildable in the preferred type; otherwise falls back to the legacy
// melee picker. Final fallback is `ids::WARRIOR` when the catalog is empty.
//
// The variable name stays `melee_id` for diff stability — callsites in this
// function consume "the chosen military unit", not literally a melee unit.
let melee_id = pick_best_unit_for_clan(
&player.clan_id,
&player.researched_techs,
player.race_id.as_deref(),
&player.strategic_resources,
&city.buildings,
unit_catalog,
&player.clan_id,
).unwrap_or(ids::WARRIOR);
let early_mil_floor = if turn <= EARLY_MIL_FLOOR_CUTOFF_TURN {
EARLY_MIL_FLOOR
@ -340,12 +345,13 @@ fn pick_for_city(
ids::WORKER.into()
}
/// Highest-tier buildable melee-military unit given player tech, race,
/// strategic resources, and city buildings.
/// Highest-tier buildable unit of a specific `unit_type` given player tech,
/// race, strategic resources, and city buildings.
///
/// Filter rules (all AND'd):
/// 1. `spec.unit_type == "melee"` — taxonomy carries the role directly post
/// 1. `spec.unit_type == unit_type` — taxonomy carries the role directly post
/// p1-44 reclassification (was `"military"` + id-substring heuristic before).
/// Common values: `"melee"`, `"siege"`, `"ranged"`, `"support"`. (p1-36 cycle 3)
/// 2. `spec.tech_required` is `None` OR present in `researched_techs`.
/// 3. `spec.race_required` is `None` OR matches `race_id`.
/// 4. `spec.requires_resource` is `None` OR present in `strategic_resources`.
@ -358,9 +364,10 @@ fn pick_for_city(
/// `.local/iter/apricot-20260418_174322/` — see p0-39).
///
/// Returns the qualifying unit with highest `tier`. Ties broken by id sort
/// order for determinism. `None` when the catalog is empty (pre-p0-39
/// back-compat) → caller falls back to `ids::WARRIOR`.
fn pick_best_melee<'a>(
/// order for determinism. `None` when the catalog has no eligible unit of
/// that type (caller falls back to a different `unit_type` or `ids::WARRIOR`).
fn pick_best_unit_of_type<'a>(
unit_type: &str,
researched_techs: &[String],
race_id: Option<&str>,
strategic_resources: &[String],
@ -370,7 +377,7 @@ fn pick_best_melee<'a>(
) -> Option<&'a str> {
catalog
.iter()
.filter(|u| u.unit_type == "melee")
.filter(|u| u.unit_type == unit_type)
.filter(|u| match &u.tech_required {
None => true,
Some(tech) => researched_techs.iter().any(|t| t == tech),
@ -398,6 +405,100 @@ fn pick_best_melee<'a>(
.map(|u| u.id.as_str())
}
/// Backward-compatible alias for `pick_best_unit_of_type("melee", ...)`. Used
/// by all internal callers that want the legacy "highest-tier melee" pick.
/// Tests use this to verify melee-specific behaviour without coupling to the
/// dispatcher's unit-type selection logic.
fn pick_best_melee<'a>(
researched_techs: &[String],
race_id: Option<&str>,
strategic_resources: &[String],
city_buildings: &[String],
catalog: &'a [super::state::TacticalUnitSpec],
clan_id: &str,
) -> Option<&'a str> {
pick_best_unit_of_type(
"melee",
researched_techs,
race_id,
strategic_resources,
city_buildings,
catalog,
clan_id,
)
}
/// Personality-driven dispatcher: picks the highest-tier buildable military
/// unit, biased toward the unit_type the clan prefers. (p1-36 cycle 3)
///
/// Per-clan preference (siege vs ranged vs melee) inferred from
/// `ai_personalities.json` archetype + the `clan_affinity` declarations on
/// per-unit JSONs. Walker units are classified as `unit_type: "melee"` in the
/// data so they flow through the melee selector by tier.
///
/// Algorithm:
/// 1. Pick the clan's primary preferred type from a small priority list.
/// 2. If a unit is buildable in that type (affinity match required), return it.
/// 3. Else fall through to melee (the catch-all baseline).
/// 4. If even melee is empty, the caller falls back to `ids::WARRIOR`.
///
/// **Why prefer the clan's archetype-affinity unit when available?** Without
/// dispatch, every clan picks the highest-tier MELEE unit and converges on
/// `warrior` (or `pikeman` etc.) regardless of personality. The 2026-05-03
/// p1-36 batch showed 4/5 clans converging on warrior — this routes Deepforge
/// to siege/walker, Goldvein to ranged, etc. when those units are buildable.
fn pick_best_unit_for_clan<'a>(
clan_id: &str,
researched_techs: &[String],
race_id: Option<&str>,
strategic_resources: &[String],
city_buildings: &[String],
catalog: &'a [super::state::TacticalUnitSpec],
) -> Option<&'a str> {
// Per-clan preferred unit_type ladder. First entry is the clan's signature
// archetype; "melee" is the universal fallback. Order matters — the
// dispatcher returns the FIRST type for which a clan-affinity match
// exists, before falling back to a lower-priority type.
let preferences: &[&str] = match clan_id {
// Siege/walker focus per ai_personalities.json#deepforge.
"deepforge" => &["siege", "ranged", "melee"],
// Ranged/wealth focus per ai_personalities.json#goldvein.
"goldvein" => &["ranged", "melee", "siege"],
// Light melee + cavalry rush — melee primary, ranged secondary.
"blackhammer" => &["melee", "siege", "ranged"],
// Heavy melee (defenders, plated_warrior, mountain_king).
"ironhold" => &["melee", "siege", "ranged"],
// Mixed roster — try ranged first then melee.
"runesmith" => &["ranged", "melee", "siege"],
_ => &["melee", "siege", "ranged"],
};
// For each preferred type, only return a unit if the clan has a
// CLAN-AFFINITY match in that type. Generic warriors don't satisfy the
// archetype preference — they fall through so the next type can fire.
for &ut in preferences {
if let Some(uid) = pick_best_unit_of_type(
ut, researched_techs, race_id, strategic_resources, city_buildings, catalog, clan_id,
) {
// Confirm this is an affinity match for this clan; if it's a
// generic (score=1) or off-clan (score=0), keep looking — we'd
// rather find an archetype unit even if a generic of the
// preferred type exists.
if let Some(spec) = catalog.iter().find(|u| u.id == uid) {
if clan_affinity_score(spec, clan_id) == 2 {
return Some(uid);
}
}
}
}
// No affinity match in any preferred type. Fall back to the legacy
// melee picker, which returns the best (possibly generic) melee unit.
pick_best_melee(
researched_techs, race_id, strategic_resources, city_buildings, catalog, clan_id,
)
}
/// Returns 2 if the unit's clan_affinity contains the player's clan, 1 if the
/// affinity list is empty (generic unit, neutral fallback), 0 otherwise
/// (off-clan — still buildable but ranked below affinity matches and
@ -1230,4 +1331,144 @@ mod tests {
let s = state(99, 10, vec![player(0, "ironhold", Vec::new(), Vec::new())]);
assert!(decide_production(&s, &weights(), &mut rng(), None).is_empty());
}
// ── p1-36 cycle 3: pick_best_unit_for_clan dispatcher ─────────────────
/// Helper for cycle-3 dispatcher tests — builds a `TacticalUnitSpec` with
/// explicit unit_type AND clan_affinity, mirroring the runtime flow where
/// both fields come from per-unit JSON.
fn unit_with_type_and_affinity(
id: &str, tier: u32, tech: Option<&str>, unit_type: &str, clan_affinity: Vec<&str>,
) -> super::super::state::TacticalUnitSpec {
super::super::state::TacticalUnitSpec {
id: id.into(),
tier,
tech_required: tech.map(Into::into),
unit_type: unit_type.into(),
requires_resource: None,
race_required: None,
clan_affinity: clan_affinity.into_iter().map(String::from).collect(),
archetype: None,
requires_building: None,
}
}
/// pick_best_unit_of_type (siege) returns siege candidates only and is
/// blind to melee units, even when a higher-tier melee is buildable.
/// Pre-cycle-3, the AI was completely blind to siege units; this test
/// pins the new selector's discrimination by unit_type.
#[test]
fn pick_best_siege_returns_siege_unit_not_melee() {
let techs = vec!["mechanized_warfare".into()];
let catalog = vec![
// High-tier melee with affinity — would dominate pick_best_melee.
unit_with_type_and_affinity("steam_walker", 7, Some("steam_walkers"), "melee", vec!["deepforge"]),
// Siege with deepforge affinity — only candidate of unit_type=siege.
unit_with_type_and_affinity("forge_titan", 5, Some("mechanized_warfare"), "siege", vec!["deepforge"]),
// Filler: generic melee.
unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]),
];
// Player only researched mechanized_warfare; steam_walkers tech is gated out.
assert_eq!(
pick_best_unit_of_type("siege", &techs, None, &[], &[], &catalog, "deepforge"),
Some("forge_titan"),
"siege selector picks forge_titan not steam_walker (which is melee)"
);
}
/// Dispatcher routes Deepforge to siege when an affinity siege unit is
/// buildable, even if a generic melee (warrior) exists. This is the
/// critical p1-36 cycle 3 routing change.
#[test]
fn dispatcher_routes_deepforge_to_siege_over_generic_melee() {
let techs = vec!["mechanized_warfare".into()];
let catalog = vec![
unit_with_type_and_affinity("forge_titan", 5, Some("mechanized_warfare"), "siege", vec!["deepforge"]),
unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]),
];
assert_eq!(
pick_best_unit_for_clan("deepforge", &techs, None, &[], &[], &catalog),
Some("forge_titan"),
"deepforge dispatcher prefers siege-affinity forge_titan over generic warrior"
);
}
/// Dispatcher routes Goldvein to ranged when an affinity ranged unit is
/// buildable.
#[test]
fn dispatcher_routes_goldvein_to_ranged_over_generic_melee() {
let techs = vec!["gunpowder".into()];
let catalog = vec![
unit_with_type_and_affinity("hand_cannoneer", 4, Some("gunpowder"), "ranged", vec!["goldvein"]),
unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]),
];
assert_eq!(
pick_best_unit_for_clan("goldvein", &techs, None, &[], &[], &catalog),
Some("hand_cannoneer"),
"goldvein dispatcher prefers ranged-affinity over generic warrior"
);
}
/// Dispatcher falls back to melee when no affinity unit is buildable in
/// any preferred type. Critical regression guard — ensures cycle-3 doesn't
/// strand clans with no production when their archetype units are
/// tech-gated past current research.
#[test]
fn dispatcher_falls_back_to_melee_when_no_archetype_buildable() {
let techs = vec![]; // no tech researched
let catalog = vec![
// All affinity units gated by tech the player hasn't researched.
unit_with_type_and_affinity("forge_titan", 5, Some("mechanized_warfare"), "siege", vec!["deepforge"]),
unit_with_type_and_affinity("steam_walker", 7, Some("steam_walkers"), "melee", vec!["deepforge"]),
// Generic warrior — always buildable.
unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]),
];
assert_eq!(
pick_best_unit_for_clan("deepforge", &techs, None, &[], &[], &catalog),
Some("warrior"),
"deepforge falls back to generic warrior when affinity units are tech-gated"
);
}
/// Dispatcher prefers archetype affinity match over generic of the same
/// preferred type. If both a generic melee and an affinity melee exist,
/// the affinity one wins (existing p1-37 behavior, but now via dispatcher).
#[test]
fn dispatcher_prefers_affinity_over_generic_in_same_type() {
let techs = vec!["bronze_working".into()];
let catalog = vec![
unit_with_type_and_affinity("hearth_raider", 2, Some("bronze_working"), "melee", vec!["blackhammer"]),
unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]),
];
assert_eq!(
pick_best_unit_for_clan("blackhammer", &techs, None, &[], &[], &catalog),
Some("hearth_raider"),
"blackhammer dispatcher picks affinity hearth_raider over generic warrior"
);
}
/// Empty catalog → dispatcher returns None (caller falls back to WARRIOR).
/// Mirrors `pick_best_melee_falls_back_to_none_on_empty_catalog`.
#[test]
fn dispatcher_returns_none_on_empty_catalog() {
assert_eq!(
pick_best_unit_for_clan("deepforge", &[], None, &[], &[], &[]),
None,
);
}
/// Unknown clan_id → dispatcher uses default ladder ([melee, siege, ranged]).
/// No affinity match (unknown clan), so falls back to melee picker which
/// returns the highest-tier generic melee.
#[test]
fn dispatcher_unknown_clan_falls_back_via_default_ladder() {
let techs = vec![];
let catalog = vec![
unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]),
];
assert_eq!(
pick_best_unit_for_clan("unknown_clan", &techs, None, &[], &[], &catalog),
Some("warrior"),
);
}
}

View file

@ -94,7 +94,8 @@ MARKER="$PARENT/completion.marker"
MAP_SIZE="$MAP_SIZE" \
NUM_PLAYERS="$NUM_PLAYERS" \
PARALLEL="$PARALLEL" \
MCTS_DECISION_BUDGET_MS=2000 \
MCTS_DECISION_BUDGET_MS="${MCTS_DECISION_BUDGET_MS:-2000}" \
SAFETY_TIMEOUT_OVERRIDE="${SAFETY_TIMEOUT_OVERRIDE:-}" \
AI_USE_MCTS=true \
AI_PIN_PERSONALITY_P0=ironhold \
AI_PIN_PERSONALITY_P1=blackhammer \