Implement Corporate Reputation allocation hub across 3 strategic branches and Boardroom brand legacy perk

This commit is contained in:
Christian 2026-10-16 21:31:02 +02:00
parent 221ed97415
commit 8aa76c805a
12 changed files with 757 additions and 27 deletions

View file

@ -22,7 +22,8 @@ BOARDROOM_PERK_COSTS = {
'robotics_overclock': [2, 5, 12, 30, 75],
'logistics_efficiency': [2, 6, 15, 40, 100],
'government_subsidies': [3, 8, 20, 55, 120],
'angel_syndicate': [5, 15, 45, 120, 300]
'angel_syndicate': [5, 15, 45, 120, 300],
'brand_legacy': [4, 10, 25, 70, 150]
}
def clean_round(val: float) -> float:
@ -45,6 +46,7 @@ def get_initial_state() -> dict:
'totalVentureShares': 0,
'ipoCount': 0,
'reputation': 0,
'repAllocations': {'b2b': 0, 'clients': 0, 'brand': 0},
'buyMode': '1',
'activeTab': 'production',
'selectedSector': 'all',
@ -95,9 +97,15 @@ def process_offline_and_ticks(state: dict) -> dict:
return state
perks = state.get('boardroomPerks', {})
supply_yield_mult = 1.0 + 0.15 * perks.get('supply_sync', 0)
robotics_speed_mult = 1.0 + 0.20 * perks.get('robotics_overclock', 0)
logistics_price_mult = 1.0 + 0.25 * perks.get('logistics_efficiency', 0)
rep_alloc = state.get('repAllocations', {}) if isinstance(state.get('repAllocations'), dict) else {}
brand_rep = rep_alloc.get('brand', 0)
clients_rep = rep_alloc.get('clients', 0)
# Multipliers
supply_yield_mult = (1.0 + 0.15 * perks.get('supply_sync', 0)) * (1.0 + 0.002 * clients_rep)
robotics_speed_mult = (1.0 + 0.20 * perks.get('robotics_overclock', 0)) * (1.0 + 0.0015 * clients_rep)
# Brand Premium: +0.25% wholesale price per point
logistics_price_mult = (1.0 + 0.25 * perks.get('logistics_efficiency', 0)) * (1.0 + 0.0025 * brand_rep)
# Simulate in reasonable sub-steps for chained DAG dependency
sub_steps = max(1, min(int(elapsed), 120))
@ -225,7 +233,9 @@ def apply_actions(state: dict, actions: list) -> dict:
if pid in PRODUCT_CONFIGS and pid in state.get('products', {}):
p_cfg = PRODUCT_CONFIGS[pid]
current_lines = state['products'][pid].get('lines', 0)
cost = p_cfg['baseFacilityCost'] * math.pow(1.15, current_lines)
client_rep = state.get('repAllocations', {}).get('clients', 0) if isinstance(state.get('repAllocations'), dict) else 0
line_discount = min(0.50, 0.002 * client_rep)
cost = p_cfg['baseFacilityCost'] * math.pow(1.15, current_lines) * (1.0 - line_discount)
if state.get('cash', 0.0) >= cost:
state['cash'] -= cost
state['products'][pid]['lines'] = current_lines + 1
@ -278,13 +288,30 @@ def apply_actions(state: dict, actions: list) -> dict:
preserved_total_shares = state.get('totalVentureShares', 0) + earned
preserved_perks = state.get('boardroomPerks', {})
preserved_ipos = state.get('ipoCount', 0) + 1
# Calculate preserved reputation from brand_legacy perk (10% to 50%)
brand_lvl = preserved_perks.get('brand_legacy', 0)
rep_retention_ratios = [0.0, 0.10, 0.20, 0.30, 0.40, 0.50]
ratio = rep_retention_ratios[min(brand_lvl, len(rep_retention_ratios) - 1)]
total_current_rep = state.get('reputation', 0)
allocs = state.get('repAllocations', {})
total_rep_accumulated = total_current_rep + sum(allocs.values()) if isinstance(allocs, dict) else total_current_rep
preserved_rep = int(math.floor(total_rep_accumulated * ratio))
# Starting cash from angel_syndicate
angel_lvl = preserved_perks.get('angel_syndicate', 0)
starting_cash_bonuses = [30.0, 500.0, 5000.0, 50000.0, 500000.0, 5000000.0]
new_starting_cash = starting_cash_bonuses[min(angel_lvl, len(starting_cash_bonuses) - 1)]
# Reset to clean initial state
state = get_initial_state()
state['cash'] = new_starting_cash
state['ventureShares'] = preserved_shares
state['totalVentureShares'] = preserved_total_shares
state['boardroomPerks'] = preserved_perks
state['ipoCount'] = preserved_ipos
state['reputation'] = preserved_rep
state['repAllocations'] = {'b2b': 0, 'clients': 0, 'brand': 0}
elif atype == 'BUY_PERK':
perk_id = act.get('perkId')
@ -297,12 +324,51 @@ def apply_actions(state: dict, actions: list) -> dict:
state['ventureShares'] -= req_shares
state['boardroomPerks'][perk_id] = current_lvl + 1
elif atype == 'ALLOCATE_REP':
dept = act.get('department')
delta = act.get('delta', 0)
if 'repAllocations' not in state or not isinstance(state['repAllocations'], dict):
state['repAllocations'] = {'b2b': 0, 'clients': 0, 'brand': 0}
allocs = state['repAllocations']
if delta == 'reset':
# Return all points to available pool
freed = sum(allocs.values())
state['reputation'] = state.get('reputation', 0) + freed
state['repAllocations'] = {'b2b': 0, 'clients': 0, 'brand': 0}
elif dept in ('b2b', 'clients', 'brand'):
try:
delta_int = int(delta)
if delta_int > 0:
# Allocating points from pool
to_alloc = min(delta_int, state.get('reputation', 0))
if to_alloc > 0:
state['reputation'] -= to_alloc
allocs[dept] = allocs.get(dept, 0) + to_alloc
elif delta_int < 0:
# De-allocating points back to pool
to_free = min(abs(delta_int), allocs.get(dept, 0))
if to_free > 0:
allocs[dept] -= to_free
state['reputation'] = state.get('reputation', 0) + to_free
except (ValueError, TypeError):
pass
return state
def sanitize_state(raw: dict) -> dict:
"""Ensures incoming state doesn't have NaN or negative numbers and dynamically populates new products."""
if not isinstance(raw, dict):
return get_initial_state()
if 'repAllocations' not in raw or not isinstance(raw['repAllocations'], dict):
raw['repAllocations'] = {'b2b': 0, 'clients': 0, 'brand': 0}
else:
for k in ('b2b', 'clients', 'brand'):
if k not in raw['repAllocations'] or not isinstance(raw['repAllocations'][k], (int, float)):
raw['repAllocations'][k] = 0
else:
raw['repAllocations'][k] = max(0, int(raw['repAllocations'][k]))
# Dynamic re-hydration: Ensure any new products added to the catalog exist in the player's saved state
if 'products' not in raw or not isinstance(raw['products'], dict):

