P07-01-WX: 仕上がった天候の機能の受入:自然天候・雨・嵐・世界樹と成人客のマナ霧・上部の表示と予報・保存と移行(マナ炉の産出 120 を除く。P07-01 の補助の作業。原単位への加算 0)

This commit is contained in:
Agent Control Runner
2026-10-24 05:04:48 +09:00
parent 7ac2224e47
commit c50136017b
25 changed files with 1868 additions and 28 deletions
@@ -0,0 +1,68 @@
extends RefCounted
## Approved initial weather values A13 (P07-01 SPEC sections 2 and 4). The
## timeline stays a pure state model; this file owns the draw and the effects.
const DAY_MINUTES := 1440
const DRAW_MINUTE_OF_DAY := 360
const FIRST_START_MINUTE_OF_DAY := 360
const START_SLOT_MINUTES := 30
const START_SLOTS := 29
## Rolls 0..99: clear 0-59, rain 60-84, mana fog 85-94, storm 95-99.
const CLEAR_BELOW := 60
const RAIN_BELOW := 85
const FOG_BELOW := 95
const DURATION_MINUTES := {&"rain": 360, &"mana_fog": 240, &"storm": 120}
const RAIN_ARRIVAL_PERCENT := 50
const RAIN_HOLD_MAX_MINUTES := 240
const FOG_MANA_NUMERATOR := 12
const FOG_MANA_DENOMINATOR := 10
const FOG_INTIMACY_NUMERATOR := 3
const FOG_INTIMACY_DENOMINATOR := 2
static func is_draw_minute(absolute_minute: int) -> bool:
return absolute_minute >= 0 and posmod(absolute_minute, DAY_MINUTES) == DRAW_MINUTE_OF_DAY
static func day_of(absolute_minute: int) -> int:
return int(floor(float(absolute_minute) / DAY_MINUTES)) if absolute_minute >= 0 else -1
## One draw per day from the saved weather stream. A clear day consumes one
## roll; other days also consume one start-slot roll.
static func draw(rng: DeterministicRng, day: int) -> Dictionary:
var roll := rng.randi_range(0, 99)
var kind: StringName = &"clear"
if roll >= FOG_BELOW:
kind = &"storm"
elif roll >= RAIN_BELOW:
kind = &"mana_fog"
elif roll >= CLEAR_BELOW:
kind = &"rain"
if kind == &"clear":
return {&"day": day, &"kind": kind, &"roll": roll, &"slot": -1, &"start": -1, &"end": -1}
var slot := rng.randi_range(0, START_SLOTS - 1)
var start := day * DAY_MINUTES + FIRST_START_MINUTE_OF_DAY + slot * START_SLOT_MINUTES
return {&"day": day, &"kind": kind, &"roll": roll, &"slot": slot,
&"start": start, &"end": start + int(DURATION_MINUTES[kind])}
static func arrival_percent(kind: StringName) -> int:
return RAIN_ARRIVAL_PERCENT if kind == &"rain" else 100
static func mana_yield(base_amount: int, kind: StringName) -> int:
if kind != &"mana_fog":
return base_amount
return int(floor(float(base_amount * FOG_MANA_NUMERATOR) / FOG_MANA_DENOMINATOR))
static func fog_intimacy_increase(increase: int) -> int:
if increase <= 0:
return increase
return int(floor(float(increase * FOG_INTIMACY_NUMERATOR) / FOG_INTIMACY_DENOMINATOR))
## Japanese HUD guidance for the current kind (SPEC section 4.6).
static func effect_text(kind: StringName) -> String:
match kind:
&"rain":
return "雨: 新規来客が半分になり、帰る予定の客は雨が止むまで(最大4時間)残ります。"
&"storm":
return "嵐: 新規来客はありません。館内の客は残ります。"
&"mana_fog":
return "マナ霧: 世界樹のマナ産出が1.2倍、成人確認済みの客の性欲の増え方が1.5倍になります。"
return "晴れ: 天候の影響はありません。"
@@ -0,0 +1 @@
uid://bkr55x5to4cgi
+164 -4
View File
@@ -2,14 +2,25 @@ extends RefCounted
## Weather state and scheduled transitions. The caller owns generation policy;
## this model invents neither probabilities nor rain/fog balance multipliers.
const VERSION := 1
## Version 2 also keeps the natural-weather bookkeeping (last daily draw, the
## current day's draw) and the once-per-guest rain holds; version 1 snapshots
## migrate with no draw for the past and no holds (P07-01 SPEC section 5).
const VERSION := 2
const LEGACY_VERSION := 1
const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"]
const MAX_MINUTE := 2147483647
const MAX_PENDING := 4096
const MAX_RAIN_HOLDS := 100000
const DAY_MINUTES := 1440
const DRAW_MINUTE_OF_DAY := 360
const RAIN_HOLD_MAX_MINUTES := 240
var _minute := -1
var _kind: StringName = &""
var _since := -1
var _pending: Array[Dictionary] = []
var _last_draw_day := -1
var _today_draw: Dictionary = {}
var _rain_holds: Dictionary = {}
func initialize(kind: Variant, minute: Variant) -> bool:
if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute):
@@ -17,6 +28,7 @@ func initialize(kind: Variant, minute: Variant) -> bool:
_minute = minute
_since = minute
_kind = StringName(kind)
_last_draw_day = _days_before(minute)
return true
func get_kind() -> StringName:
@@ -25,6 +37,9 @@ func get_kind() -> StringName:
func get_minute() -> int:
return _minute
func get_since() -> int:
return _since
func permits_new_arrivals() -> bool:
return _minute >= 0 and _kind != &"storm"
@@ -55,13 +70,95 @@ func advance_to(minute: Variant) -> Dictionary:
_minute = minute
return {&"ok": true, &"replayed": replayed, &"changes": changes}
## Natural weather (A13): the caller draws once per day at 06:00 and applies
## the result here. A start at the current minute switches now; later starts
## and the return to clear use the ordinary schedule rules, applied together.
func get_last_draw_day() -> int:
return _last_draw_day
func get_today_draw() -> Dictionary:
return _today_draw.duplicate(true)
func apply_draw(draw: Dictionary) -> Dictionary:
if _minute < 0 or not _valid_draw(draw):
return {&"ok": false, &"code": &"INVALID_DRAW"}
var day: int = draw[&"day"]
if day <= _last_draw_day or _minute != day * DAY_MINUTES + DRAW_MINUTE_OF_DAY:
return {&"ok": false, &"code": &"DRAW_NOT_DUE"}
var kind := StringName(draw[&"kind"])
var start: int = draw[&"start"]
var end: int = draw[&"end"]
var changes: Array[Dictionary] = []
if kind != &"clear":
for row: Dictionary in _pending:
if int(row[&"minute"]) == start or int(row[&"minute"]) == end:
return {&"ok": false, &"code": &"SCHEDULE_CONFLICT"}
if _pending.size() + 2 > MAX_PENDING:
return {&"ok": false, &"code": &"SCHEDULE_FULL"}
if start == _minute:
if kind != _kind:
changes.append({&"from": _kind, &"to": kind, &"minute": start})
_kind = kind
_since = start
else:
_pending.append({&"kind": kind, &"minute": start})
_pending.append({&"kind": &"clear", &"minute": end})
_pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"])
_last_draw_day = day
_today_draw = _copy_draw(draw)
return {&"ok": true, &"changes": changes}
## The next scheduled change away from the current kind, for the forecast.
func next_change() -> Dictionary:
for row: Dictionary in _pending:
if row[&"kind"] != _kind:
return {&"kind": row[&"kind"], &"minute": row[&"minute"]}
return {}
## When the current rain ends (-1 when it is not raining or no end is known).
func rain_end_minute() -> int:
if _kind != &"rain":
return -1
for row: Dictionary in _pending:
if row[&"kind"] != &"rain":
return int(row[&"minute"])
return -1
func get_rain_hold(guest_id: StringName) -> Dictionary:
var row: Variant = _rain_holds.get(String(guest_id), null)
return row.duplicate(true) if row is Dictionary else {}
func get_rain_holds() -> Dictionary:
return _rain_holds.duplicate(true)
func add_rain_hold(guest_id: StringName, original_exit: int, extended_to: int) -> bool:
var key := String(guest_id)
if key.is_empty() or _rain_holds.has(key) or _rain_holds.size() >= MAX_RAIN_HOLDS \
or not _valid_hold({"original_exit": original_exit, "extended_to": extended_to}):
return false
_rain_holds[key] = {"original_exit": original_exit, "extended_to": extended_to}
return true
## Holds of guests that have left are history; the caller passes who remains.
func prune_rain_holds(present_guest_ids: Dictionary) -> void:
for key: String in _rain_holds.keys():
if not present_guest_ids.has(StringName(key)):
_rain_holds.erase(key)
func create_snapshot() -> Dictionary:
return {&"version": VERSION, &"minute": _minute, &"kind": _kind,
&"since": _since, &"pending": _pending.duplicate(true)}
&"since": _since, &"pending": _pending.duplicate(true),
&"natural": {&"last_draw_day": _last_draw_day, &"today": _today_draw.duplicate(true)},
&"rain_holds": _rain_holds.duplicate(true)}
func restore_snapshot(source: Dictionary) -> bool:
if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
var version: Variant = source.get("version", source.get(&"version", null))
if typeof(version) != TYPE_INT or version not in [LEGACY_VERSION, VERSION]:
return false
var expected := ["version", "minute", "kind", "since", "pending"]
if version == VERSION:
expected = expected + ["natural", "rain_holds"]
if not _keys(source, expected) \
or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \
or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
@@ -75,12 +172,75 @@ func restore_snapshot(source: Dictionary) -> bool:
return false
previous = value["minute"]
pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]})
var last_draw_day := _days_before(source["minute"])
var today: Dictionary = {}
var holds: Dictionary = {}
if version == VERSION:
var natural: Variant = source["natural"]
if not natural is Dictionary or not _keys(natural, ["last_draw_day", "today"]) \
or typeof(natural["last_draw_day"]) != TYPE_INT or natural["last_draw_day"] < -1 \
or natural["last_draw_day"] > _day_of(source["minute"]) or not natural["today"] is Dictionary:
return false
last_draw_day = natural["last_draw_day"]
if not natural["today"].is_empty():
if not _valid_draw(natural["today"]) or int(natural["today"]["day"]) != last_draw_day:
return false
today = _copy_draw(natural["today"])
var raw_holds: Variant = source["rain_holds"]
if not raw_holds is Dictionary or raw_holds.size() > MAX_RAIN_HOLDS:
return false
for key: Variant in raw_holds:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key).is_empty() \
or not raw_holds[key] is Dictionary or not _keys(raw_holds[key], ["original_exit", "extended_to"]) \
or not _valid_hold(raw_holds[key]):
return false
holds[String(key)] = {"original_exit": int(raw_holds[key]["original_exit"]),
"extended_to": int(raw_holds[key]["extended_to"])}
_minute = source["minute"]
_kind = StringName(source["kind"])
_since = source["since"]
_pending = pending
_last_draw_day = last_draw_day
_today_draw = today
_rain_holds = holds
return true
## A day whose 06:00 is before the minute counts as drawn, so a new or migrated
## timeline never draws for the past (SPEC section 5.2). At exactly 06:00 the
## day is still due, which lets a new game draw its opening day.
static func _days_before(minute: int) -> int:
var day := _day_of(minute)
return day if posmod(minute, DAY_MINUTES) > DRAW_MINUTE_OF_DAY else day - 1
static func _day_of(minute: int) -> int:
return int(floor(float(minute) / DAY_MINUTES))
static func _valid_draw(draw: Variant) -> bool:
if not draw is Dictionary or not _keys(draw, ["day", "kind", "roll", "slot", "start", "end"]):
return false
for key: String in ["day", "roll", "slot", "start", "end"]:
if typeof(draw[key]) != TYPE_INT:
return false
if not _valid_kind(draw["kind"]) or draw["day"] < 0 or draw["roll"] < 0 or draw["roll"] > 99:
return false
if StringName(draw["kind"]) == &"clear":
return draw["slot"] == -1 and draw["start"] == -1 and draw["end"] == -1
var day_start: int = draw["day"] * DAY_MINUTES + DRAW_MINUTE_OF_DAY
return draw["slot"] >= 0 and draw["start"] >= day_start and draw["start"] <= day_start + 14 * 60 \
and draw["end"] > draw["start"] and draw["end"] < day_start + DAY_MINUTES
static func _copy_draw(draw: Dictionary) -> Dictionary:
var result := {}
for key: String in ["day", "kind", "roll", "slot", "start", "end"]:
result[key] = draw[key] if key != "kind" else String(draw[key])
return result
static func _valid_hold(row: Dictionary) -> bool:
var original: Variant = row.get("original_exit", null)
var extended: Variant = row.get("extended_to", null)
return typeof(original) == TYPE_INT and typeof(extended) == TYPE_INT and original >= 0 \
and extended > original and extended <= original + RAIN_HOLD_MAX_MINUTES
static func _valid_kind(value: Variant) -> bool:
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS
+5 -1
View File
@@ -365,6 +365,10 @@ func _update_needs_runtime(
delta_medical += int(normalized_effects.get(&"medical", 0))
delta_leisure += int(normalized_effects.get(&"leisure", 0))
delta_intimacy += int(normalized_effects.get(&"intimacy", 0))
## A13: mana fog raises an adult-verified guest's positive intimacy increase
## to floor(x1.5) = n + floor(n / 2); decreases and other guests are unchanged.
if definition.adult_verified and delta_intimacy > 0 and bool(_value(context, &"mana_fog", false)):
delta_intimacy += delta_intimacy >> 1
var after_hunger := clampi(before_hunger + delta_hunger, 0, 100)
var after_sleep := clampi(before_sleep + delta_sleep, 0, 100)
@@ -1157,7 +1161,7 @@ func _need_context_error(context: Dictionary) -> String:
var raw_value: Variant = _value(context, field_id, null)
if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100:
return "%s must be an integer from 0 through 100." % field_id
for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated"]:
for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated", &"mana_fog"]:
if _has_value(context, field_id) and not _value(context, field_id, null) is bool:
return "%s must be boolean." % field_id
if _has_value(context, &"need_effects"):
+20 -13
View File
@@ -11,6 +11,10 @@ const CAPACITY := 300
const WORLD_TREE_GENERATION := 20
const INTERVAL_MINUTES := 10
const FURNACE_YIELD := 100
## A13 mana fog: world tree and furnace yields x1.2 (floor) at fog boundaries.
const FOG_WORLD_TREE_GENERATION := 24
const FOG_FURNACE_YIELD := 120
const GENERATIONS: Array[int] = [WORLD_TREE_GENERATION, FOG_WORLD_TREE_GENERATION]
const LIMIT := 1000000000
const FLOW_KEYS: Array[String] = [
"from_minute", "to_minute", "before", "generation", "furnace", "demand",
@@ -35,12 +39,14 @@ func get_last_flow() -> Dictionary:
## Processes every boundary in (last boundary, minute]. Boundaries already
## processed are no-ops, so per-minute callers and same-minute retries are safe.
## Mana may rise between boundaries (event refunds, commands); anything above
## capacity is discarded and recorded as overflow at the next boundary.
func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary:
## capacity is discarded and recorded as overflow at the next boundary. The
## caller passes the world tree yield of the boundary (A13 fog: 24, else 20).
func advance_to(minute: Variant, before: Variant, demand: Variant, generation: Variant = WORLD_TREE_GENERATION) -> Dictionary:
if _minute < 0:
return _failure(&"NOT_STARTED")
if typeof(minute) != TYPE_INT or not _amount(before) \
or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute:
or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute \
or typeof(generation) != TYPE_INT or generation not in GENERATIONS:
return _failure(&"INVALID_FLOW")
var boundary: int = minute - minute % INTERVAL_MINUTES
if boundary <= _minute:
@@ -49,7 +55,7 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
var flow := {}
var count := 0
for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES):
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute)
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute, generation)
amount = flow[&"after"]
count += 1
_minute = boundary
@@ -57,17 +63,18 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary
return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)}
## Furnace offers are judged after natural generation and upkeep demand are
## paid: base_after = min(C, max(0, B + G - D)); accept only with 100 free.
static func furnace_offer_accepted(before: int, demand: int) -> bool:
var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand))
return CAPACITY - base_after >= FURNACE_YIELD
## paid: base_after = min(C, max(0, B + G - D)); accept only with the yield free
## (100, or 120 at an A13 fog boundary where G is 24).
static func furnace_offer_accepted(before: int, demand: int, generation: int = WORLD_TREE_GENERATION, furnace_yield: int = FURNACE_YIELD) -> bool:
var base_after := mini(CAPACITY, maxi(0, before + generation - demand))
return CAPACITY - base_after >= furnace_yield
static func plan(before: int, demand: int, furnace: int, start: int, end: int) -> Dictionary:
var available := before + WORLD_TREE_GENERATION + furnace
static func plan(before: int, demand: int, furnace: int, start: int, end: int, generation: int = WORLD_TREE_GENERATION) -> Dictionary:
var available := before + generation + furnace
var consumed := mini(available, demand)
var remaining := available - consumed
return {&"from_minute": start, &"to_minute": end, &"before": before,
&"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand,
&"generation": generation, &"furnace": furnace, &"demand": demand,
&"consumed": consumed, &"shortage": demand - consumed,
&"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)}
@@ -105,10 +112,10 @@ func restore_snapshot(source: Variant) -> bool:
if not _amount(flow[key]):
return false
if flow["to_minute"] != source["minute"] \
or flow["generation"] != WORLD_TREE_GENERATION \
or flow["generation"] not in GENERATIONS \
or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]:
return false
var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"])
var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"], flow["generation"])
if not _same_flow(expected, flow):
return false
_minute = source["minute"]
+26 -1
View File
@@ -1013,6 +1013,15 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
)
var external_capacity := int(capacity_value)
# A13: the arrival boundary reports the weather share of planned arrivals
# (rain 50, otherwise 100). Absent means 100 for callers without weather.
var percent_value: Variant = capture.get(&"arrival_percent", 100)
if not _is_integral_value(percent_value) or int(percent_value) < 0 or int(percent_value) > 100:
return _failed_boundary_after_capture(
_arrival_boundary, capture_payload, token,
&"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID"
)
var arrival_percent := int(percent_value)
var remaining := mini(
mini(local_capacity, external_capacity),
int(_config[&"max_arrivals_per_interval"])
@@ -1022,6 +1031,8 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
var plans: Array[Dictionary] = []
var total_count := 0
var capped_rows: Array[Dictionary] = []
var accepted_by_row: Array[int] = []
var planned_total := 0
for row: Dictionary in intent_rows:
var accepted := mini(int(row[&"desired_count"]), remaining)
var key: String = row[&"key"]
@@ -1034,6 +1045,21 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
&"guest_class": String(row[&"guest_class"]),
&"discarded_numerator": retained - capped_retained,
})
accepted_by_row.append(accepted)
planned_total += accepted
remaining -= accepted
# Rain keeps floor(planned * percent / 100) arrivals; the dropped arrivals
# are lost to the weather and are not carried to the next boundary.
var dropped := planned_total - planned_total * arrival_percent / 100
var drop_index := accepted_by_row.size() - 1
while dropped > 0 and drop_index >= 0:
var cut := mini(dropped, accepted_by_row[drop_index])
accepted_by_row[drop_index] -= cut
dropped -= cut
drop_index -= 1
for row_index in intent_rows.size():
var row: Dictionary = intent_rows[row_index]
var accepted := accepted_by_row[row_index]
if accepted <= 0:
continue
if plan_sequence > MAX_SEQUENCE_VALUE \
@@ -1058,7 +1084,6 @@ func _process_arrival_boundary(minute: int) -> CommandResult:
})
plan_sequence += 1
total_count += accepted
remaining -= accepted
var payload := capture_payload.duplicate(true)
payload[&"plans"] = plans