feat(p10.2): add three-round Siltwake arc
This commit is contained in:
@@ -42,6 +42,49 @@ func generate(run_seed: String, market_index: int, refresh_index: int = 0) -> Di
|
||||
return {"ok": true, "code": "bound_market_generated", "board": board}
|
||||
|
||||
|
||||
static func merge_locked_board(
|
||||
old_board: Dictionary,
|
||||
new_board: Dictionary,
|
||||
lock_ids: Array
|
||||
) -> Dictionary:
|
||||
var result := new_board.duplicate(true)
|
||||
for spec in [
|
||||
{
|
||||
"field": "recruit_offers",
|
||||
"id_field": "source_offer_id",
|
||||
"unique_field": "recruit_id",
|
||||
"count": 3,
|
||||
},
|
||||
{
|
||||
"field": "equipment_offers",
|
||||
"id_field": "offer_id",
|
||||
"unique_field": "catalog_id",
|
||||
"count": 4,
|
||||
},
|
||||
]:
|
||||
var locked_rows: Array = []
|
||||
for offer in old_board[spec.field]:
|
||||
if String(offer.get(spec.id_field, "")) in lock_ids:
|
||||
locked_rows.append(Dictionary(offer).duplicate(true))
|
||||
var merged: Array = locked_rows.duplicate(true)
|
||||
var used := {}
|
||||
for offer in merged:
|
||||
used[String(offer.get(spec.unique_field, ""))] = true
|
||||
for offer in result[spec.field]:
|
||||
var unique_id := String(offer.get(spec.unique_field, ""))
|
||||
if not used.has(unique_id) and merged.size() < int(spec.count):
|
||||
merged.append(Dictionary(offer).duplicate(true))
|
||||
used[unique_id] = true
|
||||
if merged.size() != int(spec.count):
|
||||
return {}
|
||||
result[spec.field] = merged
|
||||
result["carried_lock_ids"] = lock_ids.duplicate()
|
||||
result.erase("board_hash")
|
||||
result["board_hash"] = hash_value(result)
|
||||
var normalized = JSON.parse_string(JSON.stringify(result))
|
||||
return Dictionary(normalized).duplicate(true) if normalized is Dictionary else {}
|
||||
|
||||
|
||||
static func hash_value(value) -> String:
|
||||
return JSON.stringify(_canonical(value)).sha256_text()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func refresh_market(operation_id: String, expected_revision: int, expected_cost:
|
||||
)
|
||||
if not generated.ok:
|
||||
return _reject("market_generation_failed")
|
||||
var board := _merge_locked_board(
|
||||
var board := BoardService.merge_locked_board(
|
||||
Dictionary(state.current_run.market_board),
|
||||
Dictionary(generated.board),
|
||||
Array(state.current_run.locked_offer_ids)
|
||||
@@ -451,35 +451,6 @@ func commit_route(
|
||||
}, before)
|
||||
|
||||
|
||||
func _merge_locked_board(old_board: Dictionary, new_board: Dictionary, lock_ids: Array) -> Dictionary:
|
||||
var result := new_board.duplicate(true)
|
||||
for spec in [
|
||||
{"field": "recruit_offers", "id_field": "source_offer_id", "unique_field": "recruit_id", "count": 3},
|
||||
{"field": "equipment_offers", "id_field": "offer_id", "unique_field": "catalog_id", "count": 4},
|
||||
]:
|
||||
var locked_rows: Array = []
|
||||
for offer in old_board[spec.field]:
|
||||
if String(offer.get(spec.id_field, "")) in lock_ids:
|
||||
locked_rows.append(Dictionary(offer).duplicate(true))
|
||||
var merged: Array = locked_rows.duplicate(true)
|
||||
var used := {}
|
||||
for offer in merged:
|
||||
used[String(offer.get(spec.unique_field, ""))] = true
|
||||
for offer in result[spec.field]:
|
||||
var unique_id := String(offer.get(spec.unique_field, ""))
|
||||
if not used.has(unique_id) and merged.size() < int(spec.count):
|
||||
merged.append(Dictionary(offer).duplicate(true))
|
||||
used[unique_id] = true
|
||||
if merged.size() != int(spec.count):
|
||||
return {}
|
||||
result[spec.field] = merged
|
||||
result["carried_lock_ids"] = lock_ids.duplicate()
|
||||
result.erase("board_hash")
|
||||
result["board_hash"] = BoardService.hash_value(result)
|
||||
var normalized = JSON.parse_string(JSON.stringify(result))
|
||||
return Dictionary(normalized).duplicate(true) if normalized is Dictionary else {}
|
||||
|
||||
|
||||
func _fill_deployment() -> void:
|
||||
var deployed: Array = []
|
||||
for recruit_id in state.current_run.deployed_recruit_ids:
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
class_name P102MidContractBattleService
|
||||
extends RefCounted
|
||||
|
||||
const Content = preload("res://p10_2/content_registry.gd")
|
||||
const State = preload("res://p10_2/run_state.gd")
|
||||
|
||||
const PLAN_IDS := ["hold_line", "follow_tide", "focus_source", "guard_center"]
|
||||
const PREFERRED_PLAN := {
|
||||
"RO-R2-M02-Q0": "hold_line",
|
||||
"RO-R2-M02-Q1": "focus_source",
|
||||
"RO-R2-M02-Q2": "follow_tide",
|
||||
"RO-R2-M03-Q0": "hold_line",
|
||||
"RO-R2-M03-Q1": "focus_source",
|
||||
"RO-R2-M03-Q2": "guard_center",
|
||||
"RO-R2-M04-Q0": "follow_tide",
|
||||
"RO-R2-M04-Q1": "guard_center",
|
||||
"RO-R2-M04-Q2": "focus_source",
|
||||
}
|
||||
const SECONDARY_PLAN := {
|
||||
"RO-R2-M02-Q0": "guard_center",
|
||||
"RO-R2-M02-Q1": "guard_center",
|
||||
"RO-R2-M02-Q2": "focus_source",
|
||||
"RO-R2-M03-Q0": "focus_source",
|
||||
"RO-R2-M03-Q1": "hold_line",
|
||||
"RO-R2-M03-Q2": "follow_tide",
|
||||
"RO-R2-M04-Q0": "guard_center",
|
||||
"RO-R2-M04-Q1": "focus_source",
|
||||
"RO-R2-M04-Q2": "follow_tide",
|
||||
}
|
||||
|
||||
var source := State.new()
|
||||
var valid := false
|
||||
var error_code := "invalid_state"
|
||||
var catalog: Dictionary = Content.snapshot()
|
||||
var event_log: Array[Dictionary] = []
|
||||
var event_tick := 0
|
||||
|
||||
|
||||
func _init(snapshot_value: Dictionary) -> void:
|
||||
valid = source.restore_snapshot(snapshot_value)
|
||||
if valid and String(source.current_run.phase) == "prepared":
|
||||
error_code = ""
|
||||
else:
|
||||
valid = false
|
||||
error_code = "prepared_route_required"
|
||||
|
||||
|
||||
func run_to_terminal(plan_id: String) -> Dictionary:
|
||||
if not valid:
|
||||
return {"ok": false, "code": error_code}
|
||||
if plan_id not in PLAN_IDS:
|
||||
return {"ok": false, "code": "unknown_plan"}
|
||||
var run: Dictionary = source.current_run
|
||||
var route: Dictionary = run.pending_encounter
|
||||
var route_id := String(route.route_id)
|
||||
if not PREFERRED_PLAN.has(route_id):
|
||||
return {"ok": false, "code": "route_not_executable"}
|
||||
event_log = []
|
||||
event_tick = 0
|
||||
var preferred := String(PREFERRED_PLAN[route_id])
|
||||
var match_count := _equipment_match_count(run, route)
|
||||
var score := _response_score(run, route, plan_id, preferred, match_count)
|
||||
var outcome := "victory" if score >= 12 else "defeat"
|
||||
var actors := _actor_results(run, route, plan_id, preferred, match_count, outcome)
|
||||
var fallen_ids: Array = []
|
||||
for recruit_id in actors:
|
||||
if int(actors[recruit_id].hp) <= 0:
|
||||
fallen_ids.append(String(recruit_id))
|
||||
|
||||
_add_event("battle_started", String(run.run_id), route_id, {
|
||||
"round_index": int(route.round_index),
|
||||
"risk": int(route.risk),
|
||||
"plan_id": plan_id,
|
||||
"preferred_plan": preferred,
|
||||
})
|
||||
for wave in route.wave_order:
|
||||
_add_event("wave_entered", String(wave.enemy_id), "line", {
|
||||
"scheduled_tick": int(wave.tick),
|
||||
})
|
||||
_emit_route_mechanics(route, plan_id, preferred)
|
||||
_add_event("tide_shift_warning", "RG-02", "line", {
|
||||
"current_safe_index": int(route.tide_disclosure.current_safe_index),
|
||||
"next_safe_index": int(route.tide_disclosure.next_safe_index),
|
||||
"warning_ticks": int(route.tide_disclosure.warning_ticks),
|
||||
})
|
||||
_add_event("tide_shifted", "RG-02", "line", {
|
||||
"safe_index": int(route.tide_disclosure.next_safe_index),
|
||||
"followed": plan_id == "follow_tide",
|
||||
})
|
||||
for recruit_id in run.deployed_recruit_ids:
|
||||
_add_event("allied_basic", String(recruit_id), String(route.enemy_ids[0]), {
|
||||
"plan_id": plan_id,
|
||||
})
|
||||
var before_hp := int(run.recruit_records[recruit_id].current_hp)
|
||||
var after_hp := int(actors[recruit_id].hp)
|
||||
_add_event("authoritative_damage", String(route.enemy_ids[0]), String(recruit_id), {
|
||||
"amount": before_hp - after_hp,
|
||||
"hp_before": before_hp,
|
||||
"hp_after": after_hp,
|
||||
})
|
||||
if after_hp <= 0:
|
||||
_add_event("fallen", String(route.enemy_ids[-1]), String(recruit_id), {
|
||||
"permanent": true,
|
||||
})
|
||||
_add_event("battle_terminal", route_id, String(run.run_id), {
|
||||
"outcome": outcome,
|
||||
"score": score,
|
||||
"cause": "prepared_response" if outcome == "victory" else String(route.failure_cause),
|
||||
})
|
||||
|
||||
var first_decisive_event := {}
|
||||
for event in event_log:
|
||||
if bool(event.payload.get("decisive", false)):
|
||||
first_decisive_event = event.duplicate(true)
|
||||
break
|
||||
if first_decisive_event.is_empty():
|
||||
first_decisive_event = event_log[-1].duplicate(true)
|
||||
var snapshot := {
|
||||
"run_id": String(run.run_id),
|
||||
"contract_id": "CTR-R2-01",
|
||||
"region_id": "RG-02",
|
||||
"market_index": int(run.market_index),
|
||||
"round_index": int(route.round_index),
|
||||
"offer_id": String(route.offer_id),
|
||||
"route_id": route_id,
|
||||
"risk": int(route.risk),
|
||||
"plan_id": plan_id,
|
||||
"preferred_plan": preferred,
|
||||
"fixed_tick_ms": 100,
|
||||
"tick": event_tick,
|
||||
"terminal": true,
|
||||
"outcome": outcome,
|
||||
"cause": "prepared_response" if outcome == "victory" else String(route.failure_cause),
|
||||
"score": score,
|
||||
"equipment_matches": match_count,
|
||||
"actors": actors,
|
||||
"final_order": Array(run.deployed_recruit_ids).duplicate(),
|
||||
"fallen_recruit_ids": fallen_ids,
|
||||
"event_log": event_log.duplicate(true),
|
||||
"first_decisive_event": first_decisive_event,
|
||||
"reward_coin": int(route.reward_coin),
|
||||
"recovery_coin": int(route.recovery_coin),
|
||||
"xp_grant": 10 + 2 * int(route.round_index) + 4 * int(route.risk),
|
||||
"board_hash": String(route.board_hash),
|
||||
"checkpoint_id": String(run.pre_battle_checkpoint_id),
|
||||
}
|
||||
snapshot["event_log_hash"] = _hash(snapshot.event_log)
|
||||
snapshot["snapshot_hash"] = _hash(snapshot)
|
||||
return {"ok": true, "code": "battle_terminal", "snapshot": snapshot}
|
||||
|
||||
|
||||
func _response_score(
|
||||
run: Dictionary,
|
||||
route: Dictionary,
|
||||
plan_id: String,
|
||||
preferred: String,
|
||||
match_count: int
|
||||
) -> int:
|
||||
var score: int = 8 + 2 * run.deployed_recruit_ids.size()
|
||||
if plan_id == preferred:
|
||||
score += 10
|
||||
elif plan_id == String(SECONDARY_PLAN[String(route.route_id)]):
|
||||
score += 4
|
||||
else:
|
||||
score -= 2
|
||||
score += mini(6, 2 * match_count)
|
||||
score -= 4 * int(route.risk)
|
||||
score -= int(route.round_index) - 2
|
||||
var hp_ratio := 0.0
|
||||
for recruit_id in run.deployed_recruit_ids:
|
||||
var record: Dictionary = run.recruit_records[recruit_id]
|
||||
hp_ratio += float(record.current_hp) / float(record.max_hp)
|
||||
hp_ratio /= float(run.deployed_recruit_ids.size())
|
||||
if hp_ratio < 0.5:
|
||||
score -= 3
|
||||
return score
|
||||
|
||||
|
||||
func _equipment_match_count(run: Dictionary, route: Dictionary) -> int:
|
||||
var equipment := Content.rows_by_id(catalog, "equipment")
|
||||
var route_tags: Array = route.response_tags
|
||||
var matched := 0
|
||||
for recruit_id in run.deployed_recruit_ids:
|
||||
var instance_id := String(run.equipped_by_recruit.get(recruit_id, ""))
|
||||
var catalog_id := String(run.inventory.get(instance_id, {}).get("catalog_id", ""))
|
||||
if not equipment.has(catalog_id):
|
||||
continue
|
||||
var tags: Array = equipment[catalog_id].response_tags
|
||||
if tags.any(func(tag): return tag in route_tags):
|
||||
matched += 1
|
||||
return matched
|
||||
|
||||
|
||||
func _actor_results(
|
||||
run: Dictionary,
|
||||
route: Dictionary,
|
||||
plan_id: String,
|
||||
preferred: String,
|
||||
match_count: int,
|
||||
outcome: String
|
||||
) -> Dictionary:
|
||||
var actors := {}
|
||||
var base_damage := 6 + 3 * int(route.risk) + int(route.round_index)
|
||||
var mitigation := (4 if plan_id == preferred else 0) + mini(3, match_count)
|
||||
for index in range(run.deployed_recruit_ids.size()):
|
||||
var recruit_id := String(run.deployed_recruit_ids[index])
|
||||
var record: Dictionary = run.recruit_records[recruit_id]
|
||||
var damage := maxi(1, base_damage + index - mitigation)
|
||||
var hp := maxi(1, int(record.current_hp) - damage)
|
||||
actors[recruit_id] = {
|
||||
"id": recruit_id,
|
||||
"hp_before": int(record.current_hp),
|
||||
"hp": hp,
|
||||
"max_hp": int(record.max_hp),
|
||||
"profession_id": String(record.profession_id),
|
||||
}
|
||||
if outcome == "defeat" and run.deployed_recruit_ids.size() > 1:
|
||||
var fallen_id := String(run.deployed_recruit_ids[-1])
|
||||
actors[fallen_id].hp = 0
|
||||
return actors
|
||||
|
||||
|
||||
func _emit_route_mechanics(route: Dictionary, plan_id: String, preferred: String) -> void:
|
||||
var enemies: Array = route.enemy_ids
|
||||
if "EN-R2-08" in enemies:
|
||||
_add_event("drownseed_placed", "EN-R2-08", "defeating_ally", {
|
||||
"cadences_remaining": 2,
|
||||
})
|
||||
_add_event("drownseed_evaded" if plan_id == "follow_tide" else "drownseed_burst", "EN-R2-08", "line", {
|
||||
"decisive": true,
|
||||
"moved": plan_id == "follow_tide",
|
||||
})
|
||||
if "EN-R2-09" in enemies:
|
||||
_add_event("mire_medic_heal", "EN-R2-09", String(enemies[0]), {
|
||||
"hindrance_shortened": true,
|
||||
})
|
||||
_add_event("source_interrupted" if plan_id == "focus_source" else "enemy_sustain_resolved", "line", "EN-R2-09", {
|
||||
"decisive": true,
|
||||
"focused": plan_id == "focus_source",
|
||||
})
|
||||
if "EN-R2-10" in enemies:
|
||||
_add_event("fogjaw_hidden", "EN-R2-10", "line", {"minimum_reach": 1})
|
||||
_add_event("fogjaw_center_attack", "EN-R2-10", "center_band", {
|
||||
"revealed_for_cadences": 1,
|
||||
})
|
||||
_add_event("center_guarded" if plan_id == "guard_center" else "center_exposed", "line", "EN-R2-10", {
|
||||
"decisive": true,
|
||||
"guarded": plan_id == "guard_center",
|
||||
})
|
||||
if "EN-R2-01" in enemies:
|
||||
_add_event("rear_pull", "EN-R2-01", "rear", {
|
||||
"resisted": plan_id == "hold_line",
|
||||
"decisive": String(route.route_id) == "RO-R2-M02-Q0",
|
||||
})
|
||||
if "EN-R2-02" in enemies:
|
||||
_add_event("healing_denial_attached", "EN-R2-02", "lowest_hp", {
|
||||
"removed": plan_id == "focus_source",
|
||||
"decisive": true,
|
||||
})
|
||||
if "EN-R2-03" in enemies:
|
||||
_add_event("mirrored_target_selected", "EN-R2-03", "line", {
|
||||
"guarded": plan_id in ["hold_line", "guard_center"],
|
||||
})
|
||||
if "EN-R2-04" in enemies:
|
||||
_add_event("indexed_mud_marked", "EN-R2-04", "safe_index", {
|
||||
"crossed": plan_id == "follow_tide",
|
||||
})
|
||||
if "EN-R2-05" in enemies:
|
||||
_add_event("enemy_order_changed", "EN-R2-05", "enemy_front", {
|
||||
"interrupted": plan_id == "focus_source",
|
||||
})
|
||||
if "EN-R2-06" in enemies:
|
||||
_add_event("timed_hindrance_midpoint", "EN-R2-06", "line", {
|
||||
"waited": plan_id == "hold_line",
|
||||
"decisive": true,
|
||||
})
|
||||
if "EN-R2-07" in enemies:
|
||||
_add_event("ferry_crab_reordered", "EN-R2-07", "enemy_rear", {
|
||||
"source_focused": plan_id == "focus_source",
|
||||
})
|
||||
if plan_id == preferred:
|
||||
_add_event("prepared_response_succeeded", "line", String(route.route_id), {
|
||||
"decisive": true,
|
||||
"plan_id": plan_id,
|
||||
})
|
||||
|
||||
|
||||
func _add_event(kind: String, source_id: String, target_id: String, payload: Dictionary) -> void:
|
||||
var event := {
|
||||
"index": event_log.size(),
|
||||
"tick": event_tick,
|
||||
"kind": kind,
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"payload": payload.duplicate(true),
|
||||
}
|
||||
event_log.append(event)
|
||||
event_tick += 4
|
||||
|
||||
|
||||
func _hash(value) -> String:
|
||||
return JSON.stringify(_canonical(value)).sha256_text()
|
||||
|
||||
|
||||
func _canonical(value):
|
||||
if value is float and is_equal_approx(value, round(value)):
|
||||
return int(round(value))
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2v7m5q1n9h4r
|
||||
@@ -0,0 +1,453 @@
|
||||
class_name P102MidContractResolutionService
|
||||
extends RefCounted
|
||||
|
||||
const BoardService = preload("res://p10_2/market_board_service.gd")
|
||||
const State = preload("res://p10_2/run_state.gd")
|
||||
|
||||
const MAX_CREDIT := 54
|
||||
|
||||
var state := State.new()
|
||||
var boards = BoardService.new()
|
||||
var valid := false
|
||||
var error_code := "invalid_state"
|
||||
|
||||
|
||||
func _init(snapshot_value: Dictionary) -> void:
|
||||
valid = state.restore_snapshot(snapshot_value)
|
||||
if valid and String(state.current_run.phase) in ["prepared", "recovery"]:
|
||||
error_code = ""
|
||||
else:
|
||||
valid = false
|
||||
error_code = "prepared_or_recovery_required"
|
||||
|
||||
|
||||
static func xp_to_next(level: int) -> int:
|
||||
return 20 + 10 * level
|
||||
|
||||
|
||||
static func battle_xp(round_index: int, risk: int) -> int:
|
||||
return 10 + 2 * round_index + 4 * risk
|
||||
|
||||
|
||||
static func death_credit(original_list_price: int, coin_price_paid: int) -> int:
|
||||
return mini(original_list_price, 10 + floori(float(coin_price_paid) / 2.0))
|
||||
|
||||
|
||||
func settle(
|
||||
operation_id: String,
|
||||
expected_revision: int,
|
||||
battle_snapshot: Dictionary
|
||||
) -> Dictionary:
|
||||
var intent := {
|
||||
"expected_revision": expected_revision,
|
||||
"snapshot_hash": String(battle_snapshot.get("snapshot_hash", "")),
|
||||
}
|
||||
var prior := _prior_operation(operation_id, "settle_mid_round", intent)
|
||||
if prior.known:
|
||||
return prior.result
|
||||
if not valid:
|
||||
return _reject(error_code)
|
||||
if expected_revision != state.revision:
|
||||
return _reject("stale_revision")
|
||||
if String(state.current_run.phase) != "prepared":
|
||||
return _reject("prepared_route_required")
|
||||
var validation := _validate_battle_snapshot(battle_snapshot)
|
||||
if not validation.ok:
|
||||
return _reject(String(validation.code))
|
||||
var before := state.snapshot()
|
||||
var checkpoint_id := String(battle_snapshot.checkpoint_id)
|
||||
var checkpoint: Dictionary = state.battle_checkpoints[checkpoint_id]
|
||||
state.current_run = Dictionary(checkpoint.run).duplicate(true)
|
||||
var outcome := String(battle_snapshot.outcome)
|
||||
var casualties := _apply_actor_results(battle_snapshot)
|
||||
var xp_award := int(battle_snapshot.xp_grant) if outcome == "victory" else floori(0.5 * float(battle_snapshot.xp_grant))
|
||||
var growth_rows: Array = []
|
||||
for recruit_id in battle_snapshot.final_order:
|
||||
if recruit_id in state.current_run.company:
|
||||
growth_rows.append(_apply_survivor_growth(
|
||||
String(recruit_id),
|
||||
int(battle_snapshot.actors[recruit_id].hp),
|
||||
xp_award
|
||||
))
|
||||
state.current_run.last_battle_report = {
|
||||
"battle_hash": String(battle_snapshot.snapshot_hash),
|
||||
"round_index": int(battle_snapshot.round_index),
|
||||
"route_id": String(battle_snapshot.route_id),
|
||||
"plan_id": String(battle_snapshot.plan_id),
|
||||
"outcome": outcome,
|
||||
"first_decisive_event": Dictionary(battle_snapshot.first_decisive_event).duplicate(true),
|
||||
"fallen_recruit_ids": Array(battle_snapshot.fallen_recruit_ids).duplicate(),
|
||||
"tick": int(battle_snapshot.tick),
|
||||
"xp_award": xp_award,
|
||||
}
|
||||
var receipt := {
|
||||
"operation_id": operation_id,
|
||||
"battle_hash": String(battle_snapshot.snapshot_hash),
|
||||
"round_index": int(battle_snapshot.round_index),
|
||||
"route_id": String(battle_snapshot.route_id),
|
||||
"outcome": outcome,
|
||||
"casualties": casualties,
|
||||
"growth": growth_rows,
|
||||
"xp_award": xp_award,
|
||||
}
|
||||
if outcome == "victory":
|
||||
var reward_coin := int(battle_snapshot.reward_coin)
|
||||
state.current_run.coin = int(state.current_run.coin) + reward_coin
|
||||
state.current_run.cleared_rounds = int(state.current_run.cleared_rounds) + 1
|
||||
state.current_run.route_history.append({
|
||||
"round_index": int(battle_snapshot.round_index),
|
||||
"route_id": String(battle_snapshot.route_id),
|
||||
"plan_id": String(battle_snapshot.plan_id),
|
||||
"outcome": outcome,
|
||||
"battle_hash": String(battle_snapshot.snapshot_hash),
|
||||
"reward_coin": reward_coin,
|
||||
})
|
||||
var next_market := int(state.current_run.market_index) + 1
|
||||
state.current_run.market_index = next_market
|
||||
state.current_run.round_index = next_market
|
||||
state.current_run.line_capacity = mini(
|
||||
10,
|
||||
4 + floori(float(state.current_run.cleared_rounds) / 2.0)
|
||||
)
|
||||
state.current_run.refresh_count = 0
|
||||
state.current_run.selected_route_id = ""
|
||||
state.current_run.pending_encounter = {}
|
||||
state.current_run.pre_battle_checkpoint_id = ""
|
||||
state.current_run.recovery_required = false
|
||||
state.current_run.recovery_cost = 0
|
||||
if next_market <= 4:
|
||||
var generated := boards.generate(String(state.current_run.run_seed), next_market, 0)
|
||||
if not generated.ok:
|
||||
state.restore_snapshot(before)
|
||||
return _reject("next_market_generation_failed")
|
||||
var next_board := BoardService.merge_locked_board(
|
||||
Dictionary(checkpoint.run.market_board),
|
||||
Dictionary(generated.board),
|
||||
Array(state.current_run.locked_offer_ids)
|
||||
)
|
||||
if next_board.is_empty():
|
||||
state.restore_snapshot(before)
|
||||
return _reject("next_market_lock_merge_failed")
|
||||
state.current_run.phase = "market"
|
||||
state.current_run.market_board = next_board
|
||||
state.current_run.available_recruit_offer_ids = _offer_ids(next_board.recruit_offers, "source_offer_id")
|
||||
state.current_run.available_equipment_offer_ids = _offer_ids(next_board.equipment_offers, "offer_id")
|
||||
state.current_run.available_battle_offer_ids = _offer_ids(next_board.battle_offers, "offer_id")
|
||||
else:
|
||||
var settlement_hash := BoardService.hash_value({
|
||||
"battle_hash": String(battle_snapshot.snapshot_hash),
|
||||
"cleared_rounds": int(state.current_run.cleared_rounds),
|
||||
"route_history": Array(state.current_run.route_history).duplicate(true),
|
||||
})
|
||||
var handoff := {
|
||||
"handoff_kind": "p10.2.market_5_continuation",
|
||||
"run_seed": String(state.current_run.run_seed),
|
||||
"contract_id": "CTR-R2-01",
|
||||
"region_id": "RG-02",
|
||||
"market_index": 5,
|
||||
"cleared_rounds": 4,
|
||||
"line_capacity": 6,
|
||||
"source_settlement_hash": settlement_hash,
|
||||
"carried_lock_ids": Array(state.current_run.locked_offer_ids).duplicate(),
|
||||
}
|
||||
handoff["board_hash"] = BoardService.hash_value(handoff)
|
||||
state.current_run.phase = "market_5"
|
||||
state.current_run.market_board = handoff
|
||||
state.current_run.available_recruit_offer_ids = []
|
||||
state.current_run.available_equipment_offer_ids = []
|
||||
state.current_run.available_battle_offer_ids = []
|
||||
state.current_run.locked_offer_ids = []
|
||||
state.current_run.reed_ferry_restored = true
|
||||
state.battle_checkpoints.erase(checkpoint_id)
|
||||
receipt["reward_coin"] = reward_coin
|
||||
receipt["coin"] = int(state.current_run.coin)
|
||||
receipt["market_index"] = next_market
|
||||
receipt["cleared_rounds"] = int(state.current_run.cleared_rounds)
|
||||
receipt["line_capacity"] = int(state.current_run.line_capacity)
|
||||
receipt["reed_ferry_restored"] = bool(state.current_run.reed_ferry_restored)
|
||||
else:
|
||||
state.current_run.phase = "recovery"
|
||||
state.current_run.selected_route_id = ""
|
||||
state.current_run.pending_encounter = {}
|
||||
state.current_run.pre_battle_checkpoint_id = checkpoint_id
|
||||
state.current_run.recovery_required = true
|
||||
state.current_run.recovery_cost = int(battle_snapshot.recovery_coin)
|
||||
receipt["recovery_cost"] = int(state.current_run.recovery_cost)
|
||||
receipt["replacement_credit"] = int(state.current_run.replacement_credit)
|
||||
return _commit(
|
||||
operation_id,
|
||||
"settle_mid_round",
|
||||
intent,
|
||||
"mid_round_settled",
|
||||
receipt,
|
||||
before
|
||||
)
|
||||
|
||||
|
||||
func recover(
|
||||
operation_id: String,
|
||||
expected_revision: int,
|
||||
expected_battle_hash: String,
|
||||
expected_cost: int
|
||||
) -> Dictionary:
|
||||
var intent := {
|
||||
"expected_revision": expected_revision,
|
||||
"expected_battle_hash": expected_battle_hash,
|
||||
"expected_cost": expected_cost,
|
||||
}
|
||||
var prior := _prior_operation(operation_id, "recover_mid_round", intent)
|
||||
if prior.known:
|
||||
return prior.result
|
||||
if not valid:
|
||||
return _reject(error_code)
|
||||
if expected_revision != state.revision:
|
||||
return _reject("stale_revision")
|
||||
if String(state.current_run.phase) != "recovery" or not bool(state.current_run.recovery_required):
|
||||
return _reject("recovery_not_required")
|
||||
if expected_battle_hash != String(state.current_run.last_battle_report.get("battle_hash", "")):
|
||||
return _reject("battle_hash_mismatch")
|
||||
if expected_cost != int(state.current_run.recovery_cost):
|
||||
return _reject("recovery_cost_mismatch")
|
||||
if int(state.current_run.coin) < expected_cost:
|
||||
return _reject("insufficient_coin")
|
||||
var before := state.snapshot()
|
||||
state.current_run.coin = int(state.current_run.coin) - expected_cost
|
||||
for recruit_id in state.current_run.company:
|
||||
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
||||
record.current_hp = maxi(
|
||||
int(record.current_hp),
|
||||
ceili(0.4 * float(record.max_hp))
|
||||
)
|
||||
state.current_run.phase = "market"
|
||||
state.current_run.recovery_required = false
|
||||
state.current_run.recovery_cost = 0
|
||||
state.current_run.selected_route_id = ""
|
||||
state.current_run.pending_encounter = {}
|
||||
var checkpoint_id := String(state.current_run.pre_battle_checkpoint_id)
|
||||
state.current_run.pre_battle_checkpoint_id = ""
|
||||
state.battle_checkpoints.erase(checkpoint_id)
|
||||
return _commit(operation_id, "recover_mid_round", intent, "mid_round_recovered", {
|
||||
"operation_id": operation_id,
|
||||
"battle_hash": expected_battle_hash,
|
||||
"coin_cost": expected_cost,
|
||||
"coin": int(state.current_run.coin),
|
||||
"replacement_credit": int(state.current_run.replacement_credit),
|
||||
"surviving_recruit_ids": Array(state.current_run.company).duplicate(),
|
||||
}, before)
|
||||
|
||||
|
||||
func _validate_battle_snapshot(snapshot_value: Dictionary) -> Dictionary:
|
||||
for key in [
|
||||
"run_id", "contract_id", "region_id", "market_index", "round_index",
|
||||
"offer_id", "route_id", "risk", "plan_id", "fixed_tick_ms", "tick",
|
||||
"terminal", "outcome", "actors", "final_order", "fallen_recruit_ids",
|
||||
"event_log", "first_decisive_event", "reward_coin", "recovery_coin",
|
||||
"xp_grant", "board_hash", "checkpoint_id", "event_log_hash",
|
||||
"snapshot_hash",
|
||||
]:
|
||||
if not snapshot_value.has(key):
|
||||
return {"ok": false, "code": "battle_field_missing:%s" % key}
|
||||
if not bool(snapshot_value.terminal) or String(snapshot_value.outcome) not in ["victory", "defeat"]:
|
||||
return {"ok": false, "code": "battle_not_terminal"}
|
||||
var hashed := snapshot_value.duplicate(true)
|
||||
var snapshot_hash := String(hashed.snapshot_hash)
|
||||
hashed.erase("snapshot_hash")
|
||||
if String(hashed.event_log_hash) != BoardService.hash_value(hashed.event_log):
|
||||
return {"ok": false, "code": "event_log_hash_mismatch"}
|
||||
if snapshot_hash != BoardService.hash_value(hashed):
|
||||
return {"ok": false, "code": "battle_hash_mismatch"}
|
||||
var run: Dictionary = state.current_run
|
||||
var encounter: Dictionary = run.pending_encounter
|
||||
if (
|
||||
String(snapshot_value.run_id) != String(run.run_id)
|
||||
or String(snapshot_value.contract_id) != "CTR-R2-01"
|
||||
or String(snapshot_value.region_id) != "RG-02"
|
||||
or int(snapshot_value.market_index) != int(run.market_index)
|
||||
or int(snapshot_value.round_index) != int(run.round_index)
|
||||
):
|
||||
return {"ok": false, "code": "battle_run_mismatch"}
|
||||
if (
|
||||
String(snapshot_value.offer_id) != String(encounter.offer_id)
|
||||
or String(snapshot_value.route_id) != String(encounter.route_id)
|
||||
or int(snapshot_value.risk) != int(encounter.risk)
|
||||
or String(snapshot_value.board_hash) != String(encounter.board_hash)
|
||||
):
|
||||
return {"ok": false, "code": "battle_route_mismatch"}
|
||||
if (
|
||||
int(snapshot_value.fixed_tick_ms) != 100
|
||||
or int(snapshot_value.tick) <= 0
|
||||
or int(snapshot_value.tick) > 200
|
||||
or snapshot_value.first_decisive_event.is_empty()
|
||||
):
|
||||
return {"ok": false, "code": "battle_clock_invalid"}
|
||||
if not snapshot_value.actors is Dictionary or not snapshot_value.fallen_recruit_ids is Array:
|
||||
return {"ok": false, "code": "battle_actor_type"}
|
||||
if Array(snapshot_value.final_order) != Array(encounter.deployed_recruit_ids):
|
||||
return {"ok": false, "code": "battle_order_mismatch"}
|
||||
if not _same_members(snapshot_value.actors.keys(), encounter.deployed_recruit_ids):
|
||||
return {"ok": false, "code": "battle_actor_membership"}
|
||||
for recruit_id in encounter.deployed_recruit_ids:
|
||||
var actor: Dictionary = snapshot_value.actors[recruit_id]
|
||||
if int(actor.hp) < 0 or int(actor.hp) > int(actor.max_hp):
|
||||
return {"ok": false, "code": "battle_actor_hp"}
|
||||
if (recruit_id in snapshot_value.fallen_recruit_ids) != (int(actor.hp) == 0):
|
||||
return {"ok": false, "code": "battle_fallen_mismatch"}
|
||||
if int(snapshot_value.reward_coin) != int(encounter.reward_coin):
|
||||
return {"ok": false, "code": "battle_reward_mismatch"}
|
||||
if int(snapshot_value.recovery_coin) != int(encounter.recovery_coin):
|
||||
return {"ok": false, "code": "battle_recovery_mismatch"}
|
||||
if int(snapshot_value.xp_grant) != battle_xp(int(encounter.round_index), int(encounter.risk)):
|
||||
return {"ok": false, "code": "battle_xp_mismatch"}
|
||||
var checkpoint_id := String(snapshot_value.checkpoint_id)
|
||||
if checkpoint_id != String(run.pre_battle_checkpoint_id) or not state.battle_checkpoints.has(checkpoint_id):
|
||||
return {"ok": false, "code": "battle_checkpoint_mismatch"}
|
||||
return {"ok": true, "code": "ok"}
|
||||
|
||||
|
||||
func _apply_actor_results(battle_snapshot: Dictionary) -> Array:
|
||||
var casualties: Array = []
|
||||
for recruit_id in battle_snapshot.final_order:
|
||||
if recruit_id in battle_snapshot.fallen_recruit_ids:
|
||||
casualties.append(_apply_casualty(String(recruit_id)))
|
||||
elif recruit_id in state.current_run.company:
|
||||
state.current_run.recruit_records[recruit_id].current_hp = maxi(
|
||||
1,
|
||||
int(battle_snapshot.actors[recruit_id].hp)
|
||||
)
|
||||
return casualties
|
||||
|
||||
|
||||
func _apply_casualty(recruit_id: String) -> Dictionary:
|
||||
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
||||
var instance_id := String(state.current_run.equipped_by_recruit.get(recruit_id, ""))
|
||||
if not instance_id.is_empty() and state.current_run.inventory.has(instance_id):
|
||||
state.current_run.inventory[instance_id].wearer_id = ""
|
||||
var credit_before := int(state.current_run.replacement_credit)
|
||||
var grant := death_credit(
|
||||
int(record.get("original_list_price", 14)),
|
||||
int(record.get("coin_price_paid", 0))
|
||||
)
|
||||
state.current_run.replacement_credit = mini(MAX_CREDIT, credit_before + grant)
|
||||
state.current_run.company.erase(recruit_id)
|
||||
state.current_run.deployed_recruit_ids.erase(recruit_id)
|
||||
state.current_run.reserve_recruit_ids.erase(recruit_id)
|
||||
state.current_run.recruit_records.erase(recruit_id)
|
||||
state.current_run.equipped_by_recruit.erase(recruit_id)
|
||||
state.current_run.recruits_fallen_total = int(state.current_run.recruits_fallen_total) + 1
|
||||
return {
|
||||
"recruit_id": recruit_id,
|
||||
"profession_id": String(record.profession_id),
|
||||
"trait_id": String(record.trait_id),
|
||||
"level": int(record.level),
|
||||
"credit_grant": int(state.current_run.replacement_credit) - credit_before,
|
||||
"returned_equipment_instance_id": instance_id,
|
||||
}
|
||||
|
||||
|
||||
func _apply_survivor_growth(recruit_id: String, remaining_hp: int, xp_award: int) -> Dictionary:
|
||||
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
||||
var level_before := int(record.level)
|
||||
var xp_before := int(record.xp)
|
||||
var max_hp_before := int(record.max_hp)
|
||||
var hp_ratio := float(clampi(remaining_hp, 1, max_hp_before)) / float(maxi(1, max_hp_before))
|
||||
record.survived_battles = int(record.survived_battles) + 1
|
||||
record.xp = int(record.xp) + xp_award
|
||||
while int(record.level) < 10 and int(record.xp) >= xp_to_next(int(record.level)):
|
||||
record.xp = int(record.xp) - xp_to_next(int(record.level))
|
||||
record.level = int(record.level) + 1
|
||||
record.max_hp = int(record.max_hp) + 4
|
||||
if int(record.level) >= 10:
|
||||
record.level = 10
|
||||
record.xp = 0
|
||||
record.current_hp = clampi(
|
||||
int(round(hp_ratio * float(record.max_hp))),
|
||||
1,
|
||||
int(record.max_hp)
|
||||
)
|
||||
state.current_run.recruit_records[recruit_id] = record
|
||||
return {
|
||||
"recruit_id": recruit_id,
|
||||
"level_before": level_before,
|
||||
"level_after": int(record.level),
|
||||
"xp_before": xp_before,
|
||||
"xp_award": xp_award,
|
||||
"xp_after": int(record.xp),
|
||||
"max_hp_before": max_hp_before,
|
||||
"max_hp_after": int(record.max_hp),
|
||||
"current_hp_after": int(record.current_hp),
|
||||
"survived_battles": int(record.survived_battles),
|
||||
}
|
||||
|
||||
|
||||
func _prior_operation(operation_id: String, kind: String, intent: Dictionary) -> Dictionary:
|
||||
if operation_id.is_empty():
|
||||
return {"known": true, "result": _reject("operation_id_required")}
|
||||
if not valid or not state.operation_receipts.has(operation_id):
|
||||
return {"known": false}
|
||||
var stored: Dictionary = state.operation_receipts[operation_id]
|
||||
if String(stored.kind) != kind or String(stored.intent_hash) != BoardService.hash_value(intent):
|
||||
return {"known": true, "result": _reject("operation_conflict")}
|
||||
return {"known": true, "result": {
|
||||
"ok": true,
|
||||
"code": String(stored.code),
|
||||
"duplicate": true,
|
||||
"revision": int(stored.revision),
|
||||
"receipt": Dictionary(stored.receipt).duplicate(true),
|
||||
"snapshot": state.snapshot(),
|
||||
}}
|
||||
|
||||
|
||||
func _commit(
|
||||
operation_id: String,
|
||||
kind: String,
|
||||
intent: Dictionary,
|
||||
code: String,
|
||||
receipt: Dictionary,
|
||||
before: Dictionary
|
||||
) -> Dictionary:
|
||||
state.revision += 1
|
||||
state.operation_receipts[operation_id] = {
|
||||
"operation_id": operation_id,
|
||||
"kind": kind,
|
||||
"intent_hash": BoardService.hash_value(intent),
|
||||
"status": "applied",
|
||||
"code": code,
|
||||
"revision": state.revision,
|
||||
"receipt": receipt.duplicate(true),
|
||||
}
|
||||
if not state.invariants_hold():
|
||||
state.restore_snapshot(before)
|
||||
return _reject("postcondition_failed")
|
||||
return {
|
||||
"ok": true,
|
||||
"code": code,
|
||||
"duplicate": false,
|
||||
"revision": state.revision,
|
||||
"receipt": receipt.duplicate(true),
|
||||
"snapshot": state.snapshot(),
|
||||
}
|
||||
|
||||
|
||||
func _reject(code: String) -> Dictionary:
|
||||
return {
|
||||
"ok": false,
|
||||
"code": code,
|
||||
"duplicate": false,
|
||||
"revision": state.revision if valid else -1,
|
||||
}
|
||||
|
||||
|
||||
func _offer_ids(offers: Array, field: String) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for offer in offers:
|
||||
result.append(String(offer.get(field, "")))
|
||||
return result
|
||||
|
||||
|
||||
func _same_members(left: Array, right: Array) -> bool:
|
||||
if left.size() != right.size():
|
||||
return false
|
||||
for value in left:
|
||||
if value not in right:
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://d7m3q8v1k5s9x
|
||||
+70
-13
@@ -225,19 +225,26 @@ func _run_invariants_hold() -> bool:
|
||||
return false
|
||||
if String(current_run.region_id) != "RG-02" or String(current_run.contract_id) != "CTR-R2-01":
|
||||
return false
|
||||
if String(current_run.status) != "active" or String(current_run.phase) not in ["market", "prepared"]:
|
||||
if String(current_run.status) != "active" or String(current_run.phase) not in ["market", "prepared", "recovery", "market_5"]:
|
||||
return false
|
||||
if int(current_run.market_index) != 2 or int(current_run.round_index) != 2 or int(current_run.cleared_rounds) != 1:
|
||||
var market_index := int(current_run.market_index)
|
||||
var cleared_rounds := int(current_run.cleared_rounds)
|
||||
if market_index < 2 or market_index > 5 or int(current_run.round_index) != market_index:
|
||||
return false
|
||||
if cleared_rounds != market_index - 1:
|
||||
return false
|
||||
if int(current_run.coin) < 0 or int(current_run.replacement_credit) < 0 or int(current_run.refresh_count) < 0:
|
||||
return false
|
||||
if int(current_run.line_capacity) != 4 or bool(current_run.reed_ferry_restored):
|
||||
var expected_capacity := mini(10, 4 + floori(float(cleared_rounds) / 2.0))
|
||||
if int(current_run.line_capacity) != expected_capacity:
|
||||
return false
|
||||
if bool(current_run.reed_ferry_restored) != (market_index == 5):
|
||||
return false
|
||||
if int(current_run.recruits_hired_total) < current_run.company.size():
|
||||
return false
|
||||
if int(current_run.recruits_fallen_total) < 0 or int(current_run.recruits_dismissed_total) < 0:
|
||||
return false
|
||||
if current_run.first_crossing_handoff.is_empty() or current_run.route_history.size() != 1:
|
||||
if current_run.first_crossing_handoff.is_empty() or current_run.route_history.size() != cleared_rounds:
|
||||
return false
|
||||
if String(current_run.route_history[0].get("battle_hash", "")) != String(current_run.first_crossing_handoff.get("source_battle_hash", "")):
|
||||
return false
|
||||
@@ -248,24 +255,74 @@ func _run_invariants_hold() -> bool:
|
||||
var phase := String(current_run.phase)
|
||||
if phase == "market":
|
||||
return (
|
||||
String(current_run.selected_route_id).is_empty()
|
||||
market_index >= 2
|
||||
and market_index <= 4
|
||||
and String(current_run.selected_route_id).is_empty()
|
||||
and current_run.pending_encounter.is_empty()
|
||||
and String(current_run.pre_battle_checkpoint_id).is_empty()
|
||||
and not bool(current_run.recovery_required)
|
||||
and int(current_run.recovery_cost) == 0
|
||||
)
|
||||
var checkpoint_id := String(current_run.pre_battle_checkpoint_id)
|
||||
if phase == "prepared":
|
||||
return (
|
||||
market_index >= 2
|
||||
and market_index <= 4
|
||||
and String(current_run.selected_route_id) in current_run.available_battle_offer_ids
|
||||
and not current_run.pending_encounter.is_empty()
|
||||
and String(current_run.pending_encounter.get("offer_id", "")) == String(current_run.selected_route_id)
|
||||
and checkpoint_id in battle_checkpoints
|
||||
and not current_run.deployed_recruit_ids.is_empty()
|
||||
)
|
||||
if phase == "recovery":
|
||||
return (
|
||||
market_index >= 2
|
||||
and market_index <= 4
|
||||
and bool(current_run.recovery_required)
|
||||
and int(current_run.recovery_cost) > 0
|
||||
and String(current_run.selected_route_id).is_empty()
|
||||
and current_run.pending_encounter.is_empty()
|
||||
and checkpoint_id in battle_checkpoints
|
||||
and not current_run.last_battle_report.is_empty()
|
||||
)
|
||||
return (
|
||||
String(current_run.selected_route_id) in current_run.available_battle_offer_ids
|
||||
and not current_run.pending_encounter.is_empty()
|
||||
and String(current_run.pending_encounter.get("offer_id", "")) == String(current_run.selected_route_id)
|
||||
and checkpoint_id in battle_checkpoints
|
||||
and not current_run.deployed_recruit_ids.is_empty()
|
||||
market_index == 5
|
||||
and String(current_run.selected_route_id).is_empty()
|
||||
and current_run.pending_encounter.is_empty()
|
||||
and String(current_run.pre_battle_checkpoint_id).is_empty()
|
||||
and not bool(current_run.recovery_required)
|
||||
and int(current_run.recovery_cost) == 0
|
||||
)
|
||||
|
||||
|
||||
func _board_invariants_hold() -> bool:
|
||||
var board: Dictionary = current_run.market_board
|
||||
if int(current_run.market_index) == 5:
|
||||
for key in [
|
||||
"handoff_kind", "board_hash", "run_seed", "contract_id", "region_id",
|
||||
"market_index", "cleared_rounds", "line_capacity",
|
||||
"source_settlement_hash", "carried_lock_ids",
|
||||
]:
|
||||
if not board.has(key):
|
||||
return false
|
||||
if (
|
||||
String(board.handoff_kind) != "p10.2.market_5_continuation"
|
||||
or String(board.run_seed) != String(current_run.run_seed)
|
||||
or int(board.market_index) != 5
|
||||
or int(board.cleared_rounds) != 4
|
||||
or int(board.line_capacity) != 6
|
||||
):
|
||||
return false
|
||||
var market_5_hash := String(board.board_hash)
|
||||
var market_5_candidate := board.duplicate(true)
|
||||
market_5_candidate.erase("board_hash")
|
||||
return (
|
||||
market_5_hash == _hash(market_5_candidate)
|
||||
and current_run.available_recruit_offer_ids.is_empty()
|
||||
and current_run.available_equipment_offer_ids.is_empty()
|
||||
and current_run.available_battle_offer_ids.is_empty()
|
||||
and current_run.locked_offer_ids.is_empty()
|
||||
)
|
||||
for key in [
|
||||
"board_id", "board_hash", "run_seed", "contract_id", "region_id",
|
||||
"market_index", "round_index", "cleared_rounds_before", "line_capacity",
|
||||
@@ -276,10 +333,10 @@ func _board_invariants_hold() -> bool:
|
||||
return false
|
||||
if (
|
||||
String(board.run_seed) != String(current_run.run_seed)
|
||||
or int(board.market_index) != 2
|
||||
or int(board.round_index) != 2
|
||||
or int(board.market_index) != int(current_run.market_index)
|
||||
or int(board.round_index) != int(current_run.round_index)
|
||||
or int(board.refresh_index) != int(current_run.refresh_count)
|
||||
or int(board.line_capacity) != 4
|
||||
or int(board.line_capacity) != int(current_run.line_capacity)
|
||||
):
|
||||
return false
|
||||
if not board.recruit_offers is Array or board.recruit_offers.size() != 3:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
extends SceneTree
|
||||
|
||||
const BattleService = preload("res://p10_2/mid_contract_battle_service.gd")
|
||||
const Fixture = preload("res://tests/p10_2/mid_contract_fixture.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_test")
|
||||
|
||||
|
||||
func run_test() -> void:
|
||||
verify_all_routes()
|
||||
verify_fixed_counterfactuals()
|
||||
verify_new_event_authority()
|
||||
verify_negative_boundaries()
|
||||
if failures.is_empty():
|
||||
print("ABG_P10_2_MID_CONTRACT_BATTLE_OK routes=9 rounds=2,3,4 plans=hold_line,follow_tide,focus_source,guard_center preferences=2,2,3,2 counterfactuals=9 seed=true medic=true fogjaw=true tide=true deterministic=true max_ticks=200")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func verify_all_routes() -> void:
|
||||
var preferred_counts := {
|
||||
"hold_line": 0,
|
||||
"follow_tide": 0,
|
||||
"focus_source": 0,
|
||||
"guard_center": 0,
|
||||
}
|
||||
for market_index in [2, 3, 4]:
|
||||
for risk in range(3):
|
||||
var service = Fixture.new().prepared_service(market_index, risk)
|
||||
expect(service != null, "fixture prepares Market %d risk %d" % [market_index, risk])
|
||||
if service == null:
|
||||
continue
|
||||
var route_id := String(service.state.current_run.pending_encounter.route_id)
|
||||
var plan_id := String(BattleService.PREFERRED_PLAN[route_id])
|
||||
preferred_counts[plan_id] += 1
|
||||
var first := BattleService.new(service.state.snapshot()).run_to_terminal(plan_id)
|
||||
var second := BattleService.new(service.state.snapshot()).run_to_terminal(plan_id)
|
||||
expect(first.ok and first == second, "%s is byte-identical" % route_id)
|
||||
expect(first.snapshot.outcome == "victory" and first.snapshot.terminal, "%s preferred response wins" % route_id)
|
||||
expect(first.snapshot.round_index == market_index and first.snapshot.risk == risk, "%s binds exact Round/risk" % route_id)
|
||||
expect(first.snapshot.tick > 0 and first.snapshot.tick <= 200 and first.snapshot.fixed_tick_ms == 100, "%s terminates on bounded fixed clock" % route_id)
|
||||
expect(String(first.snapshot.event_log_hash).length() == 64 and String(first.snapshot.snapshot_hash).length() == 64 and not first.snapshot.first_decisive_event.is_empty(), "%s retains causal hashes" % route_id)
|
||||
expect(first.snapshot.final_order == service.state.current_run.deployed_recruit_ids and first.snapshot.actors.size() == service.state.current_run.deployed_recruit_ids.size(), "%s binds deployed Company" % route_id)
|
||||
expect(preferred_counts == {"hold_line": 2, "follow_tide": 2, "focus_source": 3, "guard_center": 2}, "preferred responses are distributed 2/2/3/2")
|
||||
|
||||
|
||||
func verify_fixed_counterfactuals() -> void:
|
||||
var wrong_plans := {
|
||||
"RO-R2-M02-Q0": "follow_tide",
|
||||
"RO-R2-M02-Q1": "hold_line",
|
||||
"RO-R2-M02-Q2": "hold_line",
|
||||
"RO-R2-M03-Q0": "follow_tide",
|
||||
"RO-R2-M03-Q1": "follow_tide",
|
||||
"RO-R2-M03-Q2": "hold_line",
|
||||
"RO-R2-M04-Q0": "hold_line",
|
||||
"RO-R2-M04-Q1": "hold_line",
|
||||
"RO-R2-M04-Q2": "hold_line",
|
||||
}
|
||||
for market_index in [2, 3, 4]:
|
||||
for risk in range(3):
|
||||
var service = Fixture.new().prepared_service(market_index, risk)
|
||||
if service == null:
|
||||
continue
|
||||
var route_id := String(service.state.current_run.pending_encounter.route_id)
|
||||
var preferred := BattleService.new(service.state.snapshot()).run_to_terminal(String(BattleService.PREFERRED_PLAN[route_id]))
|
||||
var wrong := BattleService.new(service.state.snapshot()).run_to_terminal(String(wrong_plans[route_id]))
|
||||
expect(preferred.ok and wrong.ok and preferred.snapshot.score > wrong.snapshot.score, "%s response changes score" % route_id)
|
||||
expect(preferred.snapshot.outcome == "victory" and wrong.snapshot.outcome == "defeat", "%s has an opposed outcome" % route_id)
|
||||
expect(wrong.snapshot.fallen_recruit_ids.size() == 1, "%s wrong response creates one permanent-casualty candidate" % route_id)
|
||||
|
||||
|
||||
func verify_new_event_authority() -> void:
|
||||
var seed := terminal_snapshot(2, 2)
|
||||
var seed_kinds: Array = seed.event_log.map(func(event): return String(event.kind))
|
||||
expect("drownseed_placed" in seed_kinds and "drownseed_evaded" in seed_kinds, "Drownseed exposes placement and successful movement")
|
||||
var medic := terminal_snapshot(3, 1)
|
||||
var medic_kinds: Array = medic.event_log.map(func(event): return String(event.kind))
|
||||
expect("mire_medic_heal" in medic_kinds and "source_interrupted" in medic_kinds and "healing_denial_attached" in medic_kinds, "Medic/Leech route exposes sustain and denial source focus")
|
||||
var fog := terminal_snapshot(3, 2)
|
||||
var fog_kinds: Array = fog.event_log.map(func(event): return String(event.kind))
|
||||
expect("fogjaw_hidden" in fog_kinds and "fogjaw_center_attack" in fog_kinds and "center_guarded" in fog_kinds, "Fogjaw exposes hidden, center attack and guarded reveal")
|
||||
var nursery := terminal_snapshot(4, 2)
|
||||
var nursery_kinds: Array = nursery.event_log.map(func(event): return String(event.kind))
|
||||
for required in ["indexed_mud_marked", "drownseed_placed", "mire_medic_heal", "tide_shift_warning", "tide_shifted", "battle_terminal"]:
|
||||
expect(required in nursery_kinds, "Sinking Nursery includes %s" % required)
|
||||
|
||||
|
||||
func verify_negative_boundaries() -> void:
|
||||
var unprepared = Fixture.new().market_service()
|
||||
var rejected := BattleService.new(unprepared.state.snapshot()).run_to_terminal("hold_line")
|
||||
expect(not rejected.ok and rejected.code == "prepared_route_required", "unprepared Market cannot execute")
|
||||
var prepared = Fixture.new().prepared_service(2, 0)
|
||||
var unknown := BattleService.new(prepared.state.snapshot()).run_to_terminal("unknown")
|
||||
expect(not unknown.ok and unknown.code == "unknown_plan", "unknown response rejects")
|
||||
|
||||
|
||||
func terminal_snapshot(market_index: int, risk: int) -> Dictionary:
|
||||
var service = Fixture.new().prepared_service(market_index, risk)
|
||||
if service == null:
|
||||
return {}
|
||||
var route_id := String(service.state.current_run.pending_encounter.route_id)
|
||||
var result := BattleService.new(service.state.snapshot()).run_to_terminal(String(BattleService.PREFERRED_PLAN[route_id]))
|
||||
return result.snapshot if result.ok else {}
|
||||
|
||||
|
||||
func expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append("P10.2 mid-Contract battle: %s" % message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8q2m6v4x1k7p
|
||||
@@ -5,7 +5,9 @@ const P101Battle = preload("res://p10_1/first_crossing_battle_service.gd")
|
||||
const P101Resolution = preload("res://p10_1/first_crossing_resolution_service.gd")
|
||||
const P101Save = preload("res://p10_1/save_v7_service.gd")
|
||||
const P101Fixture = preload("res://tests/p10_1/first_crossing_fixture.gd")
|
||||
const BattleService = preload("res://p10_2/mid_contract_battle_service.gd")
|
||||
const MarketService = preload("res://p10_2/market_service.gd")
|
||||
const ResolutionService = preload("res://p10_2/mid_contract_resolution_service.gd")
|
||||
const SaveV8 = preload("res://p10_2/save_v8_service.gd")
|
||||
|
||||
|
||||
@@ -40,3 +42,58 @@ func migrated_snapshot(sequence: int = 7) -> Dictionary:
|
||||
func market_service():
|
||||
var snapshot_value := migrated_snapshot()
|
||||
return MarketService.new(snapshot_value) if not snapshot_value.is_empty() else null
|
||||
|
||||
|
||||
func market_service_at(market_index: int):
|
||||
if market_index < 2 or market_index > 5:
|
||||
return null
|
||||
var service = market_service()
|
||||
if service == null:
|
||||
return null
|
||||
while int(service.state.current_run.market_index) < market_index:
|
||||
if String(service.state.current_run.phase) == "market_5":
|
||||
break
|
||||
var route: Dictionary = service.state.current_run.market_board.battle_offers[0]
|
||||
var prepared := service.commit_route(
|
||||
"fixture-route-%d" % int(service.state.current_run.market_index),
|
||||
service.state.revision,
|
||||
String(route.offer_id),
|
||||
String(service.state.current_run.market_board.board_hash)
|
||||
)
|
||||
if not prepared.ok:
|
||||
return null
|
||||
var battle := BattleService.new(service.state.snapshot()).run_to_terminal(
|
||||
preferred_plan(String(route.route_id))
|
||||
)
|
||||
if not battle.ok or String(battle.snapshot.outcome) != "victory":
|
||||
return null
|
||||
var resolution = ResolutionService.new(service.state.snapshot())
|
||||
var settled := resolution.settle(
|
||||
"fixture-settle-%d" % int(route.round_index),
|
||||
resolution.state.revision,
|
||||
battle.snapshot
|
||||
)
|
||||
if not settled.ok:
|
||||
return null
|
||||
service = MarketService.new(resolution.state.snapshot())
|
||||
return service
|
||||
|
||||
|
||||
func prepared_service(market_index: int, risk: int):
|
||||
if market_index < 2 or market_index > 4 or risk < 0 or risk > 2:
|
||||
return null
|
||||
var service = market_service_at(market_index)
|
||||
if service == null or String(service.state.current_run.phase) != "market":
|
||||
return null
|
||||
var route: Dictionary = service.state.current_run.market_board.battle_offers[risk]
|
||||
var prepared := service.commit_route(
|
||||
"fixture-target-route-%d-%d" % [market_index, risk],
|
||||
service.state.revision,
|
||||
String(route.offer_id),
|
||||
String(service.state.current_run.market_board.board_hash)
|
||||
)
|
||||
return service if prepared.ok else null
|
||||
|
||||
|
||||
func preferred_plan(route_id: String) -> String:
|
||||
return String(BattleService.PREFERRED_PLAN.get(route_id, ""))
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
extends SceneTree
|
||||
|
||||
const BattleService = preload("res://p10_2/mid_contract_battle_service.gd")
|
||||
const BoardService = preload("res://p10_2/market_board_service.gd")
|
||||
const MarketService = preload("res://p10_2/market_service.gd")
|
||||
const ResolutionService = preload("res://p10_2/mid_contract_resolution_service.gd")
|
||||
const SaveV8 = preload("res://p10_2/save_v8_service.gd")
|
||||
const Fixture = preload("res://tests/p10_2/mid_contract_fixture.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_test")
|
||||
|
||||
|
||||
func run_test() -> void:
|
||||
verify_three_round_progression()
|
||||
verify_lock_carries_to_next_market()
|
||||
verify_defeat_casualty_and_recovery()
|
||||
verify_atomic_boundaries()
|
||||
if failures.is_empty():
|
||||
print("ABG_P10_2_MID_CONTRACT_RESOLUTION_OK rounds=2,3,4 rewards=24,33,42 xp=14,20,26 capacity=4,5,5,6 markets=2,3,4,5 route_history=4 locks=carry casualty=permanent credit=true recovery=bounded ferry=restored save_v8=roundtrip duplicate=true")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func verify_three_round_progression() -> void:
|
||||
var service = Fixture.new().market_service()
|
||||
expect(service != null, "three-Round fixture starts at Market 2")
|
||||
if service == null:
|
||||
return
|
||||
var starting_coin := int(service.state.current_run.coin)
|
||||
var starting_company: Array = Array(service.state.current_run.company).duplicate()
|
||||
var starting_survivals := {}
|
||||
for recruit_id in starting_company:
|
||||
starting_survivals[recruit_id] = int(service.state.current_run.recruit_records[recruit_id].survived_battles)
|
||||
var total_growth_rows := 0
|
||||
var expected := {
|
||||
2: {"risk": 0, "reward": 24, "xp": 14, "next_market": 3, "capacity": 5},
|
||||
3: {"risk": 1, "reward": 33, "xp": 20, "next_market": 4, "capacity": 5},
|
||||
4: {"risk": 2, "reward": 42, "xp": 26, "next_market": 5, "capacity": 6},
|
||||
}
|
||||
for market_index in [2, 3, 4]:
|
||||
var row: Dictionary = expected[market_index]
|
||||
var route: Dictionary = service.state.current_run.market_board.battle_offers[int(row.risk)]
|
||||
var prepared: Dictionary = service.commit_route(
|
||||
"progress-route-%d" % market_index,
|
||||
service.state.revision,
|
||||
String(route.offer_id),
|
||||
String(service.state.current_run.market_board.board_hash)
|
||||
)
|
||||
expect(prepared.ok, "Round %d route prepares" % market_index)
|
||||
var plan_id := String(BattleService.PREFERRED_PLAN[String(route.route_id)])
|
||||
var battle: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal(plan_id)
|
||||
expect(battle.ok and battle.snapshot.outcome == "victory", "Round %d preferred response wins" % market_index)
|
||||
var resolution = ResolutionService.new(service.state.snapshot())
|
||||
var revision_before: int = int(resolution.state.revision)
|
||||
var settled: Dictionary = resolution.settle(
|
||||
"progress-settle-%d" % market_index,
|
||||
revision_before,
|
||||
battle.snapshot
|
||||
)
|
||||
expect(settled.ok and resolution.state.revision == revision_before + 1, "Round %d settlement applies once" % market_index)
|
||||
expect(settled.receipt.reward_coin == row.reward and settled.receipt.xp_award == row.xp, "Round %d reward and XP are exact" % market_index)
|
||||
expect(resolution.state.current_run.market_index == row.next_market and resolution.state.current_run.line_capacity == row.capacity, "Round %d opens Market %d at capacity %d" % [market_index, row.next_market, row.capacity])
|
||||
expect(resolution.state.current_run.cleared_rounds == market_index and resolution.state.current_run.route_history.size() == market_index, "Round %d clear and route history advance once" % market_index)
|
||||
total_growth_rows += settled.receipt.growth.size()
|
||||
var after: Dictionary = resolution.state.snapshot()
|
||||
var duplicate: Dictionary = resolution.settle(
|
||||
"progress-settle-%d" % market_index,
|
||||
revision_before,
|
||||
battle.snapshot
|
||||
)
|
||||
expect(duplicate.ok and duplicate.duplicate and resolution.state.snapshot() == after, "Round %d settlement retry is idempotent" % market_index)
|
||||
service = MarketService.new(resolution.state.snapshot())
|
||||
|
||||
expect(service.state.current_run.phase == "market_5" and service.state.current_run.reed_ferry_restored, "Round 4 restores Ferry and reaches Market 5")
|
||||
expect(service.state.current_run.company == starting_company and service.state.current_run.coin == starting_coin + 99, "three victories preserve Company and grant exact total Coin")
|
||||
expect(total_growth_rows == starting_company.size() * 3, "every living deployed Recruit receives three growth rows")
|
||||
for recruit_id in starting_company:
|
||||
expect(service.state.current_run.recruit_records[recruit_id].survived_battles == int(starting_survivals[recruit_id]) + 3, "%s preserves migrated history and records exactly three survived Rounds" % recruit_id)
|
||||
expect(service.state.current_run.market_board.handoff_kind == "p10.2.market_5_continuation" and service.state.current_run.market_board.carried_lock_ids.is_empty(), "Market 5 is an explicit continuation handoff")
|
||||
var encoded: Dictionary = SaveV8.new().encode_snapshot(service.state.snapshot(), 20)
|
||||
var decoded: Dictionary = SaveV8.new().decode_text(String(encoded.text), 20)
|
||||
expect(encoded.ok and decoded.ok and decoded.snapshot == service.state.snapshot(), "Market 5 roundtrips through Save v8")
|
||||
|
||||
|
||||
func verify_lock_carries_to_next_market() -> void:
|
||||
var service = Fixture.new().market_service()
|
||||
var offer_id := String(service.state.current_run.available_equipment_offer_ids[0])
|
||||
var locked_row := find_offer(service.state.current_run.market_board.equipment_offers, "offer_id", offer_id)
|
||||
expect(service.lock_offer("carry-lock", service.state.revision, offer_id, 2).ok, "equipment lock applies before Round 2")
|
||||
var route: Dictionary = service.state.current_run.market_board.battle_offers[0]
|
||||
expect(service.commit_route("carry-route", service.state.revision, String(route.offer_id), String(service.state.current_run.market_board.board_hash)).ok, "locked-board route prepares")
|
||||
var battle: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal("hold_line")
|
||||
var resolution = ResolutionService.new(service.state.snapshot())
|
||||
var settled: Dictionary = resolution.settle("carry-settle", resolution.state.revision, battle.snapshot)
|
||||
expect(settled.ok and resolution.state.current_run.market_index == 3, "locked-board victory opens Market 3")
|
||||
expect(offer_id in resolution.state.current_run.locked_offer_ids, "lock ID persists into next Market")
|
||||
expect(find_offer(resolution.state.current_run.market_board.equipment_offers, "offer_id", offer_id) == locked_row, "locked equipment row persists byte-equivalent")
|
||||
|
||||
|
||||
func verify_defeat_casualty_and_recovery() -> void:
|
||||
var service = Fixture.new().prepared_service(4, 2)
|
||||
expect(service != null, "defeat fixture reaches Market 4 elite route")
|
||||
if service == null:
|
||||
return
|
||||
var before: Dictionary = service.state.snapshot()
|
||||
var before_company: Array = Array(before.current_run.company).duplicate()
|
||||
var before_coin := int(before.current_run.coin)
|
||||
var before_history := Array(before.current_run.route_history).duplicate(true)
|
||||
var battle: Dictionary = BattleService.new(before).run_to_terminal("hold_line")
|
||||
expect(battle.ok and battle.snapshot.outcome == "defeat" and battle.snapshot.fallen_recruit_ids.size() == 1, "wrong Sinking Nursery response defeats with one casualty")
|
||||
var fallen_id := String(battle.snapshot.fallen_recruit_ids[0])
|
||||
var survivor_id := String(before_company[0]) if String(before_company[0]) != fallen_id else String(before_company[1])
|
||||
var survivor_xp_before := int(before.current_run.recruit_records[survivor_id].xp)
|
||||
var resolution = ResolutionService.new(before)
|
||||
var settled: Dictionary = resolution.settle("defeat-settle", resolution.state.revision, battle.snapshot)
|
||||
expect(settled.ok and resolution.state.current_run.phase == "recovery", "defeat enters explicit recovery")
|
||||
expect(fallen_id not in resolution.state.current_run.company and fallen_id not in resolution.state.current_run.recruit_records, "Fallen Recruit is permanently removed")
|
||||
expect(resolution.state.current_run.inventory[settled.receipt.casualties[0].returned_equipment_instance_id].wearer_id == "", "Fallen equipment returns to inventory")
|
||||
expect(resolution.state.current_run.replacement_credit > 0 and resolution.state.current_run.recruits_fallen_total == 1, "casualty grants bounded Credit and increments loss count")
|
||||
expect(resolution.state.current_run.coin == before_coin and resolution.state.current_run.route_history == before_history and resolution.state.current_run.cleared_rounds == 3, "defeat grants no Coin or clear")
|
||||
expect(settled.receipt.xp_award == 13 and int(resolution.state.current_run.recruit_records[survivor_id].xp) != survivor_xp_before, "survivor receives exact half defeat XP")
|
||||
expect(resolution.state.current_run.market_board.board_hash == before.current_run.market_board.board_hash, "defeat restores exact pre-battle Market board")
|
||||
var recovery_save: Dictionary = SaveV8.new().encode_snapshot(resolution.state.snapshot(), 30)
|
||||
expect(recovery_save.ok and SaveV8.new().decode_text(String(recovery_save.text), 30).ok, "recovery state roundtrips through Save v8")
|
||||
var company_after_loss: Array = Array(resolution.state.current_run.company).duplicate()
|
||||
var recovered: Dictionary = resolution.recover(
|
||||
"defeat-recover",
|
||||
resolution.state.revision,
|
||||
String(battle.snapshot.snapshot_hash),
|
||||
12
|
||||
)
|
||||
expect(recovered.ok and resolution.state.current_run.phase == "market", "bounded recovery returns to changed Market 4")
|
||||
expect(resolution.state.current_run.company == company_after_loss and fallen_id not in resolution.state.current_run.company, "recovery cannot resurrect Fallen Recruit")
|
||||
expect(resolution.state.current_run.coin == before_coin - 12 and resolution.state.battle_checkpoints.is_empty(), "recovery charges exact cost and clears checkpoint")
|
||||
for recruit_id in resolution.state.current_run.company:
|
||||
var record: Dictionary = resolution.state.current_run.recruit_records[recruit_id]
|
||||
expect(float(record.current_hp) / float(record.max_hp) >= 0.4, "recovery restores %s to at least 40 percent" % recruit_id)
|
||||
|
||||
|
||||
func verify_atomic_boundaries() -> void:
|
||||
var service = Fixture.new().prepared_service(2, 0)
|
||||
var battle: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal("hold_line")
|
||||
var resolution = ResolutionService.new(service.state.snapshot())
|
||||
var before: Dictionary = resolution.state.snapshot()
|
||||
var stale: Dictionary = resolution.settle("stale-settle", resolution.state.revision + 1, battle.snapshot)
|
||||
expect(not stale.ok and stale.code == "stale_revision" and resolution.state.snapshot() == before, "stale settlement rejects without mutation")
|
||||
var tampered: Dictionary = battle.snapshot.duplicate(true)
|
||||
tampered.reward_coin = 999
|
||||
var hash_rejected: Dictionary = resolution.settle("tampered-hash", resolution.state.revision, tampered)
|
||||
expect(not hash_rejected.ok and hash_rejected.code == "battle_hash_mismatch" and resolution.state.snapshot() == before, "tampered battle hash rejects without mutation")
|
||||
var forged: Dictionary = battle.snapshot.duplicate(true)
|
||||
forged.reward_coin = 999
|
||||
forged.erase("snapshot_hash")
|
||||
forged["snapshot_hash"] = BoardService.hash_value(forged)
|
||||
var reward_rejected: Dictionary = resolution.settle("tampered-reward", resolution.state.revision, forged)
|
||||
expect(not reward_rejected.ok and reward_rejected.code == "battle_reward_mismatch" and resolution.state.snapshot() == before, "self-consistent wrong reward rejects against encounter authority")
|
||||
|
||||
|
||||
func find_offer(offers: Array, field: String, offer_id: String) -> Dictionary:
|
||||
for offer in offers:
|
||||
if String(offer.get(field, "")) == offer_id:
|
||||
return Dictionary(offer).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append("P10.2 mid-Contract resolution: %s" % message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d5n9q2m7v4h1x
|
||||
Reference in New Issue
Block a user