View file

@ -156,6 +156,50 @@ def test_production_target_throttle():
assert state['products']['wheat']['stock'] == stock_before # Zero production when throttled to 0%
print(f"PASS: test_production_target_throttle (50%: {produced_at_50}, 100%: {produced_at_100})")
def test_reputation_allocation_and_retention():
state = engine.get_initial_state()
state['reputation'] = 100
# Allocate 30 to B2B, 40 to clients, 20 to brand
actions = [
{'type': 'ALLOCATE_REP', 'department': 'b2b', 'delta': 30},
{'type': 'ALLOCATE_REP', 'department': 'clients', 'delta': 40},
{'type': 'ALLOCATE_REP', 'department': 'brand', 'delta': 20}
]
state = engine.apply_actions(state, actions)
assert state['reputation'] == 10
assert state['repAllocations']['b2b'] == 30
assert state['repAllocations']['clients'] == 40
assert state['repAllocations']['brand'] == 20
# Test Titan discount on lines (40 * 0.2% = 8% discount)
p_cfg = engine.PRODUCT_CONFIGS['flour']
cost_base = p_cfg['baseFacilityCost']
expected_cost = cost_base * 0.92
state['cash'] = expected_cost + 10.0
cash_before = state['cash']
state = engine.apply_actions(state, [{'type': 'BUY_LINES', 'productId': 'flour'}])
assert abs(state['cash'] - (cash_before - expected_cost)) < 0.01
# Reset test
state = engine.apply_actions(state, [{'type': 'ALLOCATE_REP', 'delta': 'reset'}])
assert state['reputation'] == 100
assert state['repAllocations']['b2b'] == 0
assert state['repAllocations']['clients'] == 0
assert state['repAllocations']['brand'] == 0
# IPO without brand_legacy perk -> 0 rep preserved
state['lifetimeRevenue'] = 400000.0
state_no_perk = engine.apply_actions(dict(state), [{'type': 'TRIGGER_IPO'}])
assert state_no_perk['reputation'] == 0
# IPO with brand_legacy level 3 -> 30% rep preserved (30 Rep)
state['boardroomPerks']['brand_legacy'] = 3
state_with_perk = engine.apply_actions(dict(state), [{'type': 'TRIGGER_IPO'}])
assert state_with_perk['reputation'] == 30
assert state_with_perk['repAllocations'] == {'b2b': 0, 'clients': 0, 'brand': 0}
print("PASS: test_reputation_allocation_and_retention")
if __name__ == '__main__':
test_initial_state()
test_raw_production_and_logistics()
@ -163,5 +207,6 @@ if __name__ == '__main__':
test_chained_microchips_and_boards()
test_policy_toggles()
test_production_target_throttle()
test_reputation_allocation_and_retention()
test_ipo_and_shares()
print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!")

View file

