Update product definitions and revenue calculations in Falukant: Adjust product sell costs and production times for better balance. Refactor revenue calculations to focus on profit per minute instead of revenue per minute. Enhance localization files to include new terms related to product unlocks and certificate levels in English, German, and Spanish, improving user experience across languages.

This commit is contained in:
Torsten Schulz (local)
2026-03-26 20:19:49 +01:00
parent 01849c8ffe
commit e0c3b472db
10 changed files with 303 additions and 43 deletions

View File

@@ -64,11 +64,18 @@
<script>
import apiClient from '@/utils/axios.js';
import { showApiError } from '@/utils/feedback.js';
const PRODUCTION_COST_BASE = 6.0;
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14;
export default {
name: "ProductionSection",
props: {
branchId: { type: Number, required: true },
products: { type: Array, required: true },
currentCertificate: { type: Number, default: 1 },
},
data() {
return {
@@ -125,7 +132,7 @@
calculateProductionCost() {
if (!this.products) return 0;
const product = this.products.find(p => p.id === this.selectedProduct);
return product ? 6 * product.category * this.productionQuantity : 0;
return product ? this.calculateProductionPieceCost(product.category) * this.productionQuantity : 0;
},
calculateProductionDuration(productId) {
if (!this.products || !productId) return 0;
@@ -152,6 +159,17 @@
const secondsLeft = Math.max(Math.floor((end - this.currentTime) / 1000), 0);
return secondsLeft;
},
calculateProductionPieceCost(productCategory) {
const category = Math.max(1, Number(productCategory) || 1);
const certificate = Math.max(1, Number(this.currentCertificate) || 1);
const raw = PRODUCTION_COST_BASE + (category * PRODUCTION_COST_PER_PRODUCT_CATEGORY);
const headroom = Math.max(0, certificate - category);
const discount = Math.min(
headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP,
PRODUCTION_HEADROOM_DISCOUNT_CAP
);
return raw * (1 - discount);
},
async startProduction() {
if (this.selectedProduct && this.productionQuantity > 0) {
this.productionQuantity = Math.min(this.productionQuantity, 200);

View File

@@ -20,7 +20,7 @@
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id" :class="{ highlight: product.id === productWithMaxRevenuePerMinute?.id }">
<tr v-for="product in products" :key="product.id" :class="{ highlight: product.id === productWithMaxProfitPerMinute?.id }">
<td>{{ $t(`falukant.product.${product.labelTr}`) }}</td>
<td>{{ product.knowledges && product.knowledges[0] ? product.knowledges[0].knowledge : 0 }}</td>
<td>{{ calculateProductRevenue(product).absolute }}</td>
@@ -64,12 +64,12 @@
};
},
computed: {
productWithMaxRevenuePerMinute() {
productWithMaxProfitPerMinute() {
if (!this.products || this.products.length === 0) return null;
return this.products.reduce((maxProduct, currentProduct) => {
const currentRevenue = parseFloat(this.calculateProductRevenue(currentProduct).perMinute);
const maxRevenue = maxProduct ? parseFloat(this.calculateProductRevenue(maxProduct).perMinute) : 0;
return currentRevenue > maxRevenue ? currentProduct : maxProduct;
const currentProfit = parseFloat(this.calculateProductProfit(currentProduct).perMinute);
const maxProfit = maxProduct ? parseFloat(this.calculateProductProfit(maxProduct).perMinute) : Number.NEGATIVE_INFINITY;
return currentProfit > maxProfit ? currentProduct : maxProduct;
}, null);
},
},
@@ -223,4 +223,4 @@
font-style: italic;
}
</style>

View File

@@ -143,6 +143,8 @@
"description": "Zeigt die aktuelle Stufe und die Bedingungen für den nächsten Aufstieg.",
"current": "Aktuell",
"next": "Nächste Stufe",
"levelMatrix": "Produkte nach Zertifikatsstufe",
"levelLabel": "Stufe {level}",
"score": "Wertung",
"ready": "Für den nächsten Aufstieg bereit",
"notReady": "Bedingungen noch nicht erfüllt",
@@ -259,6 +261,12 @@
"selectionBlocked": "Neue Ausbauten sind im Schuldturm gesperrt."
},
"currentCertificate": "Derzeitiges Zertifikat",
"certificate": {
"title": "Produktfreigaben",
"description": "Zeigt, welche Produkte dein derzeitiges Zertifikat freischaltet und was mit der nächsten Stufe dazukommt.",
"currentUnlocks": "Derzeit freigeschaltet",
"nextUnlocks": "Neu mit Stufe {level}"
},
"tabs": {
"director": "Direktor",
"inventory": "Inventar",

View File

@@ -124,6 +124,8 @@
"description": "Shows your current level and the requirements for the next promotion.",
"current": "Current",
"next": "Next level",
"levelMatrix": "Products by certificate level",
"levelLabel": "Level {level}",
"score": "Score",
"ready": "Ready for the next promotion",
"notReady": "Requirements not met yet",
@@ -313,6 +315,12 @@
"selectionBlocked": "New expansions are blocked while imprisoned for debt."
},
"currentCertificate": "Current certificate",
"certificate": {
"title": "Product unlocks",
"description": "Shows which products your current certificate unlocks and what the next level adds.",
"currentUnlocks": "Currently unlocked",
"nextUnlocks": "New with level {level}"
},
"selection": {
"title": "Branch Selection",
"selected": "Selected Branch",

View File

@@ -133,6 +133,8 @@
"description": "Muestra tu nivel actual y las condiciones para el siguiente ascenso.",
"current": "Actual",
"next": "Siguiente nivel",
"levelMatrix": "Productos por nivel de certificado",
"levelLabel": "Nivel {level}",
"score": "Puntuación",
"ready": "Listo para el siguiente ascenso",
"notReady": "Condiciones aún no cumplidas",
@@ -247,6 +249,12 @@
"selectionBlocked": "Las nuevas ampliaciones están bloqueadas en la prisión por deudas."
},
"currentCertificate": "Certificado actual",
"certificate": {
"title": "Desbloqueos de productos",
"description": "Muestra qué productos habilita tu certificado actual y qué añade el siguiente nivel.",
"currentUnlocks": "Actualmente desbloqueado",
"nextUnlocks": "Nuevo con el nivel {level}"
},
"tabs": {
"director": "Director",
"inventory": "Inventario",

View File

@@ -15,6 +15,28 @@
</div>
</section>
<section class="branch-certificate surface-card">
<div class="branch-certificate__header">
<div>
<h3>{{ $t('falukant.branch.certificate.title') }}</h3>
<p>{{ $t('falukant.branch.certificate.description') }}</p>
</div>
<span class="branch-hero__badge">
{{ $t('falukant.branch.currentCertificate') }}: {{ currentCertificate ?? 1 }}
</span>
</div>
<div class="branch-certificate__grid">
<article class="branch-certificate__block">
<h4>{{ $t('falukant.branch.certificate.currentUnlocks') }}</h4>
<p>{{ currentCertificateProducts.join(', ') }}</p>
</article>
<article v-if="nextCertificateProducts.length" class="branch-certificate__block">
<h4>{{ $t('falukant.branch.certificate.nextUnlocks', { level: nextCertificateLevel }) }}</h4>
<p>{{ nextCertificateProducts.join(', ') }}</p>
</article>
</div>
</section>
<section
v-if="debtorsPrison.active"
class="branch-debt-warning surface-card"
@@ -84,6 +106,7 @@
<ProductionSection
:branchId="selectedBranch.id"
:products="products"
:current-certificate="currentCertificate"
ref="productionSection"
/>
<!-- Tax summary for production -->
@@ -363,6 +386,19 @@ import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
import { showError, showSuccess, showApiError } from '@/utils/feedback.js';
const CERTIFICATE_PRODUCT_LEVELS = [
{ level: 1, products: ['fish', 'meat', 'leather', 'wood', 'stone', 'milk', 'cheese', 'bread', 'wheat', 'grain', 'carrot'] },
{ level: 2, products: ['beer', 'iron', 'copper', 'spices', 'salt', 'sugar', 'vinegar', 'cotton', 'wine'] },
{ level: 3, products: ['gold', 'diamond', 'furniture', 'clothing'] },
{ level: 4, products: ['jewelry', 'painting', 'book', 'weapon', 'armor', 'shield'] },
{ level: 5, products: ['horse', 'ox'] },
];
const PRODUCTION_COST_BASE = 6.0;
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14;
export default {
name: "BranchView",
components: {
@@ -468,6 +504,23 @@ export default {
// 10% Rabatt für Reparatur aller Fahrzeuge
return Math.round(totalCost * 0.9);
},
currentCertificateProducts() {
const certificate = Number(this.currentCertificate || 1);
return CERTIFICATE_PRODUCT_LEVELS
.filter((entry) => entry.level <= certificate)
.flatMap((entry) => entry.products)
.map((productKey) => this.$t(`falukant.product.${productKey}`));
},
nextCertificateLevel() {
const current = Number(this.currentCertificate || 1);
return current < CERTIFICATE_PRODUCT_LEVELS.length ? current + 1 : null;
},
nextCertificateProducts() {
const nextEntry = CERTIFICATE_PRODUCT_LEVELS.find((entry) => entry.level === this.nextCertificateLevel);
return nextEntry
? nextEntry.products.map((productKey) => this.$t(`falukant.product.${productKey}`))
: [];
},
},
async mounted() {
@@ -540,6 +593,17 @@ export default {
},
methods: {
calculateProductionPieceCost(productCategory) {
const category = Math.max(1, Number(productCategory) || 1);
const certificate = Math.max(1, Number(this.currentCertificate) || 1);
const raw = PRODUCTION_COST_BASE + (category * PRODUCTION_COST_PER_PRODUCT_CATEGORY);
const headroom = Math.max(0, certificate - category);
const discount = Math.min(
headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP,
PRODUCTION_HEADROOM_DISCOUNT_CAP
);
return raw * (1 - discount);
},
matchesCurrentUser(eventData) {
if (eventData?.user_id == null) {
return true;
@@ -798,7 +862,7 @@ export default {
const { absolute: revenueAbsoluteStr, perMinute: revenuePerMinuteStr }
= this.calculateProductRevenue(product);
const revenueAbsolute = parseFloat(revenueAbsoluteStr);
const costPerUnit = 6 * product.category;
const costPerUnit = this.calculateProductionPieceCost(product.category);
const profitAbsolute = revenueAbsolute - costPerUnit;
const costPerMinute = product.productionTime > 0
? costPerUnit / product.productionTime
@@ -1267,6 +1331,45 @@ export default {
font-weight: 600;
}
.branch-certificate {
margin-bottom: 16px;
padding: 18px;
}
.branch-certificate__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
}
.branch-certificate__header p {
margin: 6px 0 0;
color: var(--color-text-secondary);
}
.branch-certificate__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.branch-certificate__block {
padding: 14px 16px;
border-radius: var(--radius-lg);
background: rgba(138, 84, 17, 0.06);
}
.branch-certificate__block h4 {
margin: 0 0 8px;
}
.branch-certificate__block p {
margin: 0;
color: var(--color-text-secondary);
}
.branch-tab-content {
margin-top: 16px;
padding: 18px;

View File

@@ -187,6 +187,21 @@
</li>
</ul>
</article>
<article class="certificate-panel__block">
<h4>{{ $t('falukant.overview.certificate.levelMatrix') }}</h4>
<div class="certificate-level-list">
<div
v-for="entry in certificateLevelMatrix"
:key="entry.level"
class="certificate-level-list__item"
:class="{ 'is-current': entry.level === (falukantUser?.certificate ?? 1) }"
>
<strong>{{ $t('falukant.overview.certificate.levelLabel', { level: entry.level }) }}</strong>
<span>{{ entry.products.join(', ') }}</span>
</div>
</div>
</article>
</div>
</section>
@@ -338,6 +353,14 @@ const AVATAR_POSITIONS = {
},
};
const CERTIFICATE_PRODUCT_LEVELS = [
{ level: 1, products: ['fish', 'meat', 'leather', 'wood', 'stone', 'milk', 'cheese', 'bread', 'wheat', 'grain', 'carrot'] },
{ level: 2, products: ['beer', 'iron', 'copper', 'spices', 'salt', 'sugar', 'vinegar', 'cotton', 'wine'] },
{ level: 3, products: ['gold', 'diamond', 'furniture', 'clothing'] },
{ level: 4, products: ['jewelry', 'painting', 'book', 'weapon', 'armor', 'shield'] },
{ level: 5, products: ['horse', 'ox'] },
];
export default {
name: 'FalukantOverviewView',
components: {
@@ -409,6 +432,12 @@ export default {
certificateProgress() {
return this.falukantUser?.certificateProgress || null;
},
certificateLevelMatrix() {
return CERTIFICATE_PRODUCT_LEVELS.map((entry) => ({
level: entry.level,
products: entry.products.map((productKey) => this.$t(`falukant.product.${productKey}`)),
}));
},
routineActions() {
return [
{
@@ -851,6 +880,32 @@ export default {
font-weight: 600;
}
.certificate-level-list {
display: grid;
gap: 10px;
}
.certificate-level-list__item {
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: rgba(138, 84, 17, 0.06);
}
.certificate-level-list__item strong {
color: #7a4b12;
}
.certificate-level-list__item span {
color: var(--color-text-secondary);
}
.certificate-level-list__item.is-current {
background: rgba(68, 138, 86, 0.12);
box-shadow: inset 0 0 0 1px rgba(68, 138, 86, 0.22);
}
.summary-card,
.routine-card {
padding: 18px;