Files
aetherbound-guild/runtime/tests/dev5/battle_observation_service_test.gd
T

244 lines
13 KiB
GDScript

extends SceneTree
const DraftService = preload("res://entry/initial_company_draft_service.gd")
const ContractService = preload("res://entry/guild_contract_service.gd")
const FormationService = preload("res://entry/formation_risk_service.gd")
const BattleService = preload("res://entry/battle_service.gd")
const DRAFT_PATH := "user://dev5_battle_observation_draft.json"
const COMPANY_PATH := "user://dev5_battle_observation_company.json"
const SEED := "13579bdf2468ace013579bdf2468ace0"
const CONTRACT_OP := "1029384756abcdef1029384756abcdef"
const HIRE_OP_1 := "11112222333344445555666677778888"
const HIRE_OP_2 := "88887777666655554444333322221111"
const ORDER_OP := "abcdefabcdefabcdefabcdefabcdefab"
const READY_OP := "1234567890abcdef1234567890abcdef"
const BATTLE_OP := "fedcba0987654321fedcba0987654321"
const WRONG_HASH := "0000000000000000000000000000000000000000000000000000000000000000"
const WRONG_EVENT_ID := "00000000000000000000000000000000"
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
_cleanup()
var prepared := _create_prepared_company()
if not prepared.is_empty():
_verify_non_settling_observation(prepared)
_cleanup()
prepared = _create_prepared_company()
if not prepared.is_empty():
_verify_legacy_settlement(prepared)
_cleanup()
if failures.is_empty():
print("ABG_DEV5_BATTLE_OBSERVATION_SERVICE_OK begin=no_tick receipt=one preview=stable commit=idempotent events=9 families=melee,ranged,magic,healing,guard,damage,knockout,enemy,outcome outcome=locked settlement=false reward=false recovery=false retry=validated legacy_settlement=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _verify_non_settling_observation(prepared: Dictionary) -> void:
var service := _battle_service()
var option_id := String(prepared.active_contract.formation.risk_options[1].risk_option_id)
var started: Dictionary = service.begin_battle(BATTLE_OP, option_id)
_expect(started.ok, "battle fixture starts")
if not started.ok:
return
var snapshot_hash := String(started.battle.snapshot_hash)
var receipt_before: Dictionary = Dictionary(started.company.active_contract.battle_receipts[0]).duplicate(true)
var coin_before := int(started.company.active_contract.coin)
var recruits_before := JSON.stringify(started.company.owned_recruits)
var rejected_snapshot: Dictionary = service.begin_observation(BATTLE_OP, WRONG_HASH)
_expect(not rejected_snapshot.ok and rejected_snapshot.code == "OBSERVATION_SNAPSHOT_MISMATCH", "wrong opening snapshot is rejected")
var begun: Dictionary = service.begin_observation(BATTLE_OP, snapshot_hash)
_expect(begun.ok and begun.code == "observation_started", "the original receipt starts observation")
if not begun.ok:
return
_expect(int(begun.battle.phase_index) == 0 and begun.battle.phase_events.is_empty(), "begin observation advances no hidden tick")
_expect(begun.company.active_contract.battle_receipts.size() == 1 and _same_receipt(begun.company.active_contract.battle_receipts[0], receipt_before), "begin observation preserves the one original receipt byte-for-byte")
var duplicate_begin: Dictionary = service.begin_observation(BATTLE_OP, snapshot_hash)
_expect(duplicate_begin.ok and duplicate_begin.code == "observation_already_started" and int(duplicate_begin.battle.phase_index) == 0, "duplicate observation begin is idempotent")
var first_preview: Dictionary = service.preview_observation_event(BATTLE_OP)
_expect(first_preview.ok and first_preview.code == "observation_event_ready", "first event can be previewed")
if not first_preview.ok:
return
var wrong_order: Dictionary = service.commit_observation_event(BATTLE_OP, WRONG_EVENT_ID, String(first_preview.event.event_hash))
_expect(not wrong_order.ok and wrong_order.code == "OBSERVATION_EVENT_ORDER_MISMATCH", "wrong event identity cannot create an order gap")
var kinds: Array[String] = []
var families: Array[String] = []
var last: Dictionary = begun
for phase in range(9):
var preview_a: Dictionary = service.preview_observation_event(BATTLE_OP)
var preview_b: Dictionary = service.preview_observation_event(BATTLE_OP)
_expect(preview_a.ok and preview_b.ok, "phase %d previews without mutation" % phase)
if not preview_a.ok or not preview_b.ok:
return
_expect(preview_a.event == preview_b.event, "phase %d preview identity and hash are stable" % phase)
_expect(int(preview_a.event.phase) == phase and int(preview_a.event.event_tick) == phase, "phase %d has an ordered tick" % phase)
if phase > 0 and phase < 8:
_expect(not String(preview_a.event.source_actor_id).is_empty() and not Array(preview_a.event.actual_recipient_ids).is_empty(), "phase %d binds a source and actual recipient" % phase)
kinds.append(String(preview_a.event.kind))
for family in preview_a.event.event_families:
if String(family) not in families:
families.append(String(family))
var wrong_hash: Dictionary = service.commit_observation_event(BATTLE_OP, String(preview_a.event.event_id), WRONG_HASH)
_expect(not wrong_hash.ok and wrong_hash.code == "OBSERVATION_EVENT_HASH_MISMATCH", "phase %d rejects a mismatched event hash" % phase)
var still_same: Dictionary = service.preview_observation_event(BATTLE_OP)
_expect(still_same.ok and still_same.event == preview_a.event, "phase %d rejection leaves the same event ready" % phase)
last = service.commit_observation_event(BATTLE_OP, String(preview_a.event.event_id), String(preview_a.event.event_hash))
_expect(last.ok, "phase %d commits" % phase)
if not last.ok:
return
var duplicate: Dictionary = service.commit_observation_event(BATTLE_OP, String(preview_a.event.event_id), String(preview_a.event.event_hash))
_expect(duplicate.ok and duplicate.code == "observation_event_already_committed", "phase %d commit retry is idempotent" % phase)
_expect(kinds == ["opening", "telegraph", "guard", "ranged", "magic", "healing", "damage", "knockout", "result"], "observation preserves the authored first-battle event order")
for required_family in [
"melee_contact", "ranged_release_and_contact", "magic_cast_and_resolution",
"healing_source_and_recipient_change", "guard_or_protection_intercept",
"damage_source_target_and_value", "knockout_and_line_collapse",
"enemy_action_and_target_relation", "outcome_lock",
]:
_expect(required_family in families, "required event family %s is bound" % required_family)
_expect(last.code == "observation_outcome_locked" and String(last.battle.status) == "outcome_locked", "the final event locks an immutable outcome")
var outcome: Dictionary = last.observation.outcome_lock
_expect(not outcome.is_empty() and outcome.outcome == "victory" and outcome.out001_is_next, "outcome lock names victory and the OUT-001 boundary")
_expect(int(last.company.active_contract.coin) == coin_before, "observation awards no Coin")
_expect(last.company.active_contract.status == "battle" and last.company.active_contract.battle_settlement_receipts.is_empty(), "observation creates no settlement receipt or result phase")
_expect(last.battle.result.is_empty() and JSON.stringify(last.company.owned_recruits) == recruits_before, "observation mutates no result, XP or Recruit history")
_expect(last.company.active_contract.battle_receipts.size() == 1 and _same_receipt(last.company.active_contract.battle_receipts[0], receipt_before), "outcome lock preserves the original receipt byte-for-byte")
var retry: Dictionary = service.retry_observation_snapshot(BATTLE_OP, snapshot_hash, String(last.observation.event_journal_hash))
_expect(retry.ok and retry.code == "observation_snapshot_valid", "snapshot retry validates the same receipt and journal")
var wrong_journal: Dictionary = service.retry_observation_snapshot(BATTLE_OP, snapshot_hash, WRONG_HASH)
_expect(not wrong_journal.ok and wrong_journal.code == "OBSERVATION_JOURNAL_MISMATCH", "snapshot retry rejects another journal")
var blocked_legacy: Dictionary = service.advance_battle(BATTLE_OP)
_expect(not blocked_legacy.ok and blocked_legacy.code == "BATTLE_OUTCOME_HANDOFF_REQUIRED", "legacy settlement cannot bypass the OUT-001 handoff")
var blocked_recovery: Dictionary = service.recover_next_stage("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
_expect(not blocked_recovery.ok and blocked_recovery.code == "RECOVERY_RESULT_REQUIRED", "recovery cannot run before OUT-001 settlement")
var resumed: Dictionary = service.begin_observation(BATTLE_OP, snapshot_hash)
_expect(resumed.ok and resumed.code == "observation_outcome_locked", "restart resolves the persisted outcome lock without reseeding")
var loaded: Dictionary = service.load_company()
_expect(loaded.ok and _draft_service().validate_company(loaded.company).ok, "outcome-locked Company remains valid after restart")
if loaded.ok:
var corrupt_company: Dictionary = loaded.company.duplicate(true)
var corrupt_contract: Dictionary = corrupt_company.active_contract
var corrupt_battle: Dictionary = corrupt_contract.battle_state
corrupt_battle.phase_events[3].amount = int(corrupt_battle.phase_events[3].amount) + 1
service._refresh_hash(corrupt_battle, "battle_hash")
corrupt_contract.battle_state = corrupt_battle
service._refresh_hash(corrupt_contract, "contract_hash")
corrupt_company.active_contract = corrupt_contract
service._refresh_hash(corrupt_company, "company_hash")
var corrupt_save: Dictionary = _draft_service().save_company(corrupt_company)
_expect(corrupt_save.ok, "fixture persists a valid outer envelope around a corrupt event")
var rejected_corruption: Dictionary = service.retry_observation_snapshot(BATTLE_OP, snapshot_hash)
_expect(not rejected_corruption.ok and rejected_corruption.code == "OBSERVATION_EVENT_HASH_INVALID", "event-content corruption pauses before resume")
func _verify_legacy_settlement(prepared: Dictionary) -> void:
var service := _battle_service()
var option_id := String(prepared.active_contract.formation.risk_options[1].risk_option_id)
var started: Dictionary = service.begin_battle(BATTLE_OP, option_id)
_expect(started.ok, "legacy fixture starts")
if not started.ok:
return
var last: Dictionary = started
for _phase in range(9):
last = service.advance_battle(BATTLE_OP)
_expect(last.ok, "legacy phase still advances")
if not last.ok:
return
_expect(String(last.battle.status) == "result" and int(last.company.active_contract.coin) == 51, "legacy advance still settles and awards the historical reward")
_expect(last.company.active_contract.battle_settlement_receipts.size() == 1, "legacy settlement still writes exactly one receipt")
func _create_prepared_company() -> Dictionary:
var draft: Dictionary = _draft_service().begin_or_load(SEED)
if not draft.ok:
_expect(false, "fixture creates opening draft")
return {}
var selected: Dictionary = _draft_service().set_selection([String(draft.draft.offers[0].offer_id), String(draft.draft.offers[3].offer_id)])
var committed: Dictionary = _draft_service().commit_company()
if not selected.ok or not committed.ok:
_expect(false, "fixture commits opening Company")
return {}
var created: Dictionary = _contract_service().create_contract(CONTRACT_OP, "CTR-R1-01", "standard")
if not created.ok:
_expect(false, "fixture creates first Contract")
return {}
var offers: Array = created.company.active_contract.market_board.recruit_offers
_contract_service().hire_recruit(HIRE_OP_1, String(offers[0].source_offer_id), 14)
var hired: Dictionary = _contract_service().hire_recruit(HIRE_OP_2, String(offers[1].source_offer_id), 14)
if not hired.ok:
_expect(false, "fixture completes opening hires")
return {}
var begun: Dictionary = _formation_service().begin_formation()
if not begun.ok:
_expect(false, "fixture creates formation")
return {}
var saved: Dictionary = _formation_service().save_order(ORDER_OP, begun.formation.base_front_to_rear_ids)
if not saved.ok:
_expect(false, "fixture saves formation")
return {}
var ready: Dictionary = _formation_service().accept_readiness(READY_OP, String(saved.receipt.order_hash))
return ready.company if ready.ok else {}
func _draft_service() -> RefCounted:
var service := DraftService.new()
service.path = DRAFT_PATH
service.company_path = COMPANY_PATH
return service
func _contract_service() -> RefCounted:
var service := ContractService.new()
service.company_path = COMPANY_PATH
return service
func _formation_service() -> RefCounted:
var service := FormationService.new()
service.company_path = COMPANY_PATH
return service
func _battle_service() -> RefCounted:
var service := BattleService.new()
service.company_path = COMPANY_PATH
return service
func _same_receipt(left_value, right_value) -> bool:
if not left_value is Dictionary or not right_value is Dictionary:
return false
var left: Dictionary = left_value
var right: Dictionary = right_value
if left.size() != right.size():
return false
for key in right:
if not left.has(key) or left[key] != right[key]:
return false
return true
func _cleanup() -> void:
for path in [
DRAFT_PATH, "%s.bak" % DRAFT_PATH, "%s.candidate" % DRAFT_PATH,
"%s.bak.candidate" % DRAFT_PATH, COMPANY_PATH, "%s.bak" % COMPANY_PATH,
"%s.candidate" % COMPANY_PATH, "%s.bak.candidate" % COMPANY_PATH,
"%s.corrupt" % COMPANY_PATH,
]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
func _expect(condition: bool, message: String) -> void:
if not condition:
failures.append("BAT2-4A observation service: %s" % message)