@ -182,3 +182,306 @@
color: var(--emerald-dark);
}
/* ==========================================================================
CORPORATE REPUTATION ALLOCATION HUB
========================================================================== */
.rep-hub-container {
background: linear-gradient(135deg, var(--bg-surface) 0%, var(--bg-surface-sub) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: var(--shadow-sm);
margin-bottom: 8px;
}
.rep-hub-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
padding-bottom: 16px;
}
.rep-hub-title-group {
display: flex;
align-items: center;
gap: 14px;
}
.rep-hub-icon {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.rep-hub-title {
font-size: 1.15rem;
font-weight: 800;
color: var(--text-primary);
margin: 0;
}
.rep-hub-desc {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 3px;
max-width: 620px;
}
.rep-pool-card {
display: flex;
align-items: center;
gap: 18px;
background: var(--bg-surface);
border: 1px solid var(--border-color);
padding: 10px 16px;
border-radius: var(--radius-md);
}
.rep-pool-info {
display: flex;
flex-direction: column;
}
.rep-pool-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.rep-pool-val {
font-size: 1.45rem;
font-weight: 900;
color: #f59e0b;
}
.rep-pool-meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.72rem;
color: var(--text-secondary);
border-left: 1px solid var(--border-color);
padding-left: 14px;
}
.btn-rep-reset {
display: inline-flex;
align-items: center;
gap: 5px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 3px 8px;
font-size: 0.72rem;
font-weight: 700;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.btn-rep-reset:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
.btn-rep-reset:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Models Grid */
.rep-models-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1024px) {
.rep-models-grid {
grid-template-columns: 1fr;
}
}
.rep-model-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.rep-model-head {
display: flex;
align-items: center;
gap: 10px;
}
.rep-model-icon {
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
flex-shrink: 0;
}
.rep-icon-b2b {
background: rgba(37, 99, 235, 0.12);
color: #2563eb;
}
.rep-icon-clients {
background: rgba(147, 51, 234, 0.12);
color: #9333ea;
}
.rep-icon-brand {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
}
.rep-model-meta {
flex: 1;
}
.rep-model-meta h4 {
font-size: 0.88rem;
font-weight: 800;
color: var(--text-primary);
margin: 0;
}
.rep-model-sub {
font-size: 0.68rem;
color: var(--text-muted);
}
.rep-allocated-badge {
background: var(--bg-surface-sub);
border: 1px solid var(--border-color);
padding: 4px 8px;
border-radius: var(--radius-sm);
font-size: 0.78rem;
font-weight: 800;
color: #f59e0b;
}
.rep-model-body {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.rep-benefit-desc {
font-size: 0.76rem;
color: var(--text-secondary);
line-height: 1.4;
margin: 0;
}
.rep-bonus-stat {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-surface-sub);
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 0.78rem;
border: 1px solid var(--border-color);
}
.rep-bonus-multi-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.rep-multi-item {
background: var(--bg-surface-sub);
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 0.72rem;
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid var(--border-color);
}
.client-name-tag {
font-size: 0.65rem;
font-weight: 700;
}
.text-amber { color: #f59e0b; }
.text-purple { color: #a855f7; }
.text-cyan { color: #06b6d4; }
.rep-model-actions {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-color);
padding-top: 10px;
}
.rep-btn-group {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.btn-rep-ctrl {
flex: 1;
height: 30px;
background: var(--bg-surface-sub);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 0.76rem;
font-weight: 800;
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
}
.btn-rep-ctrl:hover:not(:disabled) {
background: var(--primary-blue);
color: #fff;
border-color: var(--primary-blue);
}
.btn-rep-sub:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-color: #ef4444;
}
.btn-rep-ctrl:disabled {
opacity: 0.35;
cursor: not-allowed;
}

View file

@ -430,7 +430,9 @@ class ProductionApp {
if (pState && pState.stock >= c.targetAmount) {
pState.stock -= c.targetAmount;
const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
const totalPayout = c.rewardCash * subsidyBonus;
const b2bRep = state.repAllocations?.b2b || 0;
const b2bBonus = 1.0 + 0.005 * b2bRep; // +0.5% per point
const totalPayout = c.rewardCash * subsidyBonus * b2bBonus;
state.cash += totalPayout;
state.lifetimeRevenue += totalPayout;
state.reputation = (state.reputation || 0) + c.rewardRep;
@ -455,6 +457,46 @@ class ProductionApp {
break;
}
case 'ALLOCATE_REP': {
if (!state.repAllocations) {
state.repAllocations = { b2b: 0, clients: 0, brand: 0 };
}
const allocs = state.repAllocations;
const dept = action.department;
const delta = action.delta;
if (delta === 'reset') {
const freed = (allocs.b2b || 0) + (allocs.clients || 0) + (allocs.brand || 0);
state.reputation = (state.reputation || 0) + freed;
state.repAllocations = { b2b: 0, clients: 0, brand: 0 };
this.showToast(`All Corporate Rep Points Recalled to Reserve Pool!`, 'info');
this.sync.queueAction(action);
this.mountActiveView();
} else if (dept && allocs[dept] !== undefined) {
const deltaInt = parseInt(delta, 10);
if (deltaInt > 0) {
const toAlloc = Math.min(deltaInt, state.reputation || 0);
if (toAlloc > 0) {
state.reputation -= toAlloc;
allocs[dept] += toAlloc;
this.showToast(`Allocated +${toAlloc} Rep to ${dept.toUpperCase()}!`, 'success');
this.sync.queueAction(action);
this.mountActiveView();
}
} else if (deltaInt < 0) {
const toFree = Math.min(Math.abs(deltaInt), allocs[dept] || 0);
if (toFree > 0) {
allocs[dept] -= toFree;
state.reputation = (state.reputation || 0) + toFree;
this.showToast(`Recalled -${toFree} Rep from ${dept.toUpperCase()}!`, 'info');
this.sync.queueAction(action);
this.mountActiveView();
}
}
}
break;
}
case 'TRIGGER_IPO': {
this.showConfirmModal({
title: 'Authorize Initial Public Offering (IPO)',

View file

@ -29,11 +29,19 @@ export class SimulationEngine {
if (dt <= 0) return;
const state = this.sm.state;
const perks = state.boardroomPerks || {};
const repAlloc = state.repAllocations || {};
const brandRep = repAlloc.brand || 0;
const clientsRep = repAlloc.clients || 0;
// Boardroom Multipliers
const supplyYieldMult = 1.0 + 0.15 * (perks['supply_sync'] || 0);
const roboticsSpeedMult = 1.0 + 0.20 * (perks['robotics_overclock'] || 0);
const logisticsPriceMult = 1.0 + 0.25 * (perks['logistics_efficiency'] || 0);
// Boardroom & Corporate Reputation Multipliers
// Apex Synergy: +0.2% raw yield per clients rep point
const supplyYieldMult = (1.0 + 0.15 * (perks['supply_sync'] || 0)) * (1.0 + 0.002 * clientsRep);
// Omni Dynamics: +0.15% cycle speed per clients rep point
const roboticsSpeedMult = (1.0 + 0.20 * (perks['robotics_overclock'] || 0)) * (1.0 + 0.0015 * clientsRep);
// Brand Premium: +0.25% wholesale sales price per brand rep point
const logisticsPriceMult = (1.0 + 0.25 * (perks['logistics_efficiency'] || 0)) * (1.0 + 0.0025 * brandRep);
// Vanguard Shipping: +0.25% logistics throughput capacity per clients rep point
const logisticsThroughputMult = 1.0 + 0.0025 * clientsRep;
let tickRevenue = 0.0;
@ -153,8 +161,8 @@ export class SimulationEngine {
const availableToSell = Math.max(0, pState.stock - reservedStock);
if (availableToSell > 0) {
// Logistics fleet capacity: 0.75 items/sec per unit
const maxSellCapacity = logisticsUnits * 0.75 * dt;
// Logistics fleet capacity: 0.75 items/sec per unit * Vanguard client synergy
const maxSellCapacity = logisticsUnits * 0.75 * logisticsThroughputMult * dt;
const sold = Math.min(availableToSell, maxSellCapacity);
pState.stock -= sold;

View file

@ -18,6 +18,11 @@ export function getInitialState() {
totalVentureShares: 0,
ipoCount: 0,
reputation: 0,
repAllocations: {
b2b: 0,
clients: 0,
brand: 0
},
buyMode: '1',
activeTab: 'production',
floorViewMode: 'standard', // 'standard' | 'compact'
@ -109,6 +114,7 @@ export class StateManager {
const merged = { ...fresh, ...parsed };
merged.products = { ...fresh.products, ...(parsed.products || {}) };
merged.boardroomPerks = { ...fresh.boardroomPerks, ...(parsed.boardroomPerks || {}) };
merged.repAllocations = { ...fresh.repAllocations, ...(parsed.repAllocations || {}) };
merged.achievements = { ...fresh.achievements, ...(parsed.achievements || {}) };
merged.stats = { ...fresh.stats, ...(parsed.stats || {}) };
if (!Array.isArray(merged.activeContracts) || merged.activeContracts.length === 0) {
@ -183,6 +189,7 @@ export class StateManager {
}
validated.boardroomPerks = { ...fresh.boardroomPerks, ...(parsed.boardroomPerks || {}) };
validated.repAllocations = { ...fresh.repAllocations, ...(parsed.repAllocations || {}) };
validated.achievements = { ...fresh.achievements, ...(parsed.achievements || {}) };
validated.stats = { ...fresh.stats, ...(parsed.stats || {}) };
if (!Array.isArray(validated.activeContracts) || validated.activeContracts.length === 0) {
@ -207,6 +214,14 @@ export class StateManager {
const startingCashBonuses = [30.0, 500.0, 5000.0, 50000.0, 500000.0, 5000000.0];
const newStartingCash = startingCashBonuses[Math.min(startingBonusPerk, startingCashBonuses.length - 1)];
// Brand Legacy: Retain 10% - 50% of total accumulated Reputation across IPOs
const brandLegacyLvl = this.state.boardroomPerks['brand_legacy'] || 0;
const repRetentionRatios = [0.0, 0.10, 0.20, 0.30, 0.40, 0.50];
const ratio = repRetentionRatios[Math.min(brandLegacyLvl, repRetentionRatios.length - 1)];
const allocs = this.state.repAllocations || {};
const totalRep = (this.state.reputation || 0) + (allocs.b2b || 0) + (allocs.clients || 0) + (allocs.brand || 0);
const preservedRep = Math.floor(totalRep * ratio);
const preservedShares = this.state.ventureShares + earned;
const preservedTotalShares = this.state.totalVentureShares + earned;
const preservedPerks = { ...this.state.boardroomPerks };
@ -221,6 +236,8 @@ export class StateManager {
this.state.ipoCount = preservedIpoCount;
this.state.boardroomPerks = preservedPerks;
this.state.achievements = preservedAchievements;
this.state.reputation = preservedRep;
this.state.repAllocations = { b2b: 0, clients: 0, brand: 0 };
this.save();
this.notify();

View file

@ -51,6 +51,16 @@ export const BOARDROOM_PERKS = [
costShares: [5, 15, 45, 120, 300],
maxLevel: 5,
effectDesc: 'Increases starting capital by $500, $5k, $50k, $500k, $5M after IPO.'
},
{
id: 'brand_legacy',
name: 'Brand Legacy & Heritage',
icon: 'lucide:award',
color: '#0284c7',
description: 'Enduring reputation and corporate goodwill carry forward into your next enterprise.',
costShares: [4, 10, 25, 70, 150],
maxLevel: 5,
effectDesc: 'Preserves 10%, 20%, 30%, 40%, 50% of Corporate Reputation across IPOs.'
}
];

View file

@ -11,6 +11,22 @@ export function mountContractsBoard(container, state, onAction) {
const contracts = state.activeContracts || [];
currentMountedContractUids = contracts.map(c => c.uid).join(',');
const repPool = state.reputation || 0;
const allocs = state.repAllocations || { b2b: 0, clients: 0, brand: 0 };
const b2bRep = allocs.b2b || 0;
const clientsRep = allocs.clients || 0;
const brandRep = allocs.brand || 0;
const totalAllocated = b2bRep + clientsRep + brandRep;
const totalAccumulatedRep = repPool + totalAllocated;
// Computed bonus percentages
const b2bPayoutPct = (b2bRep * 0.5).toFixed(1);
const apexYieldPct = (clientsRep * 0.2).toFixed(1);
const titanDiscountPct = Math.min(50, clientsRep * 0.2).toFixed(1);
const omniSpeedPct = (clientsRep * 0.15).toFixed(1);
const vanguardCapPct = (clientsRep * 0.25).toFixed(1);
const brandPricePct = (brandRep * 0.25).toFixed(1);
let html = `
<div class="contracts-header-pane">
<div class="contracts-header-info">
@ -26,7 +42,137 @@ export function mountContractsBoard(container, state, onAction) {
<span>Ready to Deliver:</span>
<strong id="contract-stats-ready" class="font-mono text-profit">${state.readyContractsCount || 0}</strong>
<span style="margin-left: 8px; border-left: 1px solid var(--border-color); padding-left: 8px;">Completed:</span>
<strong id="contract-stats-completed" class="font-mono">${state.stats.contractsCompleted || 0}</strong>
<strong id="contract-stats-completed" class="font-mono">${state.stats?.contractsCompleted || 0}</strong>
</div>
</div>
<!-- Corporate Reputation Allocation Hub -->
<div class="rep-hub-container">
<div class="rep-hub-header">
<div class="rep-hub-title-group">
<div class="rep-hub-icon">
<iconify-icon icon="lucide:award"></iconify-icon>
</div>
<div>
<h3 class="rep-hub-title">Corporate Standing & Reputation Hub</h3>
<p class="rep-hub-desc">Distribute corporate reputation earned from fulfilled contracts across 3 strategic branches. Points can be reallocated at any time.</p>
</div>
</div>
<div class="rep-pool-card">
<div class="rep-pool-info">
<span class="rep-pool-label">Available Rep Pool</span>
<span class="rep-pool-val font-mono" id="rep-pool-val">${fmtNum(repPool, 0)}</span>
</div>
<div class="rep-pool-meta">
<span title="Total Reputation Earned This IPO Cycle">Accumulated: <strong>${fmtNum(totalAccumulatedRep, 0)}</strong></span>
<button class="btn-rep-reset" id="btn-rep-reset" ${totalAllocated > 0 ? '' : 'disabled'} title="Recall all allocated points back to available pool">
<iconify-icon icon="lucide:rotate-ccw"></iconify-icon> Reset Allocations
</button>
</div>
</div>
</div>
<div class="rep-models-grid">
<!-- Model 1: B2B Standing -->
<div class="rep-model-card">
<div class="rep-model-head">
<div class="rep-model-icon rep-icon-b2b">
<iconify-icon icon="lucide:handshake"></iconify-icon>
</div>
<div class="rep-model-meta">
<h4>B2B Procurement Standing</h4>
<span class="rep-model-sub">Model 1: Tender Optimization</span>
</div>
<div class="rep-allocated-badge font-mono" id="rep-alloc-badge-b2b">${fmtNum(b2bRep, 0)} Rep</div>
</div>
<div class="rep-model-body">
<p class="rep-benefit-desc">Increases cash payouts on all delivered B2B tenders by establishing premier vendor standing.</p>
<div class="rep-bonus-stat">
<span>Contract Cash Bonus:</span>
<strong class="font-mono text-profit" id="rep-bonus-b2b">+${b2bPayoutPct}%</strong>
</div>
</div>
<div class="rep-model-actions">
<div class="rep-btn-group">
<button class="btn-rep-ctrl btn-rep-sub" data-dept="b2b" data-delta="-10" ${b2bRep >= 10 ? '' : 'disabled'}>-10</button>
<button class="btn-rep-ctrl btn-rep-sub" data-dept="b2b" data-delta="-1" ${b2bRep >= 1 ? '' : 'disabled'}>-1</button>
<button class="btn-rep-ctrl btn-rep-add" data-dept="b2b" data-delta="1" ${repPool >= 1 ? '' : 'disabled'}>+1</button>
<button class="btn-rep-ctrl btn-rep-add" data-dept="b2b" data-delta="10" ${repPool >= 10 ? '' : 'disabled'}>+10</button>
</div>
</div>
</div>
<!-- Model 2: Client Strategic Relations -->
<div class="rep-model-card">
<div class="rep-model-head">
<div class="rep-model-icon rep-icon-clients">
<iconify-icon icon="lucide:building-2"></iconify-icon>
</div>
<div class="rep-model-meta">
<h4>Client Strategic Relations</h4>
<span class="rep-model-sub">Model 2: MegaCorp Synergies</span>
</div>
<div class="rep-allocated-badge font-mono" id="rep-alloc-badge-clients">${fmtNum(clientsRep, 0)} Rep</div>
</div>
<div class="rep-model-body">
<p class="rep-benefit-desc">Deepens industrial partnerships with the 4 MegaCorps to unlock multifaceted operational synergies.</p>
<div class="rep-bonus-multi-grid">
<div class="rep-multi-item" title="Apex Resources Synergy">
<span class="client-name-tag text-amber">Apex Raw Yield</span>
<strong class="font-mono text-profit" id="rep-bonus-apex">+${apexYieldPct}%</strong>
</div>
<div class="rep-multi-item" title="Titan Heavy Industries Synergy">
<span class="client-name-tag text-blue">Titan Line Discount</span>
<strong class="font-mono text-profit" id="rep-bonus-titan">-${titanDiscountPct}%</strong>
</div>
<div class="rep-multi-item" title="Omni Dynamics Synergy">
<span class="client-name-tag text-purple">Omni Cycle Speed</span>
<strong class="font-mono text-profit" id="rep-bonus-omni">+${omniSpeedPct}%</strong>
</div>
<div class="rep-multi-item" title="Vanguard Shipping Synergy">
<span class="client-name-tag text-cyan">Vanguard Fleet Flow</span>
<strong class="font-mono text-profit" id="rep-bonus-vanguard">+${vanguardCapPct}%</strong>
</div>
</div>
</div>
<div class="rep-model-actions">
<div class="rep-btn-group">
<button class="btn-rep-ctrl btn-rep-sub" data-dept="clients" data-delta="-10" ${clientsRep >= 10 ? '' : 'disabled'}>-10</button>
<button class="btn-rep-ctrl btn-rep-sub" data-dept="clients" data-delta="-1" ${clientsRep >= 1 ? '' : 'disabled'}>-1</button>
<button class="btn-rep-ctrl btn-rep-add" data-dept="clients" data-delta="1" ${repPool >= 1 ? '' : 'disabled'}>+1</button>
<button class="btn-rep-ctrl btn-rep-add" data-dept="clients" data-delta="10" ${repPool >= 10 ? '' : 'disabled'}>+10</button>
</div>
</div>
</div>
<!-- Model 3: Wholesale Brand Premium -->
<div class="rep-model-card">
<div class="rep-model-head">
<div class="rep-model-icon rep-icon-brand">
<iconify-icon icon="lucide:sparkles"></iconify-icon>
</div>
<div class="rep-model-meta">
<h4>Wholesale Brand Premium</h4>
<span class="rep-model-sub">Model 3: Market Margin Power</span>
</div>
<div class="rep-allocated-badge font-mono" id="rep-alloc-badge-brand">${fmtNum(brandRep, 0)} Rep</div>
</div>
<div class="rep-model-body">
<p class="rep-benefit-desc">Builds prestigious global brand equity, boosting open-market wholesale prices across all logistics fleets.</p>
<div class="rep-bonus-stat">
<span>Wholesale Price Premium:</span>
<strong class="font-mono text-profit" id="rep-bonus-brand">+${brandPricePct}%</strong>
</div>
</div>
<div class="rep-model-actions">
<div class="rep-btn-group">
<button class="btn-rep-ctrl btn-rep-sub" data-dept="brand" data-delta="-10" ${brandRep >= 10 ? '' : 'disabled'}>-10</button>
<button class="btn-rep-ctrl btn-rep-sub" data-dept="brand" data-delta="-1" ${brandRep >= 1 ? '' : 'disabled'}>-1</button>
<button class="btn-rep-ctrl btn-rep-add" data-dept="brand" data-delta="1" ${repPool >= 1 ? '' : 'disabled'}>+1</button>
<button class="btn-rep-ctrl btn-rep-add" data-dept="brand" data-delta="10" ${repPool >= 10 ? '' : 'disabled'}>+10</button>
</div>
</div>
</div>
</div>
</div>
@ -115,6 +261,23 @@ export function mountContractsBoard(container, state, onAction) {
container.querySelectorAll('.btn-fulfill-contract').forEach(btn => {
btn.onclick = () => onAction({ type: 'FULFILL_CONTRACT', uid: btn.dataset.uid });
});
// Rep Hub Allocation Control Listeners
container.querySelectorAll('.btn-rep-ctrl').forEach(btn => {
btn.onclick = () => onAction({
type: 'ALLOCATE_REP',
department: btn.dataset.dept,
delta: btn.dataset.delta
});
});
const btnReset = container.querySelector('#btn-rep-reset');
if (btnReset) {
btnReset.onclick = () => onAction({
type: 'ALLOCATE_REP',
delta: 'reset'
});
}
}
export function updateContractsBoard(container, state, onAction) {
@ -139,6 +302,61 @@ export function updateContractsBoard(container, state, onAction) {
if (elStatsCompleted.textContent !== val) elStatsCompleted.textContent = val;
}
// Update Rep Pool & Allocations Live DOM
const repPool = state.reputation || 0;
const allocs = state.repAllocations || { b2b: 0, clients: 0, brand: 0 };
const b2bRep = allocs.b2b || 0;
const clientsRep = allocs.clients || 0;
const brandRep = allocs.brand || 0;
const totalAllocated = b2bRep + clientsRep + brandRep;
const elRepPool = document.getElementById('rep-pool-val');
if (elRepPool) {
const poolTxt = fmtNum(repPool, 0);
if (elRepPool.textContent !== poolTxt) elRepPool.textContent = poolTxt;
}
const elResetBtn = document.getElementById('btn-rep-reset');
if (elResetBtn) {
elResetBtn.disabled = totalAllocated <= 0;
}
// Model 1: B2B
const elBadgeB2B = document.getElementById('rep-alloc-badge-b2b');
if (elBadgeB2B) elBadgeB2B.textContent = `${fmtNum(b2bRep, 0)} Rep`;
const elBonusB2B = document.getElementById('rep-bonus-b2b');
if (elBonusB2B) elBonusB2B.textContent = `+${(b2bRep * 0.5).toFixed(1)}%`;
// Model 2: Clients
const elBadgeClients = document.getElementById('rep-alloc-badge-clients');
if (elBadgeClients) elBadgeClients.textContent = `${fmtNum(clientsRep, 0)} Rep`;
const elBonusApex = document.getElementById('rep-bonus-apex');
if (elBonusApex) elBonusApex.textContent = `+${(clientsRep * 0.2).toFixed(1)}%`;
const elBonusTitan = document.getElementById('rep-bonus-titan');
if (elBonusTitan) elBonusTitan.textContent = `-${Math.min(50, clientsRep * 0.2).toFixed(1)}%`;
const elBonusOmni = document.getElementById('rep-bonus-omni');
if (elBonusOmni) elBonusOmni.textContent = `+${(clientsRep * 0.15).toFixed(1)}%`;
const elBonusVanguard = document.getElementById('rep-bonus-vanguard');
if (elBonusVanguard) elBonusVanguard.textContent = `+${(clientsRep * 0.25).toFixed(1)}%`;
// Model 3: Brand
const elBadgeBrand = document.getElementById('rep-alloc-badge-brand');
if (elBadgeBrand) elBadgeBrand.textContent = `${fmtNum(brandRep, 0)} Rep`;
const elBonusBrand = document.getElementById('rep-bonus-brand');
if (elBonusBrand) elBonusBrand.textContent = `+${(brandRep * 0.25).toFixed(1)}%`;
// Rep Control Buttons Disabled State
container.querySelectorAll('.btn-rep-ctrl').forEach(btn => {
const dept = btn.dataset.dept;
const delta = parseInt(btn.dataset.delta, 10);
const currentDeptRep = allocs[dept] || 0;
if (delta > 0) {
btn.disabled = repPool < delta;
} else if (delta < 0) {
btn.disabled = currentDeptRep < Math.abs(delta);
}
});
for (const c of contracts) {
const currentStock = state.products[c.productId]?.stock || 0;
const canFulfill = currentStock >= c.targetAmount;

View file

@ -51,7 +51,9 @@ export function renderCompactFacilityCardHtml(p, pState, state, buyMode) {
const nextM = getNextMilestone(pState.lines || 0);
const rates = getProductOutputRates(p, pState, state);
const milestoneMult = rates.milestoneMult || getUnitMilestoneMultiplier(pState.lines || 0);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines || 0, buyMode);
const clientRep = state.repAllocations?.clients || 0;
const lineDiscount = Math.min(0.50, 0.002 * clientRep);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines || 0, buyMode, lineDiscount);
const canAffordBuy = state.cash >= calc.cost;
const currentThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0;
@ -146,7 +148,9 @@ export function updateCompactFacilityCardDom(p, pState, state, engine) {
const buyMode = state.buyMode || '1';
const rates = getProductOutputRates(p, pState, state);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines || 0, buyMode);
const clientRep = state.repAllocations?.clients || 0;
const lineDiscount = Math.min(0.50, 0.002 * clientRep);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines || 0, buyMode, lineDiscount);
const canAffordBuy = state.cash >= calc.cost;
const currentThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0;

View file

@ -49,7 +49,9 @@ export function renderFacilityCardHtml(p, pState, state, buyMode) {
const nextM = getNextMilestone(pState.lines);
const rates = getProductOutputRates(p, pState, state);
const milestoneMult = rates.milestoneMult || getUnitMilestoneMultiplier(pState.lines);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
const clientRep = state.repAllocations?.clients || 0;
const lineDiscount = Math.min(0.50, 0.002 * clientRep);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode, lineDiscount);
const canAffordBuy = state.cash >= calc.cost;
const managerCost = p.baseManagerCost;
const canAffordManager = state.cash >= managerCost;
@ -420,7 +422,9 @@ export function updateFacilityCardDom(p, pState, state, buyMode) {
}
// Expand Button disabled state
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
const clientRep = state.repAllocations?.clients || 0;
const lineDiscount = Math.min(0.50, 0.002 * clientRep);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode, lineDiscount);
const elBtnExpand = document.getElementById(`btn-expand-${p.id}`);
if (elBtnExpand) {
const canAfford = cash >= calc.cost;

View file

@ -1,6 +1,6 @@
import { PRODUCTS, getNextMilestone, getUnitMilestoneMultiplier } from '../../data/products.js';
export function calculateMultiCost(baseCost, currentCount, multiplier) {
export function calculateMultiCost(baseCost, currentCount, multiplier, discount = 0.0) {
const growth = 1.15;
let countToBuy = 1;
@ -15,8 +15,9 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
}
let totalCost = 0;
const factor = Math.max(0.5, 1.0 - discount);
for (let i = 0; i < countToBuy; i++) {
totalCost += baseCost * Math.pow(growth, currentCount + i);
totalCost += baseCost * Math.pow(growth, currentCount + i) * factor;
}
return { count: countToBuy, cost: totalCost };
}
@ -30,14 +31,21 @@ export function getProductOutputRates(p, pState, state) {
}
const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
const roboticsMult = state.upgrades?.automated_robotics ? 2.0 : 1.0;
const baseCycleTime = Math.max(0.1, p.baseCycleTime / (milestoneMult * roboticsMult));
const perks = state.boardroomPerks || {};
const repAlloc = state.repAllocations || {};
const brandRep = repAlloc.brand || 0;
const clientsRep = repAlloc.clients || 0;
const roboticsSpeedMult = (1.0 + 0.20 * (perks['robotics_overclock'] || 0)) * (1.0 + 0.0015 * clientsRep);
const baseCycleTime = Math.max(0.05, p.baseCycleTime / (milestoneMult * roboticsSpeedMult));
const targetThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0;
const isBottlenecked = !!pState.isBottlenecked || targetThrottle <= 0;
// Output velocity: units per second
const isRaw = !p.inputs || p.inputs.length === 0;
const supplyYieldMult = isRaw ? (1.0 + 0.15 * (perks['supply_sync'] || 0)) * (1.0 + 0.002 * clientsRep) : 1.0;
const cyclesPerSec = (1 / baseCycleTime) * targetThrottle;
const prodRate = isBottlenecked ? 0 : cyclesPerSec * p.outputAmount * pState.lines;
const prodRate = isBottlenecked ? 0 : cyclesPerSec * p.outputAmount * pState.lines * supplyYieldMult;
// Logistics revenue rate: items sold passively per second * price
const logisticsUnits = pState.logistics || 0;
@ -45,9 +53,10 @@ export function getProductOutputRates(p, pState, state) {
let revRate = 0;
if (isLogisticsActive && logisticsUnits > 0) {
const logisticsPriceMult = state.upgrades?.global_supply_chain ? 1.5 : 1.0;
const logisticsPriceMult = (1.0 + 0.25 * (perks['logistics_efficiency'] || 0)) * (1.0 + 0.0025 * brandRep);
const logisticsThroughputMult = 1.0 + 0.0025 * clientsRep;
const salePrice = p.basePrice * logisticsPriceMult;
const maxSellCapacity = logisticsUnits * 0.75;
const maxSellCapacity = logisticsUnits * 0.75 * logisticsThroughputMult;
// If stockpiled, sells at max fleet capacity; otherwise bottlenecked by production velocity
const effectiveSellRate = Math.min(maxSellCapacity, pState.stock > 0 ? maxSellCapacity : prodRate);
revRate = effectiveSellRate * salePrice;

View file

@ -29,7 +29,9 @@ export function mountUnitDetailView(container, state, onAction, engine) {
const milestoneMult = rates.milestoneMult || getUnitMilestoneMultiplier(pState.lines || 0);
const nextM = getNextMilestone(pState.lines || 0);
const buyMode = state.buyMode || '1';
const calc = calculateMultiCost(product.baseFacilityCost, pState.lines || 0, buyMode);
const clientRep = state.repAllocations?.clients || 0;
const lineDiscount = Math.min(0.50, 0.002 * clientRep);
const calc = calculateMultiCost(product.baseFacilityCost, pState.lines || 0, buyMode, lineDiscount);
const canAffordBuy = state.cash >= calc.cost;
const canAffordManager = state.cash >= product.baseManagerCost;
const logisticsCost = product.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
@ -666,7 +668,9 @@ export function updateUnitDetailView(container, state, engine) {
// Expand Button disabled state
const buyMode = state.buyMode || '1';
const calc = calculateMultiCost(product.baseFacilityCost, pState.lines || 0, buyMode);
const clientRep = state.repAllocations?.clients || 0;
const lineDiscount = Math.min(0.50, 0.002 * clientRep);
const calc = calculateMultiCost(product.baseFacilityCost, pState.lines || 0, buyMode, lineDiscount);
const btnExpand = document.getElementById('detail-btn-expand');
if (btnExpand) {
btnExpand.disabled = cash < calc.cost;