extends Node ## 경제·배치 로직 스모크 테스트. 저장 파일은 건드리지 않고 메모리 안에서만 돈다. ## Godot_v4.7.2-stable_win64_console.exe --headless --path . res://tests/smoke.tscn var _failures := 0 func _ready() -> void: # 이 테스트는 실제 저장 파일을 건드리면 안 된다. # 끄지 않으면 종료할 때 초기화된 상태가 저장되어 진행도가 날아간다. SaveManager.persistence_enabled = false GameState.reset() _test_click() _test_producer_cost_curve() _test_place() _test_footprint_and_multi_tile() _test_zone_boundaries() _test_move() _test_remove_refund() _test_space_is_the_limit() _test_zone_gating() _test_zone_unlock() _test_zone_beauty_is_per_zone() _test_ramp_continuity() _test_serialization_round_trip() _test_legacy_save_migration() _test_number_format() _test_korean_particles() if _failures == 0: print("모든 검사 통과") else: print("실패 %d건" % _failures) get_tree().quit(1 if _failures > 0 else 0) func check(label: String, condition: bool, detail := "") -> void: if condition: print(" ok ", label) else: _failures += 1 print(" FAIL ", label, " ", detail) ## 모든 구역을 열고 거품을 넉넉히 준 상태에서 시작한다. func _rich_and_open() -> void: GameState.reset() GameState.unlocked_zones = Catalog.zone_count() GameState.bubbles = 1e15 func _test_click() -> void: print("클릭") GameState.reset() var gained := GameState.click() check("클릭 한 번에 기본 1 거품", is_equal_approx(gained, 1.0), str(gained)) check("잔액과 누적이 함께 오른다", is_equal_approx(GameState.bubbles, 1.0) and is_equal_approx(GameState.total_bubbles, 1.0)) func _test_producer_cost_curve() -> void: print("가격 곡선") GameState.reset() GameState.bubbles = 1000.0 var first := GameState.producer_cost("bubble_stone") check("첫 거품돌은 15", is_equal_approx(first, 15.0), str(first)) GameState.place("producer", "bubble_stone", 0, 0) var second := GameState.producer_cost("bubble_stone") check("놓을수록 비싸진다 (x1.15)", second > first and second <= ceil(15.0 * 1.15) + 0.001, "%f -> %f" % [first, second]) func _test_place() -> void: print("배치") GameState.reset() GameState.bubbles = 1000.0 var before := GameState.bubbles var index := GameState.place("producer", "seaweed", 2, 3) check("빈 칸에 놓인다", index == 0, str(index)) check("가격만큼 빠진다", is_equal_approx(GameState.bubbles, before - 110.0), str(GameState.bubbles)) check("그 칸이 자기 것이 된다", GameState.placement_at(2, 3) == 0) check("옆 칸은 여전히 빈다", GameState.placement_at(3, 3) == -1) check("아름다움이 붙는다", is_equal_approx(GameState.beauty, 3.0), str(GameState.beauty)) check("같은 칸에 또 놓을 수 없다", GameState.place("producer", "seaweed", 2, 3) == -1) check("격자 밖에는 놓을 수 없다", GameState.place("producer", "seaweed", 2, 99) == -1) GameState.bubbles = 5.0 check("잔액이 모자라면 못 놓는다", GameState.place("producer", "bubble_stone", 5, 0) == -1) check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0)) func _test_footprint_and_multi_tile() -> void: print("여러 칸짜리") _rich_and_open() var span := Catalog.size_of("ornament", "shipwreck") check("난파선은 3x2", span == Vector2i(3, 2), str(span)) # 산호초(1번 구역)의 두 번째 열에 난파선(3x2)을 놓으면 세 열 두 행을 덮는다. var left := Catalog.ZONE_COLUMNS + 1 var index := GameState.place("ornament", "shipwreck", left, 0) check("놓인다", index >= 0, str(index)) var covered := 0 for col in range(left, left + 3): for row in 2: if GameState.placement_at(col, row) == index: covered += 1 check("여섯 칸을 모두 차지한다", covered == 6, str(covered)) check("발자국 안쪽에는 겹쳐 놓을 수 없다", GameState.place("producer", "clownfish", left + 1, 1) == -1) check("발자국 바로 옆에는 놓인다", GameState.place("producer", "clownfish", left + 3, 1) >= 0) func _test_zone_boundaries() -> void: print("구역 경계") _rich_and_open() # 얕은 바다는 0번 구역. 해초는 그 구역 것이라 다음 구역 첫 열에는 못 간다. var last := Catalog.ZONE_COLUMNS - 1 check("자기 구역 안에는 놓인다", GameState.can_place_at("producer", "seaweed", last, 0)) check("남의 구역에는 못 놓는다", not GameState.can_place_at("producer", "seaweed", last + 1, 0)) # 항아리(2x1)를 마지막 열에 놓으면 다음 구역까지 걸친다. check("구역을 걸치면 못 놓는다", not GameState.can_place_at("ornament", "amphora", last, 0)) check("한 구역 안에 들어가면 놓인다", GameState.can_place_at("ornament", "amphora", last - 1, 0)) func _test_move() -> void: print("옮기기") _rich_and_open() var index := GameState.place("producer", "seaweed", 1, 1) var spent := GameState.bubbles check("빈 칸으로 옮겨진다", GameState.move(index, 4, 2)) check("옮기는 건 공짜", is_equal_approx(GameState.bubbles, spent), str(GameState.bubbles)) check("옛 칸이 비었다", GameState.placement_at(1, 1) == -1) check("새 칸이 자기 것", GameState.placement_at(4, 2) == index) GameState.place("producer", "seaweed", 0, 0) check("남이 있는 칸으로는 못 옮긴다", not GameState.move(index, 0, 0)) check("실패했으면 제자리", GameState.placement_at(4, 2) == index) check("제자리에 그대로 놓는 건 된다", GameState.move(index, 4, 2)) func _test_remove_refund() -> void: print("치우기와 환불") _rich_and_open() GameState.place("producer", "seaweed", 0, 0) var index := GameState.place("producer", "seaweed", 1, 0) var before := GameState.bubbles # 두 번째 해초 가격은 올림(110 * 1.15). 그 절반이 돌아온다. var expected: float = floor(ceil(110.0 * 1.15) * Catalog.REFUND_RATE) var refund := GameState.remove(index) check("절반을 돌려받는다", is_equal_approx(refund, expected), "%f vs %f" % [refund, expected]) check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected)) check("칸이 비었다", GameState.placement_at(1, 0) == -1) check("개수가 줄었다", GameState.count_of("seaweed") == 1) var oi := GameState.place("ornament", "amphora", 2, 0) check("장식은 하나만", GameState.place("ornament", "amphora", 4, 0) == -1) GameState.remove(oi) check("치우면 다시 살 수 있다", GameState.place("ornament", "amphora", 4, 0) >= 0) func _test_space_is_the_limit() -> void: print("공간이 곧 한계") _rich_and_open() var total := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS check("구역 하나는 %d칸" % total, GameState.free_cells_in_zone(0) == total, str(GameState.free_cells_in_zone(0))) var placed := 0 while true: var spot := GameState.first_free_spot("producer", "seaweed") if spot.x < 0: break if GameState.place("producer", "seaweed", spot.x, spot.y) < 0: break placed += 1 check("칸이 차면 더 못 놓는다", placed == total, str(placed)) check("남은 칸 0", GameState.free_cells_in_zone(0) == 0) check("거품이 남아도 못 놓는다", GameState.place("producer", "seaweed", 0, 0) == -1) check("다른 구역은 아직 비어 있다", GameState.free_cells_in_zone(1) == total, str(GameState.free_cells_in_zone(1))) func _test_zone_gating() -> void: print("구역 잠금") GameState.reset() GameState.bubbles = 1e15 check("시작은 1구역만 열림", GameState.unlocked_zones == 1) check("0구역 물건은 보인다", GameState.is_revealed("producer", "bubble_stone")) check("1구역 물건은 안 보인다", not GameState.is_revealed("producer", "clownfish")) check("돈이 넘쳐도 잠긴 구역에는 못 놓는다", GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) == -1) check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 1e15)) GameState.unlocked_zones = 2 check("열린 뒤에는 보인다", GameState.is_revealed("producer", "clownfish")) check("열린 뒤에는 놓인다", GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) >= 0) func _test_zone_unlock() -> void: print("구역 해금") GameState.reset() var need_beauty := GameState.next_zone_required_beauty() var cost := GameState.next_zone_cost() check("2구역 해금 조건이 정의되어 있다", need_beauty > 0.0 and cost > 0.0, "beauty=%f cost=%f" % [need_beauty, cost]) GameState.bubbles = cost * 4.0 check("앞 구역을 안 꾸미면 못 연다", not GameState.can_unlock_next()) check("사유가 아름다움 부족이라고 나온다", GameState.next_zone_blocker().contains("꾸며야"), GameState.next_zone_blocker()) # 앞 구역(얕은 바다)을 해초로 채워 아름다움을 올린다. 한 포기당 3. while GameState.beauty_in_zone(0) < need_beauty: var spot := GameState.first_free_spot("producer", "seaweed") if spot.x < 0 or GameState.place("producer", "seaweed", spot.x, spot.y) < 0: break check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty, "%f / %f" % [GameState.beauty_in_zone(0), need_beauty]) GameState.bubbles = cost check("조건을 채우면 열 수 있다", GameState.can_unlock_next(), GameState.next_zone_blocker()) check("해금 성공", GameState.unlock_next_zone()) check("해금 비용이 빠진다", is_equal_approx(GameState.bubbles, 0.0), str(GameState.bubbles)) check("열린 구역이 2개", GameState.unlocked_zones == 2) func _test_zone_beauty_is_per_zone() -> void: print("구역별 아름다움") _rich_and_open() GameState.place("producer", "seaweed", 0, 0) # 0구역, 아름다움 3 GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) # 1구역, 아름다움 8 check("0구역에 3", is_equal_approx(GameState.beauty_in_zone(0), 3.0), str(GameState.beauty_in_zone(0))) check("1구역에 8", is_equal_approx(GameState.beauty_in_zone(1), 8.0), str(GameState.beauty_in_zone(1))) check("총합은 11", is_equal_approx(GameState.beauty, 11.0), str(GameState.beauty)) check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0)) func _test_ramp_continuity() -> void: print("색 램프") var n := Catalog.zone_count() check("왼쪽 끝은 첫 구역 색", Catalog.ramp_color("tint", 0.0).is_equal_approx(Catalog.ZONES[0].tint)) check("오른쪽 끝은 마지막 구역 색", Catalog.ramp_color("tint", 1.0).is_equal_approx(Catalog.ZONES[n - 1].tint)) # 램프가 끊기지 않아야 구역 경계에 이음매가 보이지 않는다. var max_jump := 0.0 var prev := Catalog.ramp_color("tint", 0.0) for i in range(1, 201): var c := Catalog.ramp_color("tint", float(i) / 200.0) max_jump = maxf(max_jump, absf(c.r - prev.r) + absf(c.g - prev.g) + absf(c.b - prev.b)) prev = c check("램프에 색이 튀는 곳이 없다", max_jump < 0.05, "최대 변화 %f" % max_jump) # 구역 중심에서는 그 구역 값 그대로여야 한다. var center_ok := true for i in n: var f := (float(i) + 0.5) / n if not Catalog.ramp_color("sand", f).is_equal_approx(Catalog.ZONES[i].sand): center_ok = false if not is_equal_approx(Catalog.ramp_float("murk", f), float(Catalog.ZONES[i].murk)): center_ok = false check("구역 중심은 그 구역 값 그대로", center_ok) func _test_serialization_round_trip() -> void: print("저장 직렬화") _rich_and_open() GameState.place("producer", "seaweed", 1, 1) GameState.place("producer", "seaweed", 2, 1) GameState.place("ornament", "shipwreck", Catalog.ZONE_COLUMNS + 1, 2) GameState.upgrades["u_touch1"] = true GameState.clicks = 42 # 배치가 끝난 뒤에 잔액을 맞춘다. 배치는 값을 치르기 때문에 먼저 정하면 어긋난다. GameState.bubbles = 12345.0 GameState.recalc() var bps_before := GameState.bps # 실제 저장 경로와 똑같이 JSON 문자열을 거쳐서 되돌린다. var text := JSON.stringify(GameState.to_dict()) GameState.reset() GameState.from_dict(JSON.parse_string(text)) check("보유량 복원", is_equal_approx(GameState.bubbles, 12345.0), str(GameState.bubbles)) check("배치 개수 복원", GameState.placements.size() == 3, str(GameState.placements.size())) var wreck := Catalog.ZONE_COLUMNS + 1 check("배치된 칸까지 그대로", GameState.placement_at(2, 1) >= 0 and GameState.placement_at(wreck, 2) >= 0) check("난파선 발자국도 복원", GameState.placement_at(wreck + 2, 3) == GameState.placement_at(wreck, 2)) check("업그레이드 복원", GameState.has_upgrade("u_touch1")) check("클릭 수 복원", GameState.clicks == 42) check("열린 구역 수 복원", GameState.unlocked_zones == Catalog.zone_count(), str(GameState.unlocked_zones)) check("초당 생산량이 그대로", is_equal_approx(GameState.bps, bps_before), "%f vs %f" % [GameState.bps, bps_before]) func _test_legacy_save_migration() -> void: print("옛 저장본 이전") GameState.reset() # 격자가 없던 시절의 저장본. 개수만 있고 위치가 없다. GameState.from_dict({ "bubbles": 500.0, "unlocked_zones": 2, "producers": {"seaweed": 4, "clownfish": 2}, "ornaments": {"amphora": true}, }) check("개수만큼 칸에 놓인다", GameState.placements.size() == 7, str(GameState.placements.size())) check("해초 4포기", GameState.count_of("seaweed") == 4, str(GameState.count_of("seaweed"))) check("장식도 옮겨온다", GameState.has_ornament("amphora")) var all_in_zone := true for p in GameState.placements: if Catalog.zone_of_column(p.col) != Catalog.zone_of(p.kind, p.id): all_in_zone = false check("각자 제 구역에 놓인다", all_in_zone) GameState.from_dict({"bubbles": 1.0}) check("구역 개념이 없던 저장본은 1구역으로", GameState.unlocked_zones == 1) func _test_number_format() -> void: print("숫자 표기") check("999", NumberFormat.short(999.0) == "999", NumberFormat.short(999.0)) check("1.23K", NumberFormat.short(1234.0) == "1.23K", NumberFormat.short(1234.0)) check("12.3M", NumberFormat.short(12_345_678.0) == "12.3M", NumberFormat.short(12_345_678.0)) check("1.00B", NumberFormat.short(1e9) == "1.00B", NumberFormat.short(1e9)) check("소수점", NumberFormat.short(2.5) == "2.5", NumberFormat.short(2.5)) check("시간 표기", NumberFormat.duration(3 * 3600 + 720) == "3시간 12분", NumberFormat.duration(3 * 3600 + 720)) func _test_korean_particles() -> void: print("한글 조사") check("받침 있으면 을", Korean.eul("켈프 숲") == "켈프 숲을", Korean.eul("켈프 숲")) check("받침 없으면 를", Korean.eul("얕은 바다") == "얕은 바다를", Korean.eul("얕은 바다")) check("받침 있으면 이", Korean.i("등불") == "등불이", Korean.i("등불")) check("받침 없으면 가", Korean.i("심해") == "심해가", Korean.i("심해")) var ok := true for z in Catalog.ZONES: if not Korean.eul(String(z.name)).begins_with(String(z.name)): ok = false check("구역 이름 전부 처리된다", ok)