514 lines
20 KiB
GDScript
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
|