Files
aetherbound-guild/runtime/p10_0/offer_generator.gd
T

514 lines
20 KiB
GDScript

extends RefCounted
const ContentRegistry = preload("res://p10_0/content_registry.gd")
const BASE_PROFESSION_IDS := [
"PF-B01", "PF-B02", "PF-B03", "PF-B04", "PF-B05", "PF-B06",
"PF-B07", "PF-B08", "PF-B09", "PF-B10", "PF-B11", "PF-B12",
]
const ROLE_BY_PROFESSION := {
"PF-B01": "front-hold",
"PF-B02": "single-pressure",
"PF-B03": "reach",
"PF-B04": "reorder",
"PF-B05": "projectile",
"PF-B06": "area",
"PF-B07": "restore",
"PF-B08": "enable",
"PF-B09": "construct",
"PF-B10": "impair",
"PF-B11": "tempo",
"PF-B12": "supply",
}
const PREFERRED_ATTRIBUTES := {
"PF-B01": ["vigor", "resolve"],
"PF-B02": ["force", "finesse"],
"PF-B03": ["finesse", "resolve"],
"PF-B04": ["finesse", "focus"],
"PF-B05": ["finesse", "focus"],
"PF-B06": ["force", "focus"],
"PF-B07": ["vigor", "focus"],
"PF-B08": ["resolve", "focus"],
"PF-B09": ["force", "resolve"],
"PF-B10": ["resolve", "focus"],
"PF-B11": ["finesse", "focus"],
"PF-B12": ["vigor", "resolve"],
}
const ATTRIBUTE_IDS := ["vigor", "force", "finesse", "resolve", "focus"]
const ATTRIBUTE_TEMPLATES := {
"Even": ["B", "B", "B", "C", "C"],
"Pointed": ["A", "B", "C", "C", "D"],
"Twin": ["A", "A", "C", "D", "D"],
"Durable": ["A", "B", "B", "C", "D"],
"Precise": ["A", "B", "B", "C", "D"],
"Volatile": ["S", "B", "D", "D", "D"],
}
const GIVEN_POOLS := [
["Aven", "Brin", "Cassa", "Dorel", "Eiren", "Fara", "Gannet", "Hessa", "Iven", "Jora", "Kelm", "Lio"],
["Marn", "Neris", "Orra", "Pella", "Quin", "Rusk", "Sella", "Tavin", "Ulan", "Vessa", "Wren", "Yori"],
]
const BYNAME_POOLS := [
["Amberfield", "Bellwake", "Coldmere", "Dawnreed", "Emberstep", "Faircurrent", "Glassfern", "Highspoke"],
["Ironleaf", "Juniper", "Kindler", "Larkfall", "Mossward", "Northcoil", "Oatbridge", "Pineglass"],
["Quillwater", "Rill", "Stonebloom", "Tidewell", "Umberlane", "Valehook", "Windrow", "Yarrow"],
]
const PRONOUN_SETS := ["they", "she", "he"]
const QUALITY_BONUS := {"Common": 0, "Uncommon": 1, "Rare": 2}
var _catalog: Dictionary
var _validation: Dictionary
func _init(catalog: Dictionary = {}) -> void:
_catalog = ContentRegistry.snapshot() if catalog.is_empty() else catalog.duplicate(true)
_validation = ContentRegistry.validate(_catalog)
func validation() -> Dictionary:
return _validation.duplicate(true)
func opening_offers(run_creation_draft: Dictionary, presentation_revision: int = 0) -> Dictionary:
if not bool(_validation.get("ok", false)):
return {"ok": false, "code": "invalid_catalog", "errors": _validation.get("errors", []).duplicate()}
var draft_error := _validate_opening_draft(run_creation_draft)
if not draft_error.is_empty():
return {"ok": false, "code": draft_error}
var run_seed := String(run_creation_draft.run_seed)
var offers: Array[Dictionary] = []
var used_equipment: Array[String] = []
var used_pairs: Array[String] = []
for offer_index in range(4):
var offer_id := _low128(_digest(run_seed, "opening-offer-id", [offer_index]))
var generation_seed := _low128(_digest(run_seed, "opening-recruit", [offer_index]))
var recruit_id := _low128(_digest(run_seed, "opening-recruit-instance", [offer_index]))
var record := _generate_recruit(
generation_seed,
recruit_id,
offer_id,
offer_index,
String(run_creation_draft.draft_id),
"opening",
int(run_creation_draft.get("region_floor", 1)),
run_creation_draft.role_weights,
used_equipment,
used_pairs,
presentation_revision
)
used_equipment.append(String(record.starting_equipment_id))
used_pairs.append("%s|%s" % [record.profession_id, record.trait_id])
offers.append(record)
var materialization := {
"draft_id": String(run_creation_draft.draft_id),
"run_seed": run_seed,
"contract_id": String(run_creation_draft.contract_id),
"offers": offers,
}
materialization["materialization_hash"] = _hash(materialization)
return {"ok": true, "code": "ok", "materialization": materialization, "offers": offers}
func market_board(run_seed: String, contract_id: String, market_index: int, refresh_index: int = 0, presentation_revision: int = 0) -> Dictionary:
if not bool(_validation.get("ok", false)):
return {"ok": false, "code": "invalid_catalog", "errors": _validation.get("errors", []).duplicate()}
if not _is_seed(run_seed):
return {"ok": false, "code": "invalid_run_seed"}
if contract_id not in ["CTR-R1-01", "CTR-R1-02"]:
return {"ok": false, "code": "unknown_contract"}
if market_index < 1 or market_index > 12:
return {"ok": false, "code": "invalid_market_index"}
if refresh_index < 0:
return {"ok": false, "code": "invalid_refresh_index"}
var role_weights := _default_role_weights(run_seed, contract_id, market_index)
var used_equipment: Array[String] = []
var used_pairs: Array[String] = []
var recruit_offers: Array[Dictionary] = []
for slot in range(3):
var offer_id := _low128(_digest(run_seed, "market-recruit-offer", [contract_id, market_index, refresh_index, slot]))
var generation_seed := _low128(_digest(run_seed, "market-recruit", [contract_id, market_index, refresh_index, slot]))
var recruit_id := _low128(_digest(run_seed, "market-recruit-instance", [contract_id, market_index, refresh_index, slot]))
var record := _generate_recruit(
generation_seed,
recruit_id,
offer_id,
slot,
"MKT-%s-%02d-R%02d" % [contract_id, market_index, refresh_index],
"market",
1 + int((market_index - 1) / 4),
role_weights,
used_equipment,
used_pairs,
presentation_revision
)
used_equipment.append(String(record.starting_equipment_id))
used_pairs.append("%s|%s" % [record.profession_id, record.trait_id])
recruit_offers.append(record)
var equipment_offers := _equipment_offers(run_seed, contract_id, market_index, refresh_index)
var battle_offers := _battle_offers(run_seed, contract_id, market_index)
var board := {
"board_id": _low128(_digest(run_seed, "market-board", [contract_id, market_index, refresh_index])),
"run_seed": run_seed,
"contract_id": contract_id,
"market_index": market_index,
"refresh_index": refresh_index,
"role_weights": role_weights,
"recruit_offers": recruit_offers,
"equipment_offers": equipment_offers,
"battle_offers": battle_offers,
}
board["board_hash"] = _hash(board)
return {"ok": true, "code": "ok", "board": board}
func _generate_recruit(generation_seed: String, recruit_id: String, offer_id: String, offer_index: int, source_id: String, source_kind: String, region_floor: int, role_weights: Dictionary, used_equipment: Array[String], used_pairs: Array[String], presentation_revision: int) -> Dictionary:
var profession_id := _profession_draw(generation_seed, role_weights)
var quality := _quality_draw(generation_seed, source_kind)
var template := _template_draw(generation_seed, quality, source_kind)
var attributes := _attribute_draw(generation_seed, profession_id, template)
var trait_id := _trait_draw(generation_seed, profession_id, used_pairs)
var level_span := 3
var level := maxi(1, region_floor - 1 + _index(generation_seed, "combat.level", level_span))
var equipment_id := _starting_equipment_draw(generation_seed, profession_id, used_equipment)
var name_tokens := _name_draw(generation_seed, presentation_revision)
var appearance_seed := _digest(generation_seed, "presentation.appearance", [presentation_revision])
var max_hp := 70 + 10 * _grade_value(String(attributes.vigor)) + 4 * (level - 1)
var record := {
"recruit_id": recruit_id,
"recruit_instance_id": recruit_id,
"generation_key": generation_seed,
"generation_seed": generation_seed,
"offer_index": offer_index,
"source_offer_id": offer_id,
"source_draft_id": source_id if source_kind == "opening" else "",
"market_id": source_id if source_kind == "market" else "",
"name_key": "%s.%s" % [name_tokens[0], name_tokens[1]],
"display_name_tokens": name_tokens,
"pronoun_set": PRONOUN_SETS[_index(generation_seed, "presentation.pronoun", PRONOUN_SETS.size(), [presentation_revision])],
"appearance_seed": appearance_seed,
"body_frame": _index(generation_seed, "presentation.body", 8, [presentation_revision]),
"face_shape": _index(generation_seed, "presentation.face", 12, [presentation_revision]),
"skin_palette": _index(generation_seed, "presentation.skin", 12, [presentation_revision]),
"hair_shape": _index(generation_seed, "presentation.hair_shape", 24, [presentation_revision]),
"hair_palette": _index(generation_seed, "presentation.hair_palette", 10, [presentation_revision]),
"garment_cut": _index(generation_seed, "presentation.garment_cut", 12, [presentation_revision]),
"garment_palette": _index(generation_seed, "presentation.garment_palette", 16, [presentation_revision]),
"profession_overlay": profession_id,
"detail_mark": _index(generation_seed, "presentation.detail_mark", 20, [presentation_revision]),
"profession_id": profession_id,
"base_profession_id": profession_id,
"trait_id": trait_id,
"attribute_template": template,
"attribute_rolls": attributes,
"attribute_vector": attributes.duplicate(true),
"quality_band": quality,
"shop_cost_tier": 1 + (level - 1) + int(QUALITY_BONUS[quality]),
"level": level,
"xp": 0,
"mastery_by_profession": {profession_id: 0},
"starting_equipment_id": equipment_id,
"equipment_instance_id": _low128(_digest(generation_seed, "combat.equipment-instance", [equipment_id])),
"current_hp": max_hp,
"max_hp": max_hp,
"survived_battles": 0,
"knockouts": 0,
"scars": [],
"state": "provisional" if source_kind == "opening" else "market_offer",
"coin_price_paid": 0,
"duplicate_signature": _hash({"name": name_tokens, "appearance": appearance_seed, "profession": profession_id, "trait": trait_id, "attributes": attributes}),
}
record["canonical_output_hash"] = _hash(record)
return record
func _equipment_offers(run_seed: String, contract_id: String, market_index: int, refresh_index: int) -> Array[Dictionary]:
var equipment := ContentRegistry.rows_by_id(_catalog, "equipment")
var available: Array[String] = []
available.assign(equipment.keys())
available.sort()
var selected := _draw_unique(available, run_seed, "market-equipment", 4, [contract_id, market_index, refresh_index])
var offers: Array[Dictionary] = []
for slot in range(selected.size()):
var catalog_id: String = selected[slot]
var offer := {
"offer_id": _low128(_digest(run_seed, "market-equipment-offer", [contract_id, market_index, refresh_index, slot])),
"catalog_id": catalog_id,
"instance_id": _low128(_digest(run_seed, "market-equipment-instance", [contract_id, market_index, refresh_index, slot, catalog_id])),
"slot": slot,
"behavior_class": String(equipment[catalog_id].get("class", "")),
"semantic_tags": Array(equipment[catalog_id].semantic_tags).duplicate(),
}
offer["offer_hash"] = _hash(offer)
offers.append(offer)
return offers
func _battle_offers(run_seed: String, contract_id: String, market_index: int) -> Array[Dictionary]:
var enemy_rows := ContentRegistry.rows_by_id(_catalog, "enemies")
var ordinary: Array[String] = []
var elite: Array[String] = []
for enemy_id in enemy_rows:
if String(enemy_rows[enemy_id].get("class", "")) == "elite":
elite.append(enemy_id)
else:
ordinary.append(enemy_id)
ordinary.sort()
elite.sort()
var offers: Array[Dictionary] = []
for risk in range(3):
var enemy_count := 2 if risk == 0 else 3
var enemies := _draw_unique(ordinary, run_seed, "battle-enemy", enemy_count, [contract_id, market_index, risk])
if risk == 2:
enemies[-1] = elite[_index(run_seed, "battle-elite", elite.size(), [contract_id, market_index, risk])]
var offer := {
"offer_id": _low128(_digest(run_seed, "battle-offer", [contract_id, market_index, risk])),
"risk": risk,
"wave_count": risk + 2,
"enemy_ids": enemies,
"reward_tier": risk + 1,
"preview_tags": _battle_preview_tags(enemies, enemy_rows),
}
offer["offer_hash"] = _hash(offer)
offers.append(offer)
return offers
func _profession_draw(seed: String, role_weights: Dictionary) -> String:
var weighted: Array[String] = []
for profession_id in BASE_PROFESSION_IDS:
var role: String = ROLE_BY_PROFESSION[profession_id]
for _repeat in range(clampi(int(role_weights.get(role, 1)), 1, 9)):
weighted.append(profession_id)
return weighted[_index(seed, "combat.profession", weighted.size())]
func _quality_draw(seed: String, source_kind: String) -> String:
var roll := _index(seed, "combat.quality", 100)
if source_kind == "opening":
return "Common" if roll < 75 else "Uncommon"
if roll < 62:
return "Common"
return "Uncommon" if roll < 92 else "Rare"
func _template_draw(seed: String, quality: String, source_kind: String) -> String:
var legal := ["Even", "Pointed", "Durable", "Precise"]
if quality in ["Uncommon", "Rare"]:
legal.append("Twin")
if quality == "Rare" and source_kind != "opening":
legal.append("Volatile")
return legal[_index(seed, "combat.attribute-template", legal.size())]
func _attribute_draw(seed: String, profession_id: String, template: String) -> Dictionary:
var ordered: Array[String] = []
ordered.assign(ATTRIBUTE_IDS)
ordered.sort_custom(func(left: String, right: String):
return _digest(seed, "combat.attribute-order", [left]) < _digest(seed, "combat.attribute-order", [right])
)
var grades: Array = Array(ATTRIBUTE_TEMPLATES[template]).duplicate()
var result := {}
for index in range(ordered.size()):
result[ordered[index]] = grades[index]
var preferred: Array = PREFERRED_ATTRIBUTES[profession_id]
if String(result[preferred[0]]) == "D" and String(result[preferred[1]]) == "D":
var donor := ""
for attribute in ordered:
if String(result[attribute]) in ["S", "A", "B"]:
donor = attribute
break
if not donor.is_empty():
var target: String = preferred[_index(seed, "combat.attribute-preferred", preferred.size())]
var swap = result[target]
result[target] = result[donor]
result[donor] = swap
return result
func _trait_draw(seed: String, profession_id: String, used_pairs: Array[String]) -> String:
var traits := ContentRegistry.rows_by_id(_catalog, "traits")
var legal: Array[String] = []
for trait_id in traits:
if not String(traits[trait_id].get("prohibited", "")).contains(profession_id):
legal.append(trait_id)
legal.sort()
var ranked := _ranked_draws(legal, seed, "combat.trait")
for trait_id in ranked:
if "%s|%s" % [profession_id, trait_id] not in used_pairs:
return trait_id
return ranked[0]
func _starting_equipment_draw(seed: String, profession_id: String, used_equipment: Array[String]) -> String:
var equipment := ContentRegistry.rows_by_id(_catalog, "equipment")
var legal: Array[String] = []
for item_id in equipment:
if _equipment_legal(equipment[item_id], profession_id):
legal.append(item_id)
legal.sort()
var ranked := _ranked_draws(legal, seed, "combat.starting-equipment")
for item_id in ranked:
if item_id not in used_equipment:
return item_id
return ranked[0]
func _equipment_legal(item: Dictionary, profession_id: String) -> bool:
var fit := String(item.get("fit", "")).to_lower()
if fit.contains("any"):
return true
var profession = ContentRegistry.rows_by_id(_catalog, "professions").get(profession_id, {})
var role := String(profession.get("role", "")).to_lower()
for position in ["front", "center", "rear"]:
if fit.contains(position) and role.contains(position):
return true
var tags: Array = profession.get("semantic_tags", [])
for tag in ["restore", "enable", "construct", "impair", "reorder", "area", "pressure", "control"]:
if fit.contains(tag) and tag in tags:
return true
if fit.contains("support") and ("restore" in tags or "enable" in tags or "supply" in tags):
return true
if fit.contains("reach 2") and profession_id in ["PF-B03", "PF-B05", "PF-B06", "PF-B10"]:
return true
return false
func _name_draw(seed: String, presentation_revision: int) -> Array[String]:
var given_pool_index := _index(seed, "presentation.given-pool", GIVEN_POOLS.size(), [presentation_revision])
var given_index := _index(seed, "presentation.given", GIVEN_POOLS[given_pool_index].size(), [presentation_revision, given_pool_index])
var byname_pool_index := _index(seed, "presentation.byname-pool", BYNAME_POOLS.size(), [presentation_revision])
var byname_index := _index(seed, "presentation.byname", BYNAME_POOLS[byname_pool_index].size(), [presentation_revision, byname_pool_index])
return [
"name.given.%d.%d.%s" % [given_pool_index, given_index, GIVEN_POOLS[given_pool_index][given_index]],
"name.byname.%d.%d.%s" % [byname_pool_index, byname_index, BYNAME_POOLS[byname_pool_index][byname_index]],
]
func _default_role_weights(run_seed: String, contract_id: String, market_index: int) -> Dictionary:
var weights := {}
for role in ROLE_BY_PROFESSION.values():
weights[role] = 1
var emphasized: Array[String] = []
emphasized.assign(ROLE_BY_PROFESSION.values())
emphasized.sort()
for slot in range(3):
var role: String = emphasized[_index(run_seed, "market-role-weight", emphasized.size(), [contract_id, market_index, slot])]
weights[role] = int(weights[role]) + 1
return weights
func _battle_preview_tags(enemy_ids: Array[String], enemy_rows: Dictionary) -> Array[String]:
var tags: Array[String] = []
for enemy_id in enemy_ids:
for tag in enemy_rows[enemy_id].semantic_tags:
if tag not in tags:
tags.append(String(tag))
tags.sort()
return tags
func _validate_opening_draft(draft: Dictionary) -> String:
for field in ["draft_id", "run_seed", "contract_id", "difficulty", "ruleset", "content_revision", "role_weights"]:
if not draft.has(field):
return "missing_draft_field:%s" % field
if not bool(draft.get("seed_reservation_applied", false)) or not bool(draft.get("read_back_validated", false)):
return "seed_reservation_not_applied"
if String(draft.draft_id).is_empty() or not _is_seed(String(draft.run_seed)):
return "invalid_draft_identity"
if String(draft.contract_id) not in ["CTR-R1-01", "CTR-R1-02"]:
return "unknown_contract"
if String(draft.difficulty).is_empty() or String(draft.ruleset).is_empty():
return "invalid_run_rules"
if String(draft.content_revision) != String(_catalog.source.authority_revision):
return "content_revision_mismatch"
if not draft.role_weights is Dictionary:
return "invalid_role_weights"
var expected: Array = ROLE_BY_PROFESSION.values()
expected.sort()
var actual: Array = draft.role_weights.keys()
actual.sort()
if actual != expected:
return "role_weight_keys"
for role in expected:
if int(draft.role_weights[role]) < 1 or int(draft.role_weights[role]) > 9:
return "role_weight_value:%s" % role
return ""
func _draw_unique(values: Array[String], seed: String, label: String, count: int, parts: Array = []) -> Array[String]:
var ranked := values.duplicate()
ranked.sort_custom(func(left: String, right: String):
return _digest(seed, label, parts + [left]) < _digest(seed, label, parts + [right])
)
return ranked.slice(0, count)
func _ranked_draws(values: Array[String], seed: String, label: String) -> Array[String]:
var ranked := values.duplicate()
ranked.sort_custom(func(left: String, right: String):
return _digest(seed, label, [left]) < _digest(seed, label, [right])
)
return ranked
func _grade_value(grade: String) -> int:
return {"S": 5, "A": 4, "B": 3, "C": 2, "D": 1}.get(grade, 1)
func _index(seed: String, label: String, count: int, parts: Array = []) -> int:
if count <= 0:
return 0
return _hex_prefix_to_int(_digest(seed, label, parts).substr(0, 8)) % count
func _digest(seed: String, label: String, parts: Array = []) -> String:
var framed: Array[String] = [_frame(seed), _frame(label)]
for part in parts:
framed.append(_frame(str(part)))
return "|".join(framed).sha256_text()
func _frame(value: String) -> String:
return "%d:%s" % [value.length(), value]
func _low128(value: String) -> String:
return value.substr(0, 32)
func _hex_prefix_to_int(value: String) -> int:
var result := 0
for index in range(value.length()):
result = (result * 16 + "0123456789abcdef".find(value.substr(index, 1))) & 0x7fffffff
return result
func _is_seed(value: String) -> bool:
if value.length() not in [32, 64]:
return false
for index in range(value.length()):
if value.substr(index, 1) not in "0123456789abcdef":
return false
return true
func _hash(value: Variant) -> String:
return JSON.stringify(_canonical(value)).sha256_text()
func _canonical(value: Variant) -> Variant:
if value is Dictionary:
var result := {}
var keys: Array = value.keys()
keys.sort_custom(func(left, right): return String(left) < String(right))
for key in keys:
result[key] = _canonical(value[key])
return result
if value is Array:
var result: Array = []
for item in value:
result.append(_canonical(item))
return result
return value