244 lines
13 KiB
GDScript
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)
|