feat(@projects): ✨ define tree component types
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c76edd4c2f
commit
2c2c1e4ef5
4 changed files with 329 additions and 26 deletions
49
.project/designs/app/src/components/tree/types.ts
Normal file
49
.project/designs/app/src/components/tree/types.ts
Normal 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";
